From 269e77912947e81f991e40819260423353cc7f55 Mon Sep 17 00:00:00 2001 From: king Date: Sat, 30 May 2026 08:20:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(vacation):=20=ED=9C=B4=EA=B0=80=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=AA=A8=EB=93=88=20=E2=80=94=20=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=8B=AC=EB=A0=A5=20bar/=EC=97=B0=EC=B0=A8=C2=B7?= =?UTF-8?q?=EB=B0=98=EC=B0=A8=20=EC=8B=A0=EC=B2=AD/=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C/=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=C2=B7=EC=97=B0=EC=B0=A8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vacation_db 전용 저장소(VacationDBStore), VACATION_DB_URL 미설정 시 '설정 필요' 안내 - 월간 달력: 구글식 휴가 bar(주 단위 lane 배정), 일/공휴일 빨강·토요일 파랑 - 신청 폼: 종류/기간/시작·종료 구분(종일/오전/오후)/사유 + 예상 일수 미리보기 - 일수 계산은 서버(compute_days)에서 주말+공휴일(is_red) 제외 재계산 - 워크플로: 작성중/반려→제출→승인|반려, 취소 soft delete, 본인만 수정(작성중/반려) - 권한: vacation(접근)/vacation_approver(승인), admin 통과. 설정은 admin 전용 - 공휴일(vacation_holidays) DB 기반 provider + settings CRUD, 연차잔여(vacation_balances) - 엑셀 내보내기(openpyxl), scripts/sql/vacation_db_init.sql(멱등, 2026 공휴일 seed) - main.py 등록, 메뉴 ready 전환, docs/.env.example/CLAUDE.md 갱신 Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 + CLAUDE.md | 1 + app/main.py | 14 +- app/modules/vacation/__init__.py | 41 + app/modules/vacation/db.py | 485 +++++++++++ app/modules/vacation/router.py | 800 ++++++++++++++++++ app/modules/vacation/store.py | 99 +++ .../vacation/templates/vacation/detail.html | 82 ++ .../vacation/templates/vacation/form.html | 71 ++ .../vacation/templates/vacation/index.html | 125 +++ .../vacation/templates/vacation/pending.html | 47 + .../vacation/templates/vacation/settings.html | 109 +++ app/static/vacation.css | 189 +++++ app/static/vacation.js | 86 ++ docs/DATABASES.md | 34 + docs/DEPLOYMENT.md | 1 + docs/PROJECT_OVERVIEW.md | 7 +- scripts/sql/vacation_db_init.sql | 167 ++++ 18 files changed, 2354 insertions(+), 10 deletions(-) create mode 100644 app/modules/vacation/__init__.py create mode 100644 app/modules/vacation/db.py create mode 100644 app/modules/vacation/router.py create mode 100644 app/modules/vacation/store.py create mode 100644 app/modules/vacation/templates/vacation/detail.html create mode 100644 app/modules/vacation/templates/vacation/form.html create mode 100644 app/modules/vacation/templates/vacation/index.html create mode 100644 app/modules/vacation/templates/vacation/pending.html create mode 100644 app/modules/vacation/templates/vacation/settings.html create mode 100644 app/static/vacation.css create mode 100644 app/static/vacation.js create mode 100644 scripts/sql/vacation_db_init.sql diff --git a/.env.example b/.env.example index 1f074d9..3b946da 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고. # CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db +# ─── 휴가 관리 모듈 (vacation_db) ─── +# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음). +# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고. +# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과. +# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db + # ─── 상품 검색 (itemcode_db 읽기 전용) ─── # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # 미설정 시 검색 비활성 → 수동 등록만 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 5cdd23f..3dcc9fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) - 개인경비 (`app/modules/expense/`, `expense_db`) - 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 +- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/main.py b/app/main.py index 0d755d7..3eb3c38 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,8 @@ from .modules.cupang import build_cupang_store, build_itemcode_reader from .modules.cupang import router as cupang_router from .modules.expense import build_expense_store from .modules.expense import router as expense_router +from .modules.vacation import build_vacation_store +from .modules.vacation import router as vacation_router from .store import ( APPROVER_KEYS, MODULE_KEYS, @@ -105,6 +107,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat _MODULE_TEMPLATE_DIRS = [ BASE_DIR / "modules" / "expense" / "templates", BASE_DIR / "modules" / "cupang" / "templates", + BASE_DIR / "modules" / "vacation" / "templates", ] templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates.env.loader = ChoiceLoader( @@ -128,10 +131,13 @@ app.state.expense_store = build_expense_store( # 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백). app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None) app.state.itemcode_reader = build_itemcode_reader() +# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). +app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None) # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. app.include_router(expense_router) app.include_router(cupang_router) +app.include_router(vacation_router) def public_url_for(request: Request, route_name: str) -> str: @@ -268,10 +274,10 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]: "key": "vacation", "title": "휴가", "subtitle": "Vacation", - "description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.", - "url": "#", - "health_url": None, - "status": "preparing", + "description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.", + "url": "/vacation/", + "health_url": "/vacation/health", + "status": "ready", "category": "관리", }, ] diff --git a/app/modules/vacation/__init__.py b/app/modules/vacation/__init__.py new file mode 100644 index 0000000..702c075 --- /dev/null +++ b/app/modules/vacation/__init__.py @@ -0,0 +1,41 @@ +"""휴가 관리(vacation) 모듈. + +라우터/저장소/템플릿을 한 디렉토리에서 관리한다. +- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation) +- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산) +- 템플릿: `templates/vacation/` + +데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는 +None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음). + +권한: +- `vacation` : 휴가 관리 접근 +- `vacation_approver` : 승인/반려 (admin 은 항상 통과) +""" + +from typing import Any + +from .router import router +from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days + +__all__ = [ + "router", + "VACATION_TYPES", + "STATUSES", + "HALVES", + "HALF_LABELS", + "compute_days", + "build_vacation_store", +] + + +def build_vacation_store(*, dsn: str | None) -> Any: + """VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None. + + JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시. + """ + if not dsn: + return None + from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음) + + return VacationDBStore(dsn) diff --git a/app/modules/vacation/db.py b/app/modules/vacation/db.py new file mode 100644 index 0000000..deed4ee --- /dev/null +++ b/app/modules/vacation/db.py @@ -0,0 +1,485 @@ +"""vacation_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴. +- 연결 정보: 환경변수 `VACATION_DB_URL` + (예: postgresql://vacation_app:@postgres-db:5432/vacation_db) +- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다. + `scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다. + 앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다. +- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게. + +휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다. +공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider). +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone +from decimal import Decimal +from typing import Any + +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half + + +class VacationDBStore: + 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=False, + ) + self._pool.open(wait=False) + + def close(self) -> None: + self._pool.close() + + # ════════════════════════════════════════════════════════════ + # 공휴일 (vacation_holidays) — holiday provider + # ════════════════════════════════════════════════════════════ + def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]: + clause = "" + params: list[Any] = [] + if year: + clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s" + params.append(year) + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC", + params, + ).fetchall() + return [self._holiday_serialize(r) for r in rows] + + def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]: + """[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합. + + 휴가일수 계산/달력 색상의 단일 진실 공급원. + """ + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT holiday_date FROM vacation_holidays " + "WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s", + (date_from, date_to), + ).fetchall() + out: set[str] = set() + for r in rows: + d = r["holiday_date"] + out.add(d.isoformat() if isinstance(d, date) else str(d)) + return out + + def upsert_holiday( + self, + *, + holiday_date: str, + name: str, + kind: str = "public", + is_red: bool = True, + ) -> dict[str, Any]: + hd = (holiday_date or "").strip() + nm = (name or "").strip() + if not hd or not nm: + raise ValueError("공휴일 날짜와 이름은 필수입니다.") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO vacation_holidays (holiday_date, name, kind, is_red) + VALUES (%s, %s, %s, %s) + ON CONFLICT (holiday_date) DO UPDATE + SET name = EXCLUDED.name, + kind = EXCLUDED.kind, + is_red = EXCLUDED.is_red + RETURNING * + """, + (hd, nm, (kind or "public").strip(), bool(is_red)), + ).fetchone() + return self._holiday_serialize(row) + + def delete_holiday(self, *, holiday_id: int) -> None: + with self._pool.connection() as conn: + cur = conn.execute( + "DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,) + ) + if cur.rowcount == 0: + raise KeyError(holiday_id) + + # ════════════════════════════════════════════════════════════ + # 잔여 연차 (vacation_balances) + # ════════════════════════════════════════════════════════════ + def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s", + (user_email.lower().strip(), year), + ).fetchone() + return self._balance_serialize(row) if row else None + + def list_balances(self, *, year: int) -> list[dict[str, Any]]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC", + (year,), + ).fetchall() + return [self._balance_serialize(r) for r in rows] + + def upsert_balance( + self, *, user_email: str, year: int, total_days: float, memo: str = "" + ) -> dict[str, Any]: + email = (user_email or "").lower().strip() + if not email or "@" not in email: + raise ValueError("올바른 이메일이 필요합니다.") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO vacation_balances (user_email, year, total_days, memo) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_email, year) DO UPDATE + SET total_days = EXCLUDED.total_days, + memo = EXCLUDED.memo + RETURNING * + """, + (email, year, total_days, (memo or "").strip()), + ).fetchone() + return self._balance_serialize(row) + + def used_days(self, *, user_email: str, year: int) -> float: + """해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준.""" + with self._pool.connection() as conn: + row = conn.execute( + "SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests " + "WHERE owner = %s AND status = '승인' " + "AND EXTRACT(YEAR FROM start_date) = %s", + (user_email.lower().strip(), year), + ).fetchone() + return float(row["s"]) if row else 0.0 + + def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]: + bal = self.get_balance(user_email=user_email, year=year) + total = float(bal["total_days"]) if bal else 0.0 + used = self.used_days(user_email=user_email, year=year) + return { + "year": year, + "total_days": total, + "used_days": used, + "remaining_days": round(total - used, 2), + "memo": bal["memo"] if bal else "", + } + + # ════════════════════════════════════════════════════════════ + # 휴가 신청 (vacation_requests) + # ════════════════════════════════════════════════════════════ + def list_for(self, owner: str) -> list[dict[str, Any]]: + owner = owner.lower().strip() + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM vacation_requests WHERE owner = %s " + "ORDER BY start_date DESC, created_at DESC", + (owner,), + ).fetchall() + return [self._req_serialize(r) for r in rows] + + def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]: + """[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용). + + 취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함. + """ + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM vacation_requests " + "WHERE start_date <= %s AND end_date >= %s " + "ORDER BY start_date ASC, owner ASC", + (date_to, date_from), + ).fetchall() + return [self._req_serialize(r) for r in rows] + + def get_request(self, *, request_id: str) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM vacation_requests WHERE id = %s", (request_id,) + ).fetchone() + return self._req_serialize(row) if row else None + + def create_request( + self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중" + ) -> dict[str, Any]: + h = self._normalize_payload(payload) + if status not in STATUSES: + status = "작성중" + days = self._calc_days(h) + req_id = uuid.uuid4().hex[:12] + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO vacation_requests + (id, owner, owner_name, vacation_type, start_date, end_date, + start_half, end_half, days, reason, status) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING * + """, + ( + req_id, + owner.lower().strip(), + (owner_name or "").strip(), + h["vacation_type"], + h["start_date"], + h["end_date"], + h["start_half"], + h["end_half"], + days, + h["reason"], + status, + ), + ).fetchone() + return self._req_serialize(row) + + def update_request( + self, *, request_id: str, owner: str, payload: dict[str, Any] + ) -> dict[str, Any]: + """작성중/반려 상태에서만 본인이 수정 가능.""" + owner = owner.lower().strip() + current = self.get_request(request_id=request_id) + if not current: + raise KeyError(request_id) + if current["owner"] != owner: + raise PermissionError("본인 신청만 수정할 수 있습니다.") + if current["status"] not in EDITABLE_STATUSES: + raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.") + h = self._normalize_payload(payload) + days = self._calc_days(h) + with self._pool.connection() as conn: + row = conn.execute( + """ + UPDATE vacation_requests + SET vacation_type = %s, start_date = %s, end_date = %s, + start_half = %s, end_half = %s, days = %s, reason = %s, + status = '작성중', reject_reason = '', + approver_email = NULL, decided_at = NULL + WHERE id = %s AND owner = %s + RETURNING * + """, + ( + h["vacation_type"], + h["start_date"], + h["end_date"], + h["start_half"], + h["end_half"], + days, + h["reason"], + request_id, + owner, + ), + ).fetchone() + if not row: + raise KeyError(request_id) + return self._req_serialize(row) + + # ── 워크플로 ── + def submit(self, *, request_id: str, owner: str) -> dict[str, Any]: + """작성중/반려 → 제출 (본인).""" + owner = owner.lower().strip() + with self._pool.connection() as conn: + row = conn.execute( + """ + UPDATE vacation_requests + SET status = '제출', reject_reason = '', + approver_email = NULL, decided_at = NULL + WHERE id = %s AND owner = %s AND status IN ('작성중', '반려') + RETURNING * + """, + (request_id, owner), + ).fetchone() + if not row: + raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.") + return self._req_serialize(row) + + def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]: + approver_email = approver_email.lower().strip() + with self._pool.connection() as conn: + row = conn.execute( + """ + UPDATE vacation_requests + SET status = '승인', approver_email = %s, decided_at = now(), + reject_reason = '' + WHERE id = %s AND status = '제출' + RETURNING * + """, + (approver_email, request_id), + ).fetchone() + if not row: + raise ValueError("제출 상태가 아니거나 신청이 없습니다.") + return self._req_serialize(row) + + def reject( + self, *, request_id: str, approver_email: str, reason: str + ) -> dict[str, Any]: + if not (reason or "").strip(): + raise ValueError("반려 사유는 필수입니다.") + approver_email = approver_email.lower().strip() + with self._pool.connection() as conn: + row = conn.execute( + """ + UPDATE vacation_requests + SET status = '반려', approver_email = %s, decided_at = now(), + reject_reason = %s + WHERE id = %s AND status = '제출' + RETURNING * + """, + (approver_email, reason.strip(), request_id), + ).fetchone() + if not row: + raise ValueError("제출 상태가 아니거나 신청이 없습니다.") + return self._req_serialize(row) + + def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]: + """soft delete — status='취소'. owner 또는 admin 만.""" + user_email = user_email.lower().strip() + current = self.get_request(request_id=request_id) + if not current: + raise KeyError(request_id) + if not is_admin and current["owner"] != user_email: + raise PermissionError("본인 신청만 취소할 수 있습니다.") + with self._pool.connection() as conn: + row = conn.execute( + "UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *", + (request_id,), + ).fetchone() + return self._req_serialize(row) + + def list_pending(self) -> list[dict[str, Any]]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM vacation_requests WHERE status = '제출' " + "ORDER BY start_date ASC, created_at ASC" + ).fetchall() + return [self._req_serialize(r) for r in rows] + + def list_for_export( + self, *, date_from: str | None = None, date_to: str | None = None + ) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if date_from: + clauses.append("end_date >= %s") + params.append(date_from) + if date_to: + clauses.append("start_date <= %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 vacation_requests {where} " + "ORDER BY start_date ASC, owner ASC", + params, + ).fetchall() + return [self._req_serialize(r) for r in rows] + + # ════════════════════════════════════════════════════════════ + # 정규화 / 계산 / 직렬화 helpers + # ════════════════════════════════════════════════════════════ + def _calc_days(self, h: dict[str, Any]) -> float: + holidays = self.red_holiday_set( + date_from=h["start_date"], date_to=h["end_date"] + ) + return compute_days( + start_date=date.fromisoformat(h["start_date"]), + end_date=date.fromisoformat(h["end_date"]), + start_half=h["start_half"], + end_half=h["end_half"], + vacation_type=h["vacation_type"], + holidays=holidays, + ) + + @staticmethod + def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]: + vtype = str(payload.get("vacation_type") or "연차").strip() + if vtype not in VACATION_TYPES: + vtype = "연차" + start_date = str(payload.get("start_date") or "").strip() + end_date = str(payload.get("end_date") or "").strip() + if not start_date: + raise ValueError("시작일은 필수입니다.") + if not end_date: + end_date = start_date + # 날짜 형식/순서 검증 + try: + sd = date.fromisoformat(start_date) + ed = date.fromisoformat(end_date) + except ValueError: + raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).") + if ed < sd: + raise ValueError("종료일이 시작일보다 빠를 수 없습니다.") + start_half = normalize_half(payload.get("start_half")) + end_half = normalize_half(payload.get("end_half")) + # 반차 종류면 단일 일자로 강제 + if vtype in ("오전반차", "오후반차"): + end_date = start_date + start_half = "am" if vtype == "오전반차" else "pm" + end_half = start_half + return { + "vacation_type": vtype, + "start_date": start_date, + "end_date": end_date, + "start_half": start_half, + "end_half": end_half, + "reason": str(payload.get("reason") or "").strip(), + } + + @staticmethod + def _num(v: Any) -> float: + if isinstance(v, Decimal): + return float(v) + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + @classmethod + def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + for k in ("start_date", "end_date"): + v = out.get(k) + if isinstance(v, date): + out[k] = v.isoformat() + for k in ("created_at", "updated_at", "decided_at"): + v = out.get(k) + if isinstance(v, datetime): + out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out["days"] = cls._num(out.get("days", 0)) + return out + + @staticmethod + def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + out["id"] = int(out["id"]) + v = out.get("holiday_date") + if isinstance(v, date): + out["holiday_date"] = v.isoformat() + out["is_red"] = bool(out.get("is_red", True)) + for k in ("created_at", "updated_at"): + v = out.get(k) + if isinstance(v, datetime): + out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + return out + + @classmethod + def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + out["id"] = int(out["id"]) + out["year"] = int(out["year"]) + out["total_days"] = cls._num(out.get("total_days", 0)) + out["used_days"] = cls._num(out.get("used_days", 0)) + for k in ("created_at", "updated_at"): + v = out.get(k) + if isinstance(v, datetime): + out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + return out diff --git a/app/modules/vacation/router.py b/app/modules/vacation/router.py new file mode 100644 index 0000000..dc18896 --- /dev/null +++ b/app/modules/vacation/router.py @@ -0,0 +1,800 @@ +"""휴가 관리 모듈 라우터. + +- 경로: /vacation +- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사. + 승인/반려는 `vacation_approver` 또는 admin. +- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용. + VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다. +""" + +from __future__ import annotations + +import calendar as _calendar +import io +from datetime import date, datetime +from typing import Any + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import ( + HTMLResponse, + JSONResponse, + RedirectResponse, + StreamingResponse, +) + +from .store import HALF_LABELS, HALVES, VACATION_TYPES + +router = APIRouter(prefix="/vacation", tags=["vacation"]) + + +# ──────────────────────────────────────────────────────────── +# 공용 헬퍼 +# ──────────────────────────────────────────────────────────── +def _store(request: Request) -> Any: + return getattr(request.app.state, "vacation_store", None) + + +def _require_user(request: Request) -> dict[str, Any]: + 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, "vacation"): + raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.") + return user + + +def _require_approver(request: Request) -> dict[str, Any]: + from app.main import get_current_user_record # noqa: WPS433 + from app.store import has_module, is_admin # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + raise HTTPException(status_code=401, detail="로그인이 필요합니다.") + if not (is_admin(user) or has_module(user, "vacation_approver")): + raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.") + return user + + +def _is_approver(request: Request, user: dict[str, Any]) -> bool: + from app.store import has_module, is_admin # noqa: WPS433 + + return is_admin(user) or has_module(user, "vacation_approver") + + +def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return render_template( + request, + "denied.html", + { + "reason": "휴가 관리 모듈이 아직 설정되지 않았습니다. " + "VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 " + "vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.", + "user": user, + "is_admin": is_admin(user), + "nav_items": build_erp_nav(user, active="vacation"), + }, + status_code=503, + ) + + +def _guard( + request: Request, +) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse: + """로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.""" + from app.main import get_current_user_record, render_template # noqa: WPS433 + 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, "vacation"): + return render_template( + request, + "denied.html", + {"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + store = _store(request) + if store is None: + return _render_config_needed(request, user) + return store, user + + +def _ym(request: Request) -> tuple[int, int]: + today = date.today() + try: + year = int(request.query_params.get("year") or today.year) + month = int(request.query_params.get("month") or today.month) + except ValueError: + year, month = today.year, today.month + if not (1 <= month <= 12): + year, month = today.year, today.month + return year, month + + +# 상태별 bar 스타일 클래스 (CSS 와 매핑) +_STATUS_CLASS = { + "작성중": "vac-bar-draft", + "제출": "vac-bar-submit", + "승인": "vac-bar-approve", + "반려": "vac-bar-reject", + "취소": "vac-bar-cancel", +} + + +def _assign_lanes(bars: list[dict[str, Any]]) -> int: + """주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정. + + bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환. + """ + bars.sort(key=lambda b: (b["start_col"], -b["span"])) + lane_end: list[int] = [] # lane 별 마지막 점유 col(포함) + for b in bars: + placed = False + for li, end_col in enumerate(lane_end): + if b["start_col"] > end_col: + b["lane"] = li + lane_end[li] = b["start_col"] + b["span"] - 1 + placed = True + break + if not placed: + b["lane"] = len(lane_end) + lane_end.append(b["start_col"] + b["span"] - 1) + return len(lane_end) + + +def _build_calendar( + store: Any, year: int, month: int, sel: str +) -> dict[str, Any]: + """월간 달력 데이터 + bar lane 레이아웃 계산.""" + cal = _calendar.Calendar(firstweekday=6) # 일요일 시작 + weeks_dates = cal.monthdatescalendar(year, month) + range_start = weeks_dates[0][0] + range_end = weeks_dates[-1][-1] + + red_set = store.red_holiday_set( + date_from=range_start.isoformat(), date_to=range_end.isoformat() + ) + holiday_names = { + h["holiday_date"]: h["name"] + for h in store.list_holidays() + if h.get("is_red") + } + requests = store.list_overlapping( + date_from=range_start.isoformat(), date_to=range_end.isoformat() + ) + + today = date.today() + weeks: list[dict[str, Any]] = [] + for week in weeks_dates: + w_start, w_end = week[0], week[-1] + days = [ + { + "date": d.isoformat(), + "day": d.day, + "in_month": d.month == month, + "is_today": d == today, + "is_selected": d.isoformat() == sel, + "is_sunday": d.weekday() == 6, + "is_saturday": d.weekday() == 5, + "is_holiday": d.isoformat() in red_set, + "holiday_name": holiday_names.get(d.isoformat(), ""), + } + for d in week + ] + # 이 주에 걸치는 bar segment + bars: list[dict[str, Any]] = [] + for r in requests: + rs = date.fromisoformat(r["start_date"]) + re_ = date.fromisoformat(r["end_date"]) + if re_ < w_start or rs > w_end: + continue + seg_start = max(rs, w_start) + seg_end = min(re_, w_end) + start_col = (seg_start - w_start).days # 0..6 + span = (seg_end - seg_start).days + 1 + label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}" + bars.append( + { + "id": r["id"], + "label": label, + "status": r["status"], + "status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"), + "start_col": start_col, + "span": span, + "continues_left": rs < w_start, + "continues_right": re_ > w_end, + "days": r["days"], + } + ) + lane_count = _assign_lanes(bars) + weeks.append({"days": days, "bars": bars, "lane_count": lane_count}) + + return {"weeks": weeks, "requests": requests, "sel_date": sel} + + +# ════════════════════════════════════════════════════════════ +# 메인 — 월간 달력 + 선택일 휴가 리스트 +# ════════════════════════════════════════════════════════════ +@router.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + store, user = guard + + year, month = _ym(request) + sel = request.query_params.get("date") or "" + today = date.today() + if not sel: + sel = ( + today.isoformat() + if (today.year == year and today.month == month) + else f"{year:04d}-{month:02d}-01" + ) + + caldata = _build_calendar(store, year, month, sel) + sel_requests = [ + r + for r in caldata["requests"] + if r["start_date"] <= sel <= r["end_date"] + ] + + prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1) + next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1) + + summary = store.balance_summary(user_email=user["email"], year=year) + is_approver = _is_approver(request, user) + pending_count = len(store.list_pending()) if is_approver else 0 + + return render_template( + request, + "vacation/index.html", + { + "user": user, + "is_admin": is_admin(user), + "is_approver": is_approver, + "pending_count": pending_count, + "nav_items": build_erp_nav(user, active="vacation"), + "page_title": "휴가 관리", + "page_subtitle": f"{year}년 {month}월 휴가 달력", + "year": year, + "month": month, + "prev_y": prev_y, "prev_m": prev_m, + "next_y": next_y, "next_m": next_m, + "today": today.isoformat(), + "weekdays": ["일", "월", "화", "수", "목", "금", "토"], + "weeks": caldata["weeks"], + "selected_date": sel, + "sel_requests": sel_requests, + "balance": summary, + "half_labels": HALF_LABELS, + }, + ) + + +# ════════════════════════════════════════════════════════════ +# 휴가 신청 — 등록 / 수정 / 상세 +# ════════════════════════════════════════════════════════════ +def _form_context(request: Request, user: dict[str, Any]) -> dict[str, Any]: + from app.main import build_erp_nav # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return { + "user": user, + "is_admin": is_admin(user), + "is_approver": _is_approver(request, user), + "nav_items": build_erp_nav(user, active="vacation"), + "vacation_types": list(VACATION_TYPES), + "halves": list(HALVES), + "half_labels": HALF_LABELS, + } + + +@router.get("/new", response_class=HTMLResponse) +async def new_form(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + _store_obj, user = guard + ctx = _form_context(request, user) + ctx.update( + { + "page_title": "휴가 신청", + "page_subtitle": "휴가 종류 · 기간 · 사유 입력", + "mode": "new", + "req": None, + "default_date": date.today().isoformat(), + } + ) + return render_template(request, "vacation/form.html", ctx) + + +def _payload_from_form( + vacation_type: str, + start_date: str, + end_date: str, + start_half: str, + end_half: str, + reason: str, +) -> dict[str, Any]: + return { + "vacation_type": vacation_type, + "start_date": start_date, + "end_date": end_date or start_date, + "start_half": start_half, + "end_half": end_half, + "reason": reason, + } + + +@router.post("/new") +async def create( + request: Request, + vacation_type: str = Form(...), + start_date: str = Form(...), + end_date: str = Form(""), + start_half: str = Form("full"), + end_half: str = Form("full"), + reason: str = Form(""), + action: str = Form("submit"), # "draft" | "submit" + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + status = "작성중" if action == "draft" else "제출" + payload = _payload_from_form( + vacation_type, start_date, end_date, start_half, end_half, reason + ) + try: + req = store.create_request( + owner=user["email"], + owner_name=user.get("name") or user["email"], + payload=payload, + status=status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303) + + +@router.get("/pending", response_class=HTMLResponse) +async def pending_page(request: Request) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + store, user = guard + if not _is_approver(request, user): + return render_template( + request, + "denied.html", + {"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + pending = store.list_pending() + return render_template( + request, + "vacation/pending.html", + { + "user": user, + "is_admin": is_admin(user), + "is_approver": True, + "nav_items": build_erp_nav(user, active="vacation"), + "page_title": "휴가 — 승인 대기", + "page_subtitle": f"제출 상태 {len(pending)}건", + "items": pending, + "half_labels": HALF_LABELS, + }, + ) + + +@router.get("/settings", response_class=HTMLResponse) +async def settings_page(request: Request) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + store, user = guard + if not is_admin(user): + return render_template( + request, + "denied.html", + {"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False}, + status_code=403, + ) + today = date.today() + try: + year = int(request.query_params.get("year") or today.year) + except ValueError: + year = today.year + balances = store.list_balances(year=year) + for b in balances: + b["used_days"] = store.used_days(user_email=b["user_email"], year=year) + return render_template( + request, + "vacation/settings.html", + { + "user": user, + "is_admin": True, + "is_approver": True, + "nav_items": build_erp_nav(user, active="vacation"), + "page_title": "휴가 — 설정", + "page_subtitle": "공휴일 · 연차 잔여 관리", + "year": year, + "holidays": store.list_holidays(year=year), + "balances": balances, + }, + ) + + +@router.post("/settings/holidays") +async def holiday_upsert( + request: Request, + holiday_date: str = Form(...), + name: str = Form(...), + kind: str = Form("public"), + is_red: str = Form(""), + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + from app.store import is_admin # noqa: WPS433 + + if not is_admin(user): + raise HTTPException(status_code=403, detail="관리자만 가능합니다.") + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.upsert_holiday( + holiday_date=holiday_date, + name=name, + kind=kind, + is_red=is_red in ("1", "true", "on", "True"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + year = holiday_date[:4] + return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303) + + +@router.post("/settings/holidays/{holiday_id:int}/delete") +async def holiday_delete( + request: Request, + holiday_id: int, + year: str = Form(""), + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + from app.store import is_admin # noqa: WPS433 + + if not is_admin(user): + raise HTTPException(status_code=403, detail="관리자만 가능합니다.") + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.delete_holiday(holiday_id=holiday_id) + except KeyError: + raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.") + suffix = f"?year={year}" if year else "" + return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303) + + +@router.post("/settings/balances") +async def balance_upsert( + request: Request, + user_email: str = Form(...), + year: int = Form(...), + total_days: float = Form(...), + memo: str = Form(""), + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + from app.store import is_admin # noqa: WPS433 + + if not is_admin(user): + raise HTTPException(status_code=403, detail="관리자만 가능합니다.") + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.upsert_balance( + user_email=user_email, year=year, total_days=total_days, memo=memo + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303) + + +@router.get("/api/calendar") +async def api_calendar( + request: Request, user: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + """달력 비동기 데이터(JSON). year/month 쿼리.""" + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + year, month = _ym(request) + sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01" + caldata = _build_calendar(store, year, month, sel) + return JSONResponse( + { + "year": year, + "month": month, + "weeks": caldata["weeks"], + "requests": caldata["requests"], + } + ) + + +@router.get("/export.xlsx") +async def export_xlsx( + request: Request, user: dict[str, Any] = Depends(_require_user) +) -> StreamingResponse: + from openpyxl import Workbook # 지연 import + + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + + today = date.today() + try: + year = int(request.query_params.get("year") or today.year) + month = int(request.query_params.get("month") or 0) + except ValueError: + year, month = today.year, 0 + + if 1 <= month <= 12: + last = _calendar.monthrange(year, month)[1] + date_from = f"{year:04d}-{month:02d}-01" + date_to = f"{year:04d}-{month:02d}-{last:02d}" + else: + date_from = f"{year:04d}-01-01" + date_to = f"{year:04d}-12-31" + + rows = store.list_for_export(date_from=date_from, date_to=date_to) + + wb = Workbook() + ws = wb.active + ws.title = "vacation" + header = [ + "신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분", + "사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유", + ] + ws.append(header) + for r in rows: + ws.append([ + r.get("owner_name") or r.get("owner", ""), + r.get("vacation_type", ""), + r.get("start_date", ""), + r.get("end_date", ""), + HALF_LABELS.get(r.get("start_half", "full"), ""), + HALF_LABELS.get(r.get("end_half", "full"), ""), + r.get("days", 0), + r.get("status", ""), + r.get("approver_email", "") or "", + r.get("decided_at", "") or "", + r.get("reason", "") or "", + r.get("reject_reason", "") or "", + ]) + widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30] + for col, w in enumerate(widths, start=1): + ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}" + fname = f"vacation_{scope}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "module": "vacation"} + + +@router.get("/{request_id}", response_class=HTMLResponse) +async def detail(request: Request, request_id: str) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + store, user = guard + req = store.get_request(request_id=request_id) + if not req: + return render_template( + request, "denied.html", + {"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + is_owner = req["owner"] == user["email"] + is_approver = _is_approver(request, user) + if not (is_owner or is_approver): + return render_template( + request, "denied.html", + {"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + return render_template( + request, + "vacation/detail.html", + { + "user": user, + "is_admin": is_admin(user), + "is_approver": is_approver, + "is_owner": is_owner, + "nav_items": build_erp_nav(user, active="vacation"), + "page_title": "휴가 상세", + "page_subtitle": f"{req['start_date']} · {req['vacation_type']}", + "req": req, + "half_labels": HALF_LABELS, + "can_edit": is_owner and req["status"] in ("작성중", "반려"), + }, + ) + + +@router.get("/{request_id}/edit", response_class=HTMLResponse) +async def edit_form(request: Request, request_id: str) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + store, user = guard + req = store.get_request(request_id=request_id) + if not req: + return render_template( + request, "denied.html", + {"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + if req["owner"] != user["email"]: + return render_template( + request, "denied.html", + {"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + if req["status"] not in ("작성중", "반려"): + return render_template( + request, "denied.html", + {"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)}, + status_code=409, + ) + ctx = _form_context(request, user) + ctx.update( + { + "page_title": "휴가 신청 수정", + "page_subtitle": "작성중/반려 상태만 수정 가능", + "mode": "edit", + "req": req, + "default_date": req["start_date"], + } + ) + return render_template(request, "vacation/form.html", ctx) + + +@router.post("/{request_id}/edit") +async def update( + request: Request, + request_id: str, + vacation_type: str = Form(...), + start_date: str = Form(...), + end_date: str = Form(""), + start_half: str = Form("full"), + end_half: str = Form("full"), + reason: str = Form(""), + action: str = Form("save"), # "save" | "submit" + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + payload = _payload_from_form( + vacation_type, start_date, end_date, start_half, end_half, reason + ) + try: + store.update_request(request_id=request_id, owner=user["email"], payload=payload) + if action == "submit": + store.submit(request_id=request_id, owner=user["email"]) + except KeyError: + raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.") + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{request_id}", status_code=303) + + +@router.post("/{request_id}/submit") +async def submit( + request: Request, + request_id: str, + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.submit(request_id=request_id, owner=user["email"]) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{request_id}", status_code=303) + + +@router.post("/{request_id}/approve") +async def approve( + request: Request, + request_id: str, + user: dict[str, Any] = Depends(_require_approver), +) -> RedirectResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.approve(request_id=request_id, approver_email=user["email"]) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{request_id}", status_code=303) + + +@router.post("/{request_id}/reject") +async def reject( + request: Request, + request_id: str, + reject_reason: str = Form(...), + user: dict[str, Any] = Depends(_require_approver), +) -> RedirectResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.reject( + request_id=request_id, approver_email=user["email"], reason=reject_reason + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{request_id}", status_code=303) + + +@router.post("/{request_id}/cancel") +async def cancel( + request: Request, + request_id: str, + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + from app.store import is_admin # noqa: WPS433 + + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="vacation_db 미설정") + try: + store.cancel( + request_id=request_id, user_email=user["email"], is_admin=is_admin(user) + ) + except KeyError: + raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.") + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + return RedirectResponse(url=f"/vacation/{request_id}", status_code=303) diff --git a/app/modules/vacation/store.py b/app/modules/vacation/store.py new file mode 100644 index 0000000..d0add8f --- /dev/null +++ b/app/modules/vacation/store.py @@ -0,0 +1,99 @@ +"""휴가 관리 모듈 상수 및 순수 계산 헬퍼. + +- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`). + 운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다. + VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다. +- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다. + 공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리). +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Iterable + +# 휴가 종류 (한글 라벨 그대로 저장) +VACATION_TYPES: tuple[str, ...] = ( + "연차", + "오전반차", + "오후반차", + "병가", + "경조", + "대체휴무", + "기타", +) + +# 신청 상태 워크플로 +STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소") + +# 시작/종료일 구분 +HALVES: tuple[str, ...] = ("full", "am", "pm") +HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"} + +# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용 +EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려") + +# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제) +_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"} + + +def _daterange(start: date, end: date) -> Iterable[date]: + cur = start + while cur <= end: + yield cur + cur += timedelta(days=1) + + +def is_working_day(d: date, holidays: set[str]) -> bool: + """주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일.""" + if d.weekday() >= 5: # 5=토, 6=일 + return False + return d.isoformat() not in holidays + + +def compute_days( + *, + start_date: date, + end_date: date, + start_half: str = "full", + end_half: str = "full", + vacation_type: str = "연차", + holidays: set[str] | None = None, +) -> float: + """휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지). + + - 주말 + 공휴일(is_red) 제외. + - 오전/오후 반차는 0.5일. + - 같은 날 + 반차면 0.5일. + - 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감. + - 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제. + """ + holidays = holidays or set() + if end_date < start_date: + return 0.0 + + # 반차 종류는 단일 일자 0.5 + if vacation_type in _HALF_DAY_TYPES: + if is_working_day(start_date, holidays): + return 0.5 + return 0.0 + + working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)] + n = len(working) + if n == 0: + return 0.0 + + if start_date == end_date: + return 0.5 if start_half in ("am", "pm") else 1.0 + + total = float(n) + if start_half in ("am", "pm") and is_working_day(start_date, holidays): + total -= 0.5 + if end_half in ("am", "pm") and is_working_day(end_date, holidays): + total -= 0.5 + return max(0.0, total) + + +def normalize_half(value: str | None) -> str: + v = (value or "full").strip().lower() + return v if v in HALVES else "full" diff --git a/app/modules/vacation/templates/vacation/detail.html b/app/modules/vacation/templates/vacation/detail.html new file mode 100644 index 0000000..da8fdfb --- /dev/null +++ b/app/modules/vacation/templates/vacation/detail.html @@ -0,0 +1,82 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+
+ +
+
+

