269e779129
- 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 <noreply@anthropic.com>
100 lines
3.2 KiB
Python
100 lines
3.2 KiB
Python
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
|
|
|
|
- 데이터 저장은 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"
|