feat(leave): \uc5f0\ucc28 \ubbf8\ub9ac \ub4f1\ub85d + \uc218\uc544\ud55c \uc790\ub3d9 \uc801\uc6a9 + \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
\u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
\u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
\u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
\u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
\u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
\u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac
Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
\u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
\u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
\u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
\u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc
Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
This commit is contained in:
+118
-4
@@ -209,6 +209,24 @@ class Database:
|
||||
)
|
||||
''')
|
||||
|
||||
# 반복 연차 패턴 테이블 (P2)
|
||||
# pattern 형식:
|
||||
# 'weekly:friday' / 'weekly:mon,wed' (요일 영문 소문자, 콤마 구분)
|
||||
# 'biweekly:friday' (격주)
|
||||
# 'monthly:15' (매월 N일)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS recurring_leaves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern TEXT NOT NULL,
|
||||
leave_type TEXT NOT NULL,
|
||||
days REAL NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
def migrate_break_records_cascade(self):
|
||||
"""break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션).
|
||||
|
||||
@@ -962,12 +980,108 @@ class Database:
|
||||
def get_today_leave_minutes(self) -> int:
|
||||
"""오늘 사용한 연차/반차 시간 조회 (분)"""
|
||||
from datetime import date
|
||||
today = date.today().isoformat()
|
||||
return self.get_leave_minutes_for(date.today().isoformat())
|
||||
|
||||
def get_leave_minutes_for(self, date_str: str) -> int:
|
||||
"""특정 날짜에 등록된 연차 합계를 분 단위로 반환.
|
||||
|
||||
구체 leave_records + 매치되는 recurring_leaves 인스턴스 합산.
|
||||
예정/사용 구분 없음.
|
||||
"""
|
||||
days = self._effective_leave_days_for(date_str)
|
||||
return int(days * self.get_work_minutes())
|
||||
|
||||
def has_full_day_leave(self, date_str: str) -> bool:
|
||||
"""해당 날짜에 종일(또는 그 이상) 연차가 등록되어 있는지.
|
||||
|
||||
구체 + 반복 패턴 모두 검사.
|
||||
"""
|
||||
return self._effective_leave_days_for(date_str) >= 1.0
|
||||
|
||||
def _effective_leave_days_for(self, date_str: str) -> float:
|
||||
"""구체 leave_records + 반복 패턴 매치를 합산한 일수."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?', (today,))
|
||||
days = cursor.fetchone()[0] or 0.0
|
||||
return int(days * self.get_work_minutes())
|
||||
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?',
|
||||
(date_str,))
|
||||
concrete_days = float(cursor.fetchone()[0] or 0.0)
|
||||
|
||||
# 반복 패턴 — 매번 호출되니 lazy import + 가벼운 query
|
||||
try:
|
||||
from core.recurring_leaves import expand_for_date
|
||||
from datetime import datetime as _dt
|
||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||
recs = self.get_recurring_leaves(active_on=date_str)
|
||||
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
||||
except Exception:
|
||||
recurring_days = 0.0
|
||||
|
||||
return concrete_days + recurring_days
|
||||
|
||||
def get_leave_records_by_date(self, date_str: str) -> List[Dict]:
|
||||
"""해당 날짜에 등록된 leave_records 전체 (디스플레이/편집용)."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM leave_records WHERE date = ? ORDER BY id',
|
||||
(date_str,))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_leave_records_by_range(self, start_date: str, end_date: str) -> List[Dict]:
|
||||
"""기간 내 leave_records (스케줄 화면용). start/end inclusive."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT * FROM leave_records
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date ASC, id ASC
|
||||
''', (start_date, end_date))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# ===== 반복 연차 (recurring_leaves) — P2 =====
|
||||
|
||||
def add_recurring_leave(self, pattern: str, leave_type: str, days: float,
|
||||
start_date: str, end_date: str = None,
|
||||
memo: str = None) -> int:
|
||||
"""반복 연차 패턴 등록.
|
||||
|
||||
Args:
|
||||
pattern: 'weekly:friday' / 'biweekly:mon' / 'monthly:15' 등
|
||||
leave_type: '연차' / '반차' / '시간' 등 (표시용)
|
||||
days: 한 회당 차감 일수 (0.5 = 반차)
|
||||
start_date: 시작일 'YYYY-MM-DD'
|
||||
end_date: 종료일 또는 None(=무기한)
|
||||
memo: 옵션
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO recurring_leaves
|
||||
(pattern, leave_type, days, start_date, end_date, memo)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (pattern, leave_type, days, start_date, end_date, memo))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_recurring_leaves(self, active_on: str = None) -> List[Dict]:
|
||||
"""반복 패턴 목록. active_on 지정 시 그날 유효한 것만."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
if active_on:
|
||||
cursor.execute('''
|
||||
SELECT * FROM recurring_leaves
|
||||
WHERE start_date <= ?
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY id
|
||||
''', (active_on, active_on))
|
||||
else:
|
||||
cursor.execute('SELECT * FROM recurring_leaves ORDER BY id')
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def delete_recurring_leave(self, rec_id: int) -> None:
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM recurring_leaves WHERE id = ?', (rec_id,))
|
||||
conn.commit()
|
||||
|
||||
def add_initial_overtime_balance(self, minutes: int):
|
||||
"""초기 연장근무 잔액 추가"""
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
반복 연차 패턴 파싱 + 일자 확장.
|
||||
|
||||
지원 패턴:
|
||||
- 'weekly:friday' — 매주 금요일
|
||||
- 'weekly:mon,wed,fri' — 매주 월·수·금
|
||||
- 'biweekly:friday' — 격주 금요일 (start_date 기준)
|
||||
- 'monthly:15' — 매월 15일 (해당 월에 그 일이 없으면 스킵)
|
||||
|
||||
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range()로 펼친다.
|
||||
같은 날짜에 leave_records(구체 인스턴스)가 이미 있으면 호출자가 합산 로직 책임.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
'tue': 1, 'tuesday': 1,
|
||||
'wed': 2, 'wednesday': 2,
|
||||
'thu': 3, 'thursday': 3,
|
||||
'fri': 4, 'friday': 4,
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Occurrence:
|
||||
"""반복 패턴이 펼친 한 인스턴스."""
|
||||
date: date
|
||||
leave_type: str
|
||||
days: float
|
||||
pattern: str
|
||||
memo: str = ''
|
||||
recurring_id: Optional[int] = None
|
||||
|
||||
|
||||
def _parse_pattern(pattern: str):
|
||||
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
|
||||
if not pattern or ':' not in pattern:
|
||||
return None
|
||||
kind, rest = pattern.split(':', 1)
|
||||
kind = kind.strip().lower()
|
||||
rest = rest.strip().lower()
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
days = [d.strip() for d in rest.split(',') if d.strip()]
|
||||
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
|
||||
if not weekdays:
|
||||
return None
|
||||
return (kind, weekdays)
|
||||
if kind == 'monthly':
|
||||
try:
|
||||
day_of_month = int(rest)
|
||||
if 1 <= day_of_month <= 31:
|
||||
return (kind, day_of_month)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def matches(rec: Dict, target_date: date) -> bool:
|
||||
"""단일 패턴 rec이 target_date에 매치되는지."""
|
||||
start = _parse_date(rec.get('start_date'))
|
||||
end = _parse_date(rec.get('end_date'))
|
||||
if start is None:
|
||||
return False
|
||||
if target_date < start:
|
||||
return False
|
||||
if end is not None and target_date > end:
|
||||
return False
|
||||
|
||||
parsed = _parse_pattern(rec.get('pattern', ''))
|
||||
if parsed is None:
|
||||
return False
|
||||
kind, info = parsed
|
||||
|
||||
if kind == 'weekly':
|
||||
return target_date.weekday() in info
|
||||
|
||||
if kind == 'biweekly':
|
||||
if target_date.weekday() not in info:
|
||||
return False
|
||||
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
|
||||
weeks = (target_date - start).days // 7
|
||||
return weeks % 2 == 0
|
||||
|
||||
if kind == 'monthly':
|
||||
return target_date.day == info
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
|
||||
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
|
||||
|
||||
반환은 날짜 오름차순. 같은 날 여러 패턴이 매치되면 모두 포함.
|
||||
"""
|
||||
out: List[Occurrence] = []
|
||||
if start > end:
|
||||
return out
|
||||
cur = start
|
||||
while cur <= end:
|
||||
for r in records:
|
||||
try:
|
||||
if matches(r, cur):
|
||||
out.append(Occurrence(
|
||||
date=cur,
|
||||
leave_type=r.get('leave_type') or '연차',
|
||||
days=float(r.get('days') or 0),
|
||||
pattern=r.get('pattern', ''),
|
||||
memo=r.get('memo') or '',
|
||||
recurring_id=r.get('id'),
|
||||
))
|
||||
except Exception:
|
||||
# 잘못된 패턴 1개가 전체를 망치지 않도록
|
||||
continue
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
|
||||
"""단일 날짜에 매치되는 인스턴스만."""
|
||||
return expand_for_range(records, target_date, target_date)
|
||||
|
||||
|
||||
def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||
prefix = '매주' if kind == 'weekly' else '격주'
|
||||
return f"{prefix} {','.join(names)}요일"
|
||||
if kind == 'monthly':
|
||||
return f"매월 {info}일"
|
||||
return pattern
|
||||
@@ -236,6 +236,25 @@ class TimeCalculator:
|
||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||
|
||||
def effective_work_minutes(self, date_obj: datetime, db) -> int:
|
||||
"""해당 날짜의 실효 근무 시간(분).
|
||||
|
||||
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
|
||||
종일 연차(>= work_minutes)면 0 반환.
|
||||
|
||||
Args:
|
||||
date_obj: 기준 날짜 (datetime; 시각은 무시)
|
||||
db: Database 인스턴스 (None이면 차감 없음)
|
||||
|
||||
Returns:
|
||||
실효 work_minutes (>= 0)
|
||||
"""
|
||||
if db is None:
|
||||
return self.work_minutes
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
leave_min = db.get_leave_minutes_for(date_str)
|
||||
return max(0, self.work_minutes - leave_min)
|
||||
|
||||
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
|
||||
Reference in New Issue
Block a user