{{ req.owner_name or req.owner }}

+ {{ req.owner }} +
+ {% set badge = 'erp-badge-neutral' %} + {% if req.status == '승인' %}{% set badge = 'erp-badge-success' %} + {% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %} + {% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %} + {{ req.status }} +
+ +
+
휴가 종류
{{ req.vacation_type }}
+
사용 일수
{{ req.days }}
+
시작일
{{ req.start_date }} ({{ half_labels[req.start_half] }})
+
종료일
{{ req.end_date }} ({{ half_labels[req.end_half] }})
+ {% if req.approver_email %} +
승인자
{{ req.approver_email }}
+
결재일시
{{ req.decided_at }}
+ {% endif %} +
+ + {% if req.reason %} +
+
사유
+

{{ req.reason }}

+
+ {% endif %} + + {% if req.status == '반려' and req.reject_reason %} +
+
반려 사유
+

{{ req.reject_reason }}

+
+ {% endif %} + + + {% if is_approver and req.status == '제출' %} +
+
+ +
+
+ + +
+
+ {% endif %} + +
+ ◀◀ 달력 + + {% if is_owner and req.status in ('작성중', '반려') %} +
+ +
+ {% endif %} + + {% if req.status != '취소' and (is_owner or is_admin) %} +
+ +
+ {% endif %} + + {% if can_edit %} + 수정 + {% endif %} +
+ +
+
+{% endblock %} diff --git a/app/modules/vacation/templates/vacation/form.html b/app/modules/vacation/templates/vacation/form.html new file mode 100644 index 0000000..211a9d1 --- /dev/null +++ b/app/modules/vacation/templates/vacation/form.html @@ -0,0 +1,71 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + {% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %} +
+ +
+ + + + + + + + + +
+ + + +
+ 예상 사용 일수: + (주말 제외 · 공휴일은 저장 시 반영) +
+ +
+ + + {% if mode == 'edit' %} + 취소 + {% else %} + 취소 + {% endif %} +
+
+
+{% endblock %} + +{% block scripts %}{% endblock %} diff --git a/app/modules/vacation/templates/vacation/index.html b/app/modules/vacation/templates/vacation/index.html new file mode 100644 index 0000000..59df9f5 --- /dev/null +++ b/app/modules/vacation/templates/vacation/index.html @@ -0,0 +1,125 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + +
+
+ + 휴가 신청 + {% if is_approver %} + 승인 대기 + {% if pending_count %}{{ pending_count }}{% endif %} + + {% endif %} + 엑셀 + {% if is_admin %} + 설정 + {% endif %} +
+
+ {{ balance.total_days }} + · + 사용 {{ balance.used_days }} + · + 잔여 {{ balance.remaining_days }} + {{ year }}년 +
+
+ +
+ +
+
+ +

