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:
KINDNICK
2026-05-01 13:07:52 +09:00
parent d41e5cb921
commit c98ca361cd
12 changed files with 1250 additions and 22 deletions
+118 -4
View File
@@ -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):
"""초기 연장근무 잔액 추가"""
+153
View File
@@ -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
+19
View File
@@ -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,