{{ year }}년 {{ month }}월

+ + 오늘 +
+ +
+
+ {% for wd in weekdays %} +
{{ wd }}
+ {% endfor %} +
+ + {% for week in weeks %} +
+ {% for cell in week.days %} + + {{ cell.day }} + + {% endfor %} + + {% for bar in week.bars %} + + {{ bar.label }} + + {% endfor %} +
+ {% endfor %} +
+ +
+ 제출 + 승인 + 반려 + 취소 +
+
+ + + +
+ +
+{% endblock %} diff --git a/app/modules/vacation/templates/vacation/pending.html b/app/modules/vacation/templates/vacation/pending.html new file mode 100644 index 0000000..703e6ec --- /dev/null +++ b/app/modules/vacation/templates/vacation/pending.html @@ -0,0 +1,47 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+
+ {% if items %} +
+ + + + + + + + + {% for r in items %} + + + + + + + + + {% endfor %} + +
신청자휴가종류기간일수사유동작
{{ r.owner_name or r.owner }}{{ r.vacation_type }}{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}{{ r.days }}{{ r.reason }} +
+
+ +
+
+ + +
+
+
+
+ {% else %} +

승인 대기 중인 휴가 신청이 없습니다.

+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/vacation/templates/vacation/settings.html b/app/modules/vacation/templates/vacation/settings.html new file mode 100644 index 0000000..29200ce --- /dev/null +++ b/app/modules/vacation/templates/vacation/settings.html @@ -0,0 +1,109 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ +
+
+ + +
+
+ +
+ + +
+

공휴일 ({{ year }})

+ +
+ + + + + +
+ +
+ + + + {% for h in holidays %} + + + + + + + + {% else %} + + {% endfor %} + +
날짜이름종류빨강
{{ h.holiday_date }}{{ h.name }}{{ h.kind }}{% if h.is_red %}●{% else %}○{% endif %} +
+ + +
+
등록된 공휴일이 없습니다.
+
+
+ + +
+

연차 잔여 ({{ year }})

+ +
+ + + + + +
+ +
+ + + + {% for b in balances %} + + + + + + + {% else %} + + {% endfor %} + +
이메일연차사용메모
{{ b.user_email }}{{ b.total_days }}{{ b.used_days }}{{ b.memo }}
설정된 연차가 없습니다.
+
+

사용 일수는 승인된 휴가 합계로 자동 계산됩니다.

+
+ +
+
+{% endblock %} diff --git a/app/static/vacation.css b/app/static/vacation.css new file mode 100644 index 0000000..61babd3 --- /dev/null +++ b/app/static/vacation.css @@ -0,0 +1,189 @@ +/* 휴가 관리(vacation) 모듈 스타일. + DESIGN.md 토큰 준수 — 모노크롬, 10/14px radius, 카드 padding 16px. + 캐시 버전: ?v=20260530a (변경 시 템플릿 head_extra 의 ?v= 도 함께 올린다) */ + +:root { + --vac-ash: #e5e5e5; + --vac-ghost: #f2f2f2; + --vac-muted: #737373; + --vac-black: #0a0a0a; + --vac-red: #c22b10; + --vac-blue: #1d4ed8; + --vac-green: #10733a; +} + +.vac { display: flex; flex-direction: column; gap: 16px; } + +/* ── 페이지 액션 + 잔여 요약 ── */ +.vac-actions { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; +} +.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.vac-mini { margin-left: 4px; } +.vac-balance { + display: flex; align-items: baseline; gap: 8px; + font-size: 14px; color: var(--vac-black); +} +.vac-balance strong { font-weight: 600; } +.vac-bal-sep { color: var(--vac-ash); } +.vac-bal-remain strong { color: var(--vac-green); } +.vac-bal-year { margin-left: 6px; font-size: 12px; } + +/* ── 레이아웃: 달력 좌 / 리스트 우 ── */ +.vac-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 16px; + align-items: start; +} +@media (max-width: 1100px) { + .vac-layout { grid-template-columns: 1fr; } +} + +/* ── 달력 카드 ── */ +.vac-cal-card { padding: 16px; } +.vac-cal-head { + display: flex; align-items: center; gap: 10px; margin-bottom: 12px; +} +.vac-cal-title { font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.45px; } +.vac-nav-btn { padding: 4px 12px; font-size: 16px; line-height: 1; } +.vac-today-btn { margin-left: auto; } + +.vac-cal { border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden; } + +.vac-wd-row { + display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); + background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash); +} +.vac-wd { + text-align: center; padding: 6px 0; font-size: 13px; font-weight: 500; + color: var(--vac-black); +} + +/* 주(week) — 1행=날짜, 2행+=bar lane */ +.vac-week { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-template-rows: 30px; + grid-auto-rows: 21px; + border-bottom: 1px solid var(--vac-ash); + padding-bottom: 4px; + position: relative; +} +.vac-week:last-child { border-bottom: none; } + +.vac-day { + border-right: 1px solid var(--vac-ash); + padding: 4px 6px; text-decoration: none; + display: block; min-height: 30px; +} +.vac-day:nth-child(7n) { border-right: none; } +.vac-day-num { font-size: 13px; font-weight: 500; color: var(--vac-black); } +.vac-out { background: #fafafa; } +.vac-out .vac-day-num { color: #bbb; } +.vac-today { background: #f5f5f5; } +.vac-today .vac-day-num { + background: var(--vac-black); color: #fff; border-radius: 9999px; + padding: 1px 7px; +} +.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); } +.vac-red { color: var(--vac-red) !important; } +.vac-blue { color: var(--vac-blue) !important; } + +/* ── 휴가 bar (구글 달력 스타일) ── */ +.vac-bar { + margin: 1px 3px; padding: 0 6px; height: 18px; line-height: 18px; + border-radius: 6px; font-size: 11px; text-decoration: none; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + align-self: center; +} +.vac-bar-label { pointer-events: none; } +.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; } +.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; } + +.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); } +.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); } +.vac-bar-approve { background: var(--vac-black); color: #fff; } +.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; } +.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; } + +/* 범례 */ +.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; } +.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); } +.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; } + +/* ── 선택일 리스트 ── */ +.vac-list-card { padding: 16px; } +.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; } +.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; } +.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.vac-list-link { + display: block; text-decoration: none; color: var(--vac-black); + border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px; +} +.vac-list-link:hover { background: var(--vac-ghost); } +.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.vac-list-meta { font-size: 12px; margin-top: 4px; } +.vac-list-reason { font-size: 12px; margin-top: 4px; } +.vac-empty { padding: 12px 0; } + +/* ── 신청 폼 ── */ +.vac-form-card { padding: 16px; max-width: 760px; } +.vac-form-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; margin-bottom: 12px; +} +.vac-full { grid-column: 1 / -1; } +.vac-days-preview { margin: 12px 0; font-size: 14px; } +.vac-days-preview strong { font-size: 16px; font-weight: 600; } +.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* ── 상세 ── */ +.vac-detail-card { padding: 16px; max-width: 760px; } +.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; } +.vac-detail-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 24px; margin: 16px 0; +} +.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; } +.vac-detail-grid dd { margin: 0; font-size: 14px; } +.vac-detail-block { margin: 12px 0; } +.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; } +.vac-reject-block p { color: var(--vac-red); } +.vac-approve-box { + display: flex; gap: 12px; align-items: center; flex-wrap: wrap; + background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0; +} +.vac-reject-form { display: flex; gap: 6px; align-items: center; } +.vac-inline-form { display: inline; } +.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; } +.vac-push-right { margin-left: auto; } + +/* ── 승인 대기 ── */ +.vac-pending-table .vac-act-col { width: 280px; } +.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.vac-reason-cell { max-width: 260px; } +.erp-btn-sm { padding: 2px 10px; font-size: 12px; } +.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; } + +/* ── 설정 ── */ +.vac-settings-head { margin-bottom: 4px; } +.vac-year-form { display: flex; gap: 8px; align-items: flex-end; } +.vac-year-field { max-width: 140px; } +.vac-settings-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; + align-items: start; +} +@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } } +.vac-set-card { padding: 16px; } +.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; } +.vac-holiday-form, .vac-balance-form { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; margin-bottom: 12px; align-items: end; +} +.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; } +.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; } +.vac-set-scroll { max-height: 420px; overflow: auto; } +.vac-set-note { font-size: 12px; margin-top: 8px; } diff --git a/app/static/vacation.js b/app/static/vacation.js new file mode 100644 index 0000000..2efda5b --- /dev/null +++ b/app/static/vacation.js @@ -0,0 +1,86 @@ +/* 휴가 신청 폼 — 종류에 따른 필드 토글 + 예상 일수 미리보기. + 서버(store.compute_days)가 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */ +(function () { + "use strict"; + var form = document.getElementById("vac-form"); + if (!form) return; + + var type = document.getElementById("vac-type"); + var start = document.getElementById("vac-start"); + var startHalf = document.getElementById("vac-start-half"); + var endField = document.getElementById("vac-end-field"); + var end = document.getElementById("vac-end"); + var endHalfField = document.getElementById("vac-end-half-field"); + var endHalf = document.getElementById("vac-end-half"); + var startHalfField = document.getElementById("vac-start-half-field"); + var out = document.getElementById("vac-days-out"); + + function isHalfType(t) { return t === "오전반차" || t === "오후반차"; } + + function parseDate(s) { + if (!s) return null; + var p = s.split("-"); + if (p.length !== 3) return null; + return new Date(+p[0], +p[1] - 1, +p[2]); + } + + function workingDays(sd, ed) { + var n = 0; + var d = new Date(sd.getTime()); + while (d <= ed) { + var wd = d.getDay(); // 0=일, 6=토 + if (wd !== 0 && wd !== 6) n++; + d.setDate(d.getDate() + 1); + } + return n; + } + + function isWorking(d) { + var wd = d.getDay(); + return wd !== 0 && wd !== 6; + } + + function recalc() { + var t = type.value; + var half = isHalfType(t); + + // 반차 종류면 종료일/구분 숨김, 단일 0.5 + endField.style.display = half ? "none" : ""; + endHalfField.style.display = half ? "none" : ""; + startHalfField.style.display = half ? "none" : ""; + + if (half) { + out.textContent = "0.5"; + return; + } + + var sd = parseDate(start.value); + var ed = parseDate(end.value) || sd; + if (!sd || !ed || ed < sd) { out.textContent = "—"; return; } + + if (sd.getTime() === ed.getTime()) { + out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1"; + return; + } + + var n = workingDays(sd, ed); + var total = n; + if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5; + if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5; + out.textContent = total > 0 ? String(total) : "0"; + } + + // 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌 + start.addEventListener("change", function () { + if (!end.value || parseDate(end.value) < parseDate(start.value)) { + end.value = start.value; + } + recalc(); + }); + + [type, end, startHalf, endHalf].forEach(function (el) { + if (el) el.addEventListener("change", recalc); + }); + + recalc(); +})(); diff --git a/docs/DATABASES.md b/docs/DATABASES.md index fc6dcea..8de1b3d 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -222,6 +222,40 @@ cd /opt/www/main && docker compose up -d --build --- +## vacation_db 스키마 / 초기화 + +DDL: `scripts/sql/vacation_db_init.sql` (멱등). DB·역할(`vacation_app`)·테이블·인덱스·트리거·2026 공휴일 seed 를 한 번에 생성. **JSON 폴백 없음** — `VACATION_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시. + +테이블: + +| 테이블 | 용도 | +| --- | --- | +| `vacation_requests` | 휴가 신청(헤더). 종류/기간/시작·종료 구분(full/am/pm)/일수/사유/상태/승인자/반려사유 | +| `vacation_holidays` | 공휴일(`holiday_date` UNIQUE). `is_red=true` 면 달력 빨강 + 일수 계산 제외. 관리자가 settings 에서 추가/수정/삭제 | +| `vacation_balances` | 사용자별 연차(`UNIQUE(user_email, year)`). `total_days` 설정, 사용일수는 승인 휴가 합계로 자동 계산 | + +`status` 허용값: `작성중`, `제출`, `승인`, `반려`, `취소`. 워크플로: 작성중/반려 → 제출 → 승인|반려. 삭제는 기본 soft delete(`status='취소'`). 수정은 작성중/반려 상태에서 본인만. + +휴가 일수는 서버(`store.compute_days`)에서 재계산: 주말 + `vacation_holidays(is_red)` 제외, 오전/오후 반차 0.5일, 시작/종료 반차는 각 0.5 차감. 클라이언트 계산은 미리보기(주말만 제외)용. + +권한: `vacation`(접근) / `vacation_approver`(승인·반려). admin 은 항상 통과. 공휴일·연차 설정은 admin 전용. + +### 운영 서버 초기화 (1회, 사용자 승인 후) + +```bash +read -s -p "vacation_app password: " APP_PWD; echo +docker exec -i postgres-db psql -U postgres \ + -v app_password="$APP_PWD" \ + < scripts/sql/vacation_db_init.sql +# main-app .env 에 추가: +# VACATION_DB_URL=postgresql://vacation_app:@postgres-db:5432/vacation_db +cd /opt/www/main && docker compose up -d --build +``` + +> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 공휴일은 연도별로 다르므로 settings 화면에서 추가/수정. + +--- + ## 백업 / 복구 (안전 절차) ### 백업 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2156dbb..0c45297 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -117,6 +117,7 @@ docker compose down # 컨테이너 제거 (볼륨 유지) | `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | | `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | | `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) | +| `VACATION_DB_URL` | 휴가 관리 DB DSN (예: `postgresql://vacation_app:@postgres-db:5432/vacation_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내). 권한키 `vacation`/`vacation_approver` | | `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 | | `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 | diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 5fbc87c..2856111 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -88,12 +88,7 @@ app/modules// | `return_db` | 반품·교환·CS 데이터 | | `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | | `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) | - -### DB 후보 - -| 모듈 | 현재 저장소 | 비고 | -| --- | --- | --- | -| 휴가 | (미개발) | `vacation_db` (승인 후 생성) | +| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) | > 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`. diff --git a/scripts/sql/vacation_db_init.sql b/scripts/sql/vacation_db_init.sql new file mode 100644 index 0000000..f82a5be --- /dev/null +++ b/scripts/sql/vacation_db_init.sql @@ -0,0 +1,167 @@ +-- ===================================================================== +-- vacation_db 초기화 스크립트 (PostgreSQL) — 휴가 관리 모듈 +-- ===================================================================== +-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. +-- +-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. +-- +-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): +-- +-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) +-- read -s -p "vacation_app password: " APP_PWD; echo +-- docker exec -i postgres-db psql -U postgres \ +-- -v app_password="$APP_PWD" \ +-- < scripts/sql/vacation_db_init.sql +-- +-- 2) main-app .env 에 연결 정보 등록 +-- VACATION_DB_URL=postgresql://vacation_app:@postgres-db:5432/vacation_db +-- +-- 3) main-app 재기동 +-- cd /opt/www/main && docker compose up -d --build +-- +-- 주의: +-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). +-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- DB 가 없을 때만 생성 +SELECT 'CREATE DATABASE vacation_db ENCODING ''UTF8'' TEMPLATE template0' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vacation_db') +\gexec + +-- 앱 전용 로그인 역할 (expense_app / cupang_app 패턴과 동일) +SELECT 'CREATE ROLE vacation_app LOGIN PASSWORD ' || quote_literal(:'app_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vacation_app') +\gexec + +-- 항상 최신 비밀번호로 동기화 +SELECT 'ALTER ROLE vacation_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') +\gexec + +GRANT CONNECT ON DATABASE vacation_db TO vacation_app; + +-- vacation_db 컨텍스트로 전환 +\connect vacation_db + +-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── +CREATE OR REPLACE FUNCTION vacation_set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ════════════════════════════════════════════════════════════ +-- 1) 휴가 신청 (헤더) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS vacation_requests ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + owner_name TEXT NOT NULL DEFAULT '', + vacation_type TEXT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + start_half TEXT NOT NULL DEFAULT 'full', + end_half TEXT NOT NULL DEFAULT 'full', + days NUMERIC(5,2) NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '작성중', + approver_email TEXT, + decided_at TIMESTAMPTZ, + reject_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_vacation_req_owner_start ON vacation_requests (owner, start_date DESC); +CREATE INDEX IF NOT EXISTS idx_vacation_req_status ON vacation_requests (status); +CREATE INDEX IF NOT EXISTS idx_vacation_req_range ON vacation_requests (start_date, end_date); + +DROP TRIGGER IF EXISTS trg_vacation_requests_updated ON vacation_requests; +CREATE TRIGGER trg_vacation_requests_updated + BEFORE UPDATE ON vacation_requests + FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 2) 공휴일 (달력 빨강 표시 + 휴가일수 계산 제외 기준) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS vacation_holidays ( + id BIGSERIAL PRIMARY KEY, + holiday_date DATE NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'public', + is_red BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_vacation_holidays_date ON vacation_holidays (holiday_date); + +DROP TRIGGER IF EXISTS trg_vacation_holidays_updated ON vacation_holidays; +CREATE TRIGGER trg_vacation_holidays_updated + BEFORE UPDATE ON vacation_holidays + FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 3) 사용자별 연차 잔여 (연도 단위) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS vacation_balances ( + id BIGSERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + year INTEGER NOT NULL, + total_days NUMERIC(5,2) NOT NULL DEFAULT 0, + used_days NUMERIC(5,2) NOT NULL DEFAULT 0, + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_email, year) +); +CREATE INDEX IF NOT EXISTS idx_vacation_balances_user_year ON vacation_balances (user_email, year); + +DROP TRIGGER IF EXISTS trg_vacation_balances_updated ON vacation_balances; +CREATE TRIGGER trg_vacation_balances_updated + BEFORE UPDATE ON vacation_balances + FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 4) 공휴일 seed — 2026년 대한민국 공휴일 (멱등: ON CONFLICT DO NOTHING) +-- 연도별로 달라지므로 settings 화면에서 추가/수정/삭제 가능. +-- KASI(한국천문연구원) 발표 기준. 새 연도는 settings 또는 본 seed 추가. +-- ════════════════════════════════════════════════════════════ +INSERT INTO vacation_holidays (holiday_date, name, kind) VALUES + ('2026-01-01', '신정', 'public'), + ('2026-02-16', '설날 연휴', 'lunar'), + ('2026-02-17', '설날', 'lunar'), + ('2026-02-18', '설날 연휴', 'lunar'), + ('2026-03-01', '삼일절', 'public'), + ('2026-03-02', '삼일절 대체', 'substitute'), + ('2026-05-05', '어린이날', 'public'), + ('2026-05-24', '부처님오신날', 'lunar'), + ('2026-05-25', '부처님오신날 대체', 'substitute'), + ('2026-06-06', '현충일', 'public'), + ('2026-08-15', '광복절', 'public'), + ('2026-08-17', '광복절 대체', 'substitute'), + ('2026-09-24', '추석 연휴', 'lunar'), + ('2026-09-25', '추석', 'lunar'), + ('2026-09-26', '추석 연휴', 'lunar'), + ('2026-09-28', '추석 대체', 'substitute'), + ('2026-10-03', '개천절', 'public'), + ('2026-10-05', '개천절 대체', 'substitute'), + ('2026-10-09', '한글날', 'public'), + ('2026-12-25', '성탄절', 'public') +ON CONFLICT (holiday_date) DO NOTHING; + +-- ════════════════════════════════════════════════════════════ +-- 5) 권한 (vacation_app: CRUD only) +-- ════════════════════════════════════════════════════════════ +GRANT USAGE ON SCHEMA public TO vacation_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON + vacation_requests, vacation_holidays, vacation_balances + TO vacation_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vacation_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vacation_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO vacation_app; + +SELECT 'vacation_db ready' AS status;