Compare commits

..
1 Commits
Author SHA1 Message Date
68893236+KINDNICK@users.noreply.github.comandClaude Opus 4.7 dbb8635c6c v2.8.1: 업데이트 시 cmd 창 깜빡임 제거
- updater.spec: console=True → console=False (windowed 빌드)
- updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환
  (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록.
- updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가
- utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가
  main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:14:53 +09:00
20 changed files with 111 additions and 1934 deletions
+1 -77
View File
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.10.1] — 2026-05-01
## [2.8.1] — 2026-05-01
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드).
@@ -17,82 +17,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
## [2.10.0] — 2026-05-01
### Added — 정부 공휴일 API 자동 동기화
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
- 키는 dev 본인 계정의 특일정보 API 한정 키
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
- sentinel: `settings['holidays_synced_date']`
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
### Changed
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
### Tests
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
- pytest: 175 → **189**
### 주의
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
## [2.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고)
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
- 진행바: 휴일은 100% 고정 (의미 없음)
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
#### Phase 1 — 연차 미리등록 + 자동 적용
- **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)` — 부분 연차만큼 정규 근무 차감
- **종일 연차일 자동 처리:**
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
#### Phase 2 — 통합 스케줄 + 반복 연차
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
### Changed
- **근로자의 날(5/1) 자동 추가** — `holidays.KR` 패키지가 누락하는 노동자 휴일을
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
### Tests
- pytest: 122 → **175** (+53)
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
- 통합 시나리오: 48 → **53** (+5)
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
## [2.8.0] — 2026-05-01
### Added — 도전과제 시스템 + 디자인 리뉴얼
-73
View File
@@ -696,74 +696,6 @@ def s51_accessibility_keys():
assert db.get_setting_bool('high_contrast') is True
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
def s52b_planned_leave():
db = fresh_db('s52b')
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert db.has_full_day_leave('2026-05-15')
assert db.get_leave_minutes_for('2026-05-15') == 480
# 다른 날엔 영향 없음
assert not db.has_full_day_leave('2026-05-16')
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
def s52c_recurring_leave():
db = fresh_db('s52c')
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
# 2026-05-01 = Friday
assert db.get_leave_minutes_for('2026-05-01') == 240
# Monday
assert db.get_leave_minutes_for('2026-05-04') == 0
# 종일 아님
assert not db.has_full_day_leave('2026-05-01')
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
def s52d_effective():
from core.time_calculator import TimeCalculator
db = fresh_db('s52d')
db.add_leave_record('2026-05-15', '오전반차', 0.5)
calc = TimeCalculator(work_minutes=480)
target = datetime(2026, 5, 15)
assert calc.effective_work_minutes(target, db) == 240
# 다른 날엔 변화 없음
other = datetime(2026, 5, 16)
assert calc.effective_work_minutes(other, db) == 480
@case("S52E. effective_work_minutes: 종일 연차 시 0")
def s52e_full_day():
from core.time_calculator import TimeCalculator
db = fresh_db('s52e')
db.add_leave_record('2026-05-15', '연차', 1.0)
calc = TimeCalculator(work_minutes=480)
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath():
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
from core.time_calculator import TimeCalculator
db = fresh_db('s52a')
holiday_date = '2026-05-01' # 근로자의 날
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
ci = datetime(2026, 5, 1, 9, 0)
# 휴일 인식
assert calc.is_non_working_day(ci, db)
assert calc.get_day_type(ci, db) == 'holiday'
# 출근 1분 후: 적립 0 (30분 단위 절삭)
now1 = ci + timedelta(minutes=1)
actual, earned = calc.calculate_holiday_overtime(ci, now1)
assert actual == 1 and earned == 0
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
now30 = ci + timedelta(minutes=30)
actual, earned = calc.calculate_holiday_overtime(ci, now30)
assert actual == 30 and earned == 30
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
@@ -832,11 +764,6 @@ def main():
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52a_holiday_hotpath()
s52b_planned_leave()
s52c_recurring_leave()
s52d_effective()
s52e_full_day()
s52_csv_overtime()
print()
+11 -206
View File
@@ -89,54 +89,10 @@ class Database:
self.migrate_v271_work_records_indexes()
self.migrate_v280_achievements_columns()
self.migrate_v280_hire_date()
self.migrate_v290_holidays_auto_sync()
# 기본 설정 초기화
self.init_default_settings()
def migrate_v290_holidays_auto_sync(self) -> None:
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
값이 오늘과 같으면 스킵 — 즉 같은 날 여러 번 켜도 호출 1회.
매일 호출하므로 정부가 임시공휴일 발표하면 다음 날 자동 반영.
일일 한도 10000회, 사용자 50명 × 1회 = 0.5% 소비.
실제 동기화는 백그라운드 스레드에서 — 부트스트랩이 네트워크에 묶이지 않음.
실패는 silent, 다음 실행 시 재시도.
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 로 비활성화.
"""
import os
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
return
from datetime import datetime as _dt
import threading
try:
today = _dt.now().date().isoformat()
sentinel = self.get_setting('holidays_synced_date', '')
if sentinel == today:
return
except Exception:
return
cur_year = _dt.now().year
def _worker():
try:
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
from core.database import Database
db = Database(self.db_path)
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
if added >= 0:
db.set_setting('holidays_synced_date', today)
except Exception:
pass
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
t.start()
def _create_tables(self, conn) -> None:
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
cursor = conn.cursor()
@@ -253,24 +209,6 @@ 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 제약조건 추가 (마이그레이션).
@@ -1024,109 +962,13 @@ class Database:
def get_today_leave_minutes(self) -> int:
"""오늘 사용한 연차/반차 시간 조회 (분)"""
from datetime import date
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)
today = date.today().isoformat()
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())
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 = ?',
(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):
"""초기 연장근무 잔액 추가"""
from datetime import datetime
@@ -1757,32 +1599,6 @@ class Database:
for date, name in fixed_holidays:
self.add_holiday(date, name, is_recurring=True)
def add_korean_holidays_from_api(self, year: int) -> int:
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
임시공휴일 + 근로자의 날 등 holidays 패키지가 놓치는 항목까지 포함.
네트워크 실패 시 -1 반환 → 호출자 fallback.
Returns:
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 시 -1.
"""
try:
from utils.holiday_api import fetch_korean_holidays
except ImportError:
return -1
items = fetch_korean_holidays(year)
if items is None:
return -1
added = 0
for it in items:
if not it.get('is_holiday'):
continue
date_str = it['date']
if not self.is_holiday(date_str):
self.add_holiday(date_str, it['name'], is_recurring=False)
added += 1
return added
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
@@ -1808,37 +1624,26 @@ class Database:
Returns:
추가된 공휴일 개수. 패키지 미설치 시 -1.
"""
try:
import holidays as _holidays
except ImportError:
return -1
years_to_add = [year]
if include_next_year:
years_to_add.append(year + 1)
added = 0
for y in years_to_add:
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
api_count = self.add_korean_holidays_from_api(y)
if api_count >= 0:
added += api_count
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
continue
# 2차 fallback: holidays 패키지
try:
import holidays as _holidays
kr = _holidays.country_holidays('KR', years=y)
except Exception:
continue # 둘 다 실패면 해당 연도만 스킵
continue # 패키지 내부 오류는 해당 연도만 스킵
for d, name in kr.items():
date_str = d.isoformat()
if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=False)
added += 1
# holidays.KR이 누락하는 근로자의 날 명시적 보강
extra = [(f"{y}-05-01", "근로자의 날")]
for date_str, name in extra:
if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=True)
added += 1
return added
def copy_recurring_holidays(self, from_year: int, to_year: int):
-153
View File
@@ -1,153 +0,0 @@
"""
반복 연차 패턴 파싱 + 일자 확장.
지원 패턴:
- '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
-49
View File
@@ -236,55 +236,6 @@ 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,
break_minutes: int = 0,
unit_minutes: int = 30) -> Tuple[int, int]:
"""
휴일/주말 근무: 모든 시간을 연장근무로 계산.
Args:
clock_in: 출근 시간
current_time: 현재(또는 퇴근) 시간
include_lunch/dinner: 식사 시간 차감 여부
break_minutes: 외출 시간 (분) — 연장근무에서 제외
unit_minutes: 적립 단위 (15/30/60)
Returns:
(실제 연장 분, 적립 분) — 둘 다 0 이상.
"""
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
if include_lunch:
elapsed_minutes -= self.lunch_duration_minutes
if include_dinner:
elapsed_minutes -= self.dinner_duration_minutes
elapsed_minutes -= break_minutes
elapsed_minutes = max(0, elapsed_minutes)
unit = unit_minutes if unit_minutes > 0 else 30
earned = (elapsed_minutes // unit) * unit
return elapsed_minutes, earned
def is_weekend(self, date_obj: datetime) -> bool:
"""
주말 여부 확인
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.10.1'
__version__ = '2.8.1'
-9
View File
@@ -1,9 +0,0 @@
"""
pytest 공통 설정.
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
"""
import os
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
-94
View File
@@ -108,97 +108,3 @@ class TestConsecutiveOvertimeDays:
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
overtime_minutes=120, overtime_earned=120)
assert fresh_db.get_consecutive_overtime_days() == 3
class TestLeaveQueriesByDate:
def test_get_leave_minutes_for_no_records(self, fresh_db):
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
def test_full_day_leave_detected(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_half_day_not_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
assert not fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
def test_two_halves_become_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_records_by_date(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
recs = fresh_db.get_leave_records_by_date('2026-05-15')
assert len(recs) == 1
assert recs[0]['leave_type'] == '연차'
assert recs[0]['memo'] == '메모'
def test_records_by_range(self, fresh_db):
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
assert len(recs) == 2
# 날짜 정렬
assert recs[0]['date'] == '2026-05-01'
assert recs[1]['date'] == '2026-05-10'
class TestRecurringLeavesDB:
def test_add_and_list(self, fresh_db):
rid = fresh_db.add_recurring_leave(
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
)
assert rid > 0
recs = fresh_db.get_recurring_leaves()
assert len(recs) == 1
assert recs[0]['pattern'] == 'weekly:friday'
assert recs[0]['memo'] == '단축'
def test_active_on_filter(self, fresh_db):
# 종료일이 지난 패턴
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2025-01-01', '2025-12-31')
# 아직 시작 안 한 패턴
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
'2027-01-01', None)
# 현재 활성 패턴
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
'2026-01-01', None)
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
assert len(active) == 1
assert active[0]['pattern'] == 'monthly:15'
def test_delete(self, fresh_db):
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2026-01-01')
assert len(fresh_db.get_recurring_leaves()) == 1
fresh_db.delete_recurring_leave(rid)
assert fresh_db.get_recurring_leaves() == []
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
# 매주 금요일 반차
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
'2026-01-01')
# 2026-05-01 = Friday → 240분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
# 2026-05-04 = Monday → 0분
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
def test_concrete_plus_recurring_sum(self, fresh_db):
# 매주 금요일 반차 + 그날 별도 반반차 추가
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
# 0.5 + 0.25 = 0.75일 = 360분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
assert not fresh_db.has_full_day_leave('2026-05-01')
def test_concrete_plus_recurring_full_day(self, fresh_db):
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
# 0.5 + 0.5 = 1.0일
assert fresh_db.has_full_day_leave('2026-05-01')
-166
View File
@@ -1,166 +0,0 @@
"""
utils.holiday_api 단위 테스트.
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.holiday_api import (
fetch_korean_holidays, _parse_response, is_configured,
)
def _ok_response(items):
"""API 정상 응답 형식 빌드."""
return {
'response': {
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
'body': {
'items': {'item': items} if items else {'item': []},
'numOfRows': 100,
'pageNo': 1,
'totalCount': len(items) if isinstance(items, list) else 1,
},
}
}
class TestParseResponse:
def test_multiple_items(self):
items = [
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
'locdate': 20260501, 'seq': 1},
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
'locdate': 20260505, 'seq': 1},
]
out = _parse_response(_ok_response(items))
assert len(out) == 2
assert out[0]['date'] == '2026-05-01'
assert out[0]['name'] == '근로자의 날'
assert out[0]['is_holiday'] is True
assert out[1]['date'] == '2026-05-05'
def test_single_item_as_dict(self):
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': {'item': item}, 'totalCount': 1},
}
}
out = _parse_response(data)
assert len(out) == 1
assert out[0]['name'] == '근로자의 날'
def test_empty_year(self):
# totalCount=0 같은 정상 빈 응답
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': '', 'totalCount': 0},
}
}
assert _parse_response(data) == []
def test_error_result_code(self):
data = {
'response': {
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
'body': {},
}
}
assert _parse_response(data) is None
def test_isholiday_n_filtered_at_caller_level(self):
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
out = _parse_response(_ok_response(items))
assert len(out) == 1
assert out[0]['is_holiday'] is False
def test_locdate_str_form(self):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
out = _parse_response(_ok_response(items))
assert out[0]['date'] == '2026-05-01'
def test_invalid_locdate_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
]
out = _parse_response(_ok_response(items))
assert len(out) == 1 # 정상 1개만
assert out[0]['name'] == '근로자의 날'
def test_missing_required_fields_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
]
out = _parse_response(_ok_response(items))
assert len(out) == 1
def test_malformed_response_returns_none(self):
# response 구조 자체가 깨진 경우
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
# 위는 implementation-dependent — 둘 다 합리적
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
assert _parse_response({}) is None
class TestFetchNetwork:
@patch('utils.holiday_api.urllib.request.urlopen')
def test_success(self, mock_urlopen):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
resp = MagicMock()
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
out = fetch_korean_holidays(2026)
assert out is not None
assert len(out) == 1
assert out[0]['date'] == '2026-05-01'
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
req = mock_urlopen.call_args[0][0]
assert 'serviceKey=' in req.full_url
assert 'solYear=2026' in req.full_url
assert '_type=json' in req.full_url
@patch('utils.holiday_api.urllib.request.urlopen')
def test_network_error_returns_none(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_timeout_returns_none(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_invalid_json_returns_none(self, mock_urlopen):
resp = MagicMock()
resp.read.return_value = b'<html>error</html>'
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert fetch_korean_holidays(2026) is None
class TestConfigured:
def test_key_set(self):
assert is_configured() is True
-153
View File
@@ -1,153 +0,0 @@
"""
core.recurring_leaves 단위 테스트.
"""
import os
import sys
from datetime import date
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.recurring_leaves import (
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
)
class TestParsePattern:
@pytest.mark.parametrize("pattern,expected_kind", [
('weekly:friday', 'weekly'),
('weekly:fri', 'weekly'),
('weekly:mon,wed,fri', 'weekly'),
('biweekly:friday', 'biweekly'),
('monthly:15', 'monthly'),
('monthly:1', 'monthly'),
])
def test_valid(self, pattern, expected_kind):
result = _parse_pattern(pattern)
assert result is not None
assert result[0] == expected_kind
@pytest.mark.parametrize("pattern", [
'', 'weekly', 'weekly:', 'weekly:xyz',
'monthly:0', 'monthly:32', 'monthly:abc',
'unknown:fri', None,
])
def test_invalid(self, pattern):
assert _parse_pattern(pattern) is None
class TestMatches:
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
return {
'pattern': pattern,
'start_date': start,
'end_date': end,
'days': days,
'leave_type': leave_type,
}
def test_weekly_single_day(self):
rec = self._rec('weekly:friday')
assert matches(rec, date(2026, 5, 1)) # Fri
assert not matches(rec, date(2026, 5, 2)) # Sat
assert not matches(rec, date(2026, 5, 4)) # Mon
def test_weekly_multiple_days(self):
rec = self._rec('weekly:mon,wed,fri')
assert matches(rec, date(2026, 5, 4)) # Mon
assert matches(rec, date(2026, 5, 6)) # Wed
assert matches(rec, date(2026, 5, 8)) # Fri
assert not matches(rec, date(2026, 5, 5)) # Tue
assert not matches(rec, date(2026, 5, 7)) # Thu
def test_biweekly_alignment(self):
# start_date 2026-01-02 = Friday (week 0)
rec = self._rec('biweekly:friday', start='2026-01-02')
assert matches(rec, date(2026, 1, 2)) # week 0
assert not matches(rec, date(2026, 1, 9)) # week 1
assert matches(rec, date(2026, 1, 16)) # week 2
def test_monthly(self):
rec = self._rec('monthly:15')
assert matches(rec, date(2026, 1, 15))
assert matches(rec, date(2026, 5, 15))
assert not matches(rec, date(2026, 5, 14))
assert not matches(rec, date(2026, 5, 16))
def test_monthly_skipped_in_short_month(self):
# 31일은 30일 달에는 매치되지 않음
rec = self._rec('monthly:31')
assert matches(rec, date(2026, 1, 31))
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
def test_before_start(self):
rec = self._rec('weekly:friday', start='2026-05-01')
assert matches(rec, date(2026, 5, 1))
assert not matches(rec, date(2026, 4, 24)) # 시작 전
def test_after_end(self):
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
def test_no_end_means_forever(self):
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
def test_invalid_pattern_returns_false(self):
rec = self._rec('garbage:xyz')
assert not matches(rec, date(2026, 5, 1))
class TestExpandRange:
def _rec(self, pattern, start='2026-01-01'):
return {
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
'days': 0.5, 'leave_type': '반차', 'memo': '',
}
def test_expand_weekly_one_month(self):
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
assert len(occs) == 5
assert all(o.date.weekday() == 4 for o in occs)
def test_expand_empty_when_outside(self):
rec = self._rec('weekly:friday', start='2027-01-01')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
assert occs == []
def test_expand_invalid_range(self):
# start > end
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
assert occs == []
def test_expand_multiple_recs(self):
rec_fri = self._rec('weekly:friday')
rec_mon = self._rec('weekly:monday')
rec_mon['id'] = 2
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
assert len(occs) == 2
def test_expand_for_date_single(self):
rec = self._rec('monthly:15')
occs = expand_for_date([rec], date(2026, 5, 15))
assert len(occs) == 1
assert occs[0].date == date(2026, 5, 15)
class TestDescribePattern:
def test_weekly_korean(self):
assert '매주' in describe_pattern('weekly:friday')
assert '금' in describe_pattern('weekly:friday')
def test_biweekly(self):
assert '격주' in describe_pattern('biweekly:friday')
def test_monthly(self):
assert '매월' in describe_pattern('monthly:15')
assert '15' in describe_pattern('monthly:15')
-74
View File
@@ -98,77 +98,3 @@ class TestDayType:
mon = datetime(2026, 5, 4)
assert not calc.is_weekend(mon)
assert calc.get_day_type(mon) == 'normal'
class TestHolidayOvertime:
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
def test_zero_elapsed_returns_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
assert actual == 0 and earned == 0
def test_one_minute_elapsed_no_lunch(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=1)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 1
assert earned == 0 # 30분 단위 절삭
def test_30min_elapsed_truncates_to_30(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=30)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 30 and earned == 30
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=29)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 29 and earned == 0
def test_lunch_subtracted(self, calc_8h):
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=9)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True
)
assert actual == 8 * 60
assert earned == 8 * 60
def test_break_minutes_subtracted(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=2)
# 외출 30분 → 90분 적립
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, break_minutes=30
)
assert actual == 90 and earned == 90
def test_unit_minutes_15(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=44)
# 44분 → 30분 적립 (15분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=15
)
assert actual == 44 and earned == 30
def test_unit_minutes_60(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=119)
# 119분 → 60분 적립 (60분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=60
)
assert actual == 119 and earned == 60
def test_negative_clamped_to_zero(self, calc_8h):
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=1)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True, include_dinner=True
)
assert actual == 0 and earned == 0
+3 -6
View File
@@ -82,13 +82,10 @@ class NotificationOrchestrator:
except Exception as e:
dlog(f"discord weekly_report failed: {e}")
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
is_holiday: bool = False) -> None:
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
if not is_holiday:
n.check_clock_out_soon(expected_clock_out, now)
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
n.check_dinner_reminder(self.window.clock_in_time,
+10 -17
View File
@@ -43,10 +43,11 @@ class LeaveCalendarView(QDialog):
header.addStretch()
layout.addLayout(header)
# 범례 (사용 완료 + 예정 분리)
# 범례
legend = QHBoxLayout()
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
"🔵 예정", "🔘 종일+예정"]:
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
@@ -75,9 +76,7 @@ class LeaveCalendarView(QDialog):
self.setLayout(layout)
def _mark_dates(self):
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
from datetime import date as _date
today = _date.today()
"""연차 사용 일자에 색상 표시."""
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
@@ -86,18 +85,12 @@ class LeaveCalendarView(QDialog):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
is_planned = d > today
if is_planned:
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
# 과거/오늘 = 사용 완료 색상
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
-46
View File
@@ -90,11 +90,6 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
schedule_button = QPushButton("🗓️ 스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@@ -107,13 +102,6 @@ class LeaveView(QDialog):
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def _show_schedule(self):
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
# 닫고 돌아오면 잔액/리스트 갱신
self.load_data()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
@@ -261,8 +249,6 @@ class AddLeaveDialog(QDialog):
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
@@ -359,38 +345,6 @@ class AddLeaveDialog(QDialog):
)
return
# 휴일/주말 검증 — 차감 의미 없으므로 차단
from datetime import datetime as _dt
date_dt = _dt.strptime(date, "%Y-%m-%d")
if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning(
self,
"주말 등록 불가",
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
)
return
if self.db.is_holiday(date):
holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일')
QMessageBox.warning(
self,
"공휴일 등록 불가",
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
)
return
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
existing_min = self.db.get_leave_minutes_for(date)
existing_days = existing_min / max(1, self.db.get_work_minutes())
if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning(
self,
"중복 등록 초과",
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
)
return
# 확인 메시지
hours = days * 8
reply = QMessageBox.question(
+83 -191
View File
@@ -631,21 +631,6 @@ class MainWindow(QMainWindow):
self.clock_out_button.setText("✅ 퇴근하기")
else:
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
self.is_clocked_in = False
self.clock_out_button.setEnabled(False)
# 점심/저녁/외출/잔액 갱신만 수행
self.lunch_button.setChecked(False)
self.update_lunch_status()
self.dinner_button.setChecked(False)
self.update_dinner_status()
self.update_break_status()
self.update_overtime_balance()
self.update_leave_balance()
return
# 출근 기록 없음 - 자동 감지 시도
auto_clock_in = self.event_monitor.get_work_start_time()
@@ -716,14 +701,6 @@ class MainWindow(QMainWindow):
self.start_new_workday(now)
return
# 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료.
# (수동 출근 override는 handle_clock_in 경로에서 별도 처리)
if not self.is_clocked_in:
today_str = now.date().isoformat()
if self.db.has_full_day_leave(today_str):
self._render_full_day_leave_state(today_str)
return
# 출근하지 않았으면 여기서 종료
if not self.is_clocked_in or not self.clock_in_time:
return
@@ -751,46 +728,21 @@ class MainWindow(QMainWindow):
# 총 차감 시간 (추가근무 + 연차/반차)
total_time_off = overtime_used_today + leave_used_today
# 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
is_non_working = self.time_calc.is_non_working_day(now, self.db)
is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat())
is_holiday = is_non_working or is_full_day_leave
if is_holiday:
actual_ot, _ = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, now,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
)
# 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
actual_ot = max(0, actual_ot - overtime_used_today)
remaining = -timedelta(minutes=actual_ot)
else:
# 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에
# 그대로 반영되어 카운트다운이 단축됨.
remaining = self.time_calc.calculate_remaining_time(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
current_time=now,
break_minutes=break_minutes
)
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
remaining -= timedelta(minutes=total_time_off)
# 남은 시간 계산 (외출 시간 반영, 추가근무/반차 사용 시간 차감)
remaining = self.time_calc.calculate_remaining_time(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
current_time=now,
break_minutes=break_minutes
)
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
remaining -= timedelta(minutes=total_time_off)
# 남은 시간 표시 및 추가 근무 처리
if remaining.total_seconds() < 0:
# 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
day_type = self.time_calc.get_day_type(now, self.db)
if is_full_day_leave and not is_non_working:
self.remaining_time_group.setTitle("연차 override (전체 적립)")
elif day_type == 'weekend':
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
elif day_type == 'holiday':
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
else:
self.remaining_time_group.setTitle("추가 근무 중")
# 추가 근무 중
self.remaining_time_group.setTitle("추가 근무 중")
# + 기호로 표시
total_seconds = int(abs(remaining.total_seconds()))
hours = total_seconds // 3600
@@ -813,46 +765,35 @@ class MainWindow(QMainWindow):
self._set_text_if_changed(self.remaining_time_label, remaining_str)
# 진행률 업데이트
# 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 100%로 채워둠.
if is_holiday:
self.progress_bar.setValue(100)
else:
progress = self.time_calc.calculate_work_progress(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
current_time=now,
break_minutes=break_minutes,
overtime_used_minutes=total_time_off
)
self.progress_bar.setValue(int(progress * 100))
# - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함)
# - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨)
progress = self.time_calc.calculate_work_progress(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
current_time=now,
break_minutes=break_minutes,
overtime_used_minutes=total_time_off
)
self.progress_bar.setValue(int(progress * 100))
# 예상 퇴근 시간 (외출 시간 포함)
# 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미)
if is_holiday:
expected_clock_out = self.clock_in_time
self._set_text_if_changed(
self.expected_time_label,
"휴일 근무 (정해진 퇴근시각 없음)"
)
else:
expected_clock_out = self.time_calc.calculate_clock_out_time(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes
)
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
expected_clock_out -= timedelta(minutes=total_time_off)
self._set_text_if_changed(
self.expected_time_label,
f"예상 퇴근: {self.format_time(expected_clock_out)}"
)
# 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감
expected_clock_out = self.time_calc.calculate_clock_out_time(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes
)
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
expected_clock_out -= timedelta(minutes=total_time_off)
self._set_text_if_changed(
self.expected_time_label,
f"예상 퇴근: {self.format_time(expected_clock_out)}"
)
# 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함)
# 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅.
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(),
is_holiday=is_holiday)
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds())
# 트레이 / 미니 위젯 갱신
if remaining.total_seconds() < 0:
@@ -871,35 +812,6 @@ class MainWindow(QMainWindow):
date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일"
self.date_label.setText(date_str)
def _render_full_day_leave_state(self, today_str: str) -> None:
"""오늘이 종일 연차이고 출근 안 한 상태 → 카운트다운 대신 휴가 카드 표시."""
records = self.db.get_leave_records_by_date(today_str)
# 가장 큰 일수의 leave_type을 대표로 표시 (보통 1.0짜리 1건)
if records:
primary = max(records, key=lambda r: r.get('days') or 0)
label = primary.get('leave_type') or '연차'
memo = primary.get('memo') or ''
else:
label = '연차'
memo = ''
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
self.remaining_time_label.setText("연차 사용 중")
self.remaining_time_label.setStyleSheet(
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
)
self.progress_bar.setValue(100)
if memo:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label} — {memo}")
else:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label}")
# 트레이/미니 위젯
self.tray_icon.update_time_display("🌴 휴가")
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
self._mini_widget.update_remaining("🌴 휴가")
def toggle_lunch_break(self):
"""점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch()
@@ -1244,14 +1156,16 @@ class MainWindow(QMainWindow):
unit_minutes = 30
if is_non_working_day:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외)
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, now,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
work_minutes = int(total_hours * 60)
if self.lunch_break_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes
if self.dinner_break_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
work_minutes = max(0, work_minutes)
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
overtime_actual = work_minutes
else:
# 평일: 정상 연장근무 계산 (외출 시간 포함)
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
@@ -1428,25 +1342,24 @@ class MainWindow(QMainWindow):
break_minutes = cursor.fetchone()[0] or 0
conn.close()
# 추가근무 계산 (사용자 설정 적립 단위 적용)
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
# 추가근무 계산
if is_non_working_day:
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, workday_end,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
work_minutes = int(total_hours * 60)
if self.lunch_break_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes
if self.dinner_break_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
# 음수 방지
work_minutes = max(0, work_minutes)
overtime_earned = (work_minutes // 30) * 30
overtime_actual = work_minutes
else:
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
self.clock_in_time, workday_end,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
break_minutes=break_minutes
)
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
@@ -1585,26 +1498,26 @@ class MainWindow(QMainWindow):
lunch_enabled = bool(record.get('lunch_break', False))
dinner_enabled = bool(record.get('dinner_break', False))
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
if is_non_working_day:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외)
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
clock_in_time, shutdown_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외)
work_minutes = int(total_hours * 60)
if lunch_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes
if dinner_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
# 음수 방지
work_minutes = max(0, work_minutes)
# 30분 단위로 절삭
overtime_earned = (work_minutes // 30) * 30
overtime_actual = work_minutes
else:
# 평일: 정상 연장근무 계산 (외출 시간 포함)
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
clock_in_time, shutdown_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
break_minutes=break_minutes
)
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
@@ -1656,24 +1569,23 @@ class MainWindow(QMainWindow):
lunch_enabled = bool(record.get('lunch_break', False))
dinner_enabled = bool(record.get('dinner_break', False))
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
if is_non_working_day:
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
clock_in_time, fallback_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
work_minutes = int(total_hours * 60)
if lunch_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes
if dinner_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
# 음수 방지
work_minutes = max(0, work_minutes)
overtime_earned = (work_minutes // 30) * 30
overtime_actual = work_minutes
else:
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
clock_in_time, fallback_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
break_minutes=break_minutes
)
# DB 업데이트
@@ -1693,20 +1605,6 @@ class MainWindow(QMainWindow):
def manual_clock_in(self):
"""수동 출근 시간 입력"""
# 종일 연차 등록일이면 override 의도 확인
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
reply = QMessageBox.question(
self,
"종일 연차 등록됨",
"오늘은 종일 연차로 등록되어 있습니다.\n"
"그래도 출근하시겠어요?\n\n"
"(출근 시 모든 시간이 연장근무로 적립됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
default_time = self.clock_in_time if self.clock_in_time else None
@@ -1778,12 +1676,6 @@ class MainWindow(QMainWindow):
dialog = CalendarView(self, self.db)
dialog.exec_()
def show_schedule(self):
"""통합 스케줄(휴일+연차+반복) 창 표시."""
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
def show_leave_management(self):
"""휴가 관리 창 표시"""
dialog = LeaveView(self, self.db)
-199
View File
@@ -1,199 +0,0 @@
"""
반복 연차 등록/관리 다이얼로그.
지원: 매주/격주 요일, 매월 N일.
"""
from __future__ import annotations
from datetime import date
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QComboBox, QDateEdit, QSpinBox,
QDoubleSpinBox, QLineEdit, QGroupBox,
QListWidget, QListWidgetItem, QMessageBox,
QCheckBox, QButtonGroup, QRadioButton)
from PyQt5.QtCore import QDate, Qt
from core.recurring_leaves import describe_pattern
from ui.styles import apply_dark_titlebar
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
class RecurringLeaveDialog(QDialog):
"""반복 연차 패턴 추가/삭제."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🔁 반복 연차 관리")
self.setMinimumSize(540, 480)
self._build_ui()
self._reload_list()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 기존 패턴 목록
list_group = QGroupBox("등록된 반복 패턴")
lg = QVBoxLayout()
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(160)
lg.addWidget(self.list_widget)
del_btn = QPushButton("선택 삭제")
del_btn.clicked.connect(self._delete_selected)
lg.addWidget(del_btn)
list_group.setLayout(lg)
layout.addWidget(list_group)
# 신규 등록
add_group = QGroupBox("신규 패턴 추가")
ag = QVBoxLayout()
# 패턴 종류
kind_row = QHBoxLayout()
kind_row.addWidget(QLabel("주기:"))
self.kind_group = QButtonGroup(self)
self.rb_weekly = QRadioButton("매주")
self.rb_weekly.setChecked(True)
self.rb_biweekly = QRadioButton("격주")
self.rb_monthly = QRadioButton("매월 N일")
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
self.kind_group.addButton(rb)
kind_row.addWidget(rb)
kind_row.addStretch()
ag.addLayout(kind_row)
# 요일 체크박스 (weekly/biweekly)
wd_row = QHBoxLayout()
wd_row.addWidget(QLabel("요일:"))
self.weekday_checks = []
for ko, en in _KO_WEEKDAYS:
cb = QCheckBox(ko)
self.weekday_checks.append((cb, en))
wd_row.addWidget(cb)
wd_row.addStretch()
ag.addLayout(wd_row)
# 매월 N일
month_row = QHBoxLayout()
month_row.addWidget(QLabel("매월:"))
self.day_of_month = QSpinBox()
self.day_of_month.setRange(1, 31)
self.day_of_month.setValue(15)
self.day_of_month.setSuffix("일")
month_row.addWidget(self.day_of_month)
month_row.addStretch()
ag.addLayout(month_row)
# 차감 일수
days_row = QHBoxLayout()
days_row.addWidget(QLabel("차감:"))
self.days_combo = QComboBox()
self.days_combo.addItem("1.0일 (종일)", 1.0)
self.days_combo.addItem("0.5일 (반차)", 0.5)
self.days_combo.addItem("0.25일 (반반차)", 0.25)
days_row.addWidget(self.days_combo)
days_row.addStretch()
ag.addLayout(days_row)
# 시작/종료 날짜
date_row = QHBoxLayout()
date_row.addWidget(QLabel("시작:"))
self.start_edit = QDateEdit()
self.start_edit.setDate(QDate.currentDate())
self.start_edit.setCalendarPopup(True)
date_row.addWidget(self.start_edit)
date_row.addWidget(QLabel("종료:"))
self.end_edit = QDateEdit()
self.end_edit.setDate(QDate.currentDate().addMonths(6))
self.end_edit.setCalendarPopup(True)
date_row.addWidget(self.end_edit)
self.no_end_check = QCheckBox("종료 없음 (무기한)")
self.no_end_check.toggled.connect(
lambda v: self.end_edit.setEnabled(not v)
)
date_row.addWidget(self.no_end_check)
date_row.addStretch()
ag.addLayout(date_row)
# 메모
memo_row = QHBoxLayout()
memo_row.addWidget(QLabel("메모:"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
memo_row.addWidget(self.memo_edit)
ag.addLayout(memo_row)
# 추가 버튼
add_btn = QPushButton("➕ 추가")
add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save)
ag.addWidget(add_btn)
add_group.setLayout(ag)
layout.addWidget(add_group)
# 닫기
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
def _reload_list(self):
self.list_widget.clear()
for r in self.db.get_recurring_leaves():
desc = describe_pattern(r['pattern'])
end = r.get('end_date') or '무기한'
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
f"· {r['start_date']} ~ {end}")
if r.get('memo'):
text += f" — {r['memo']}"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, r['id'])
self.list_widget.addItem(item)
def _delete_selected(self):
item = self.list_widget.currentItem()
if not item:
return
rec_id = item.data(Qt.UserRole)
reply = QMessageBox.question(
self, "삭제 확인",
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(rec_id)
self._reload_list()
def _build_pattern(self) -> str | None:
if self.rb_monthly.isChecked():
return f"monthly:{self.day_of_month.value()}"
# weekly/biweekly
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
if not chosen:
return None
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
return f"{prefix}:" + ",".join(chosen)
def _save(self):
pattern = self._build_pattern()
if not pattern:
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
return
days = self.days_combo.currentData()
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
start = self.start_edit.date().toString('yyyy-MM-dd')
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
memo = self.memo_edit.text().strip()
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
QMessageBox.information(self, "추가 완료",
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
self.memo_edit.clear()
self._reload_list()
-315
View File
@@ -1,315 +0,0 @@
"""
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
기능:
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
- 반복 패턴 관리 → RecurringLeaveDialog
"""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import List
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget, QListWidget,
QListWidgetItem, QMessageBox, QMenu,
QGroupBox, QSplitter, QWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from core.recurring_leaves import expand_for_range, describe_pattern
from ui.styles import apply_dark_titlebar
# 색상 팔레트
_C_HOLIDAY = QColor("#e53935") # 빨강
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
class ScheduleView(QDialog):
"""월간 통합 스케줄 다이얼로그."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🗓️ 스케줄")
self.setMinimumSize(820, 560)
self._build_ui()
self._reload()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 상단 툴바
bar = QHBoxLayout()
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
bar.addWidget(title)
bar.addStretch()
rec_btn = QPushButton("🔁 반복 패턴 관리")
rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn)
add_btn = QPushButton("➕ 연차 등록")
add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn)
layout.addLayout(bar)
# 범례
legend = QHBoxLayout()
for label, color in [("공휴일", _C_HOLIDAY),
("연차 사용", _C_LEAVE_FULL_PAST),
("연차 예정", _C_LEAVE_FULL_PLAN),
("반차/반반차", _C_LEAVE_HALF_PAST),
("반복 패턴", _C_RECURRING)]:
sw = QLabel(f" {label} ")
sw.setStyleSheet(
f"background-color: {color.name()}; color: white; "
f"padding: 2px 6px; border-radius: 3px;"
)
legend.addWidget(sw)
legend.addStretch()
layout.addLayout(legend)
# 캘린더 + 상세 splitter
splitter = QSplitter(Qt.Horizontal)
# 좌측: 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
self.calendar.currentPageChanged.connect(self._on_page_change)
splitter.addWidget(self.calendar)
# 우측: 상세 패널
right = QWidget()
right_layout = QVBoxLayout()
self.detail_title = QLabel("날짜를 선택하세요")
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
right_layout.addWidget(self.detail_title)
self.detail_list = QListWidget()
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
right_layout.addWidget(self.detail_list, 1)
right.setLayout(right_layout)
splitter.addWidget(right)
splitter.setSizes([520, 280])
layout.addWidget(splitter, 1)
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
# ------------------------------------------------------------- reload
def _reload(self):
"""현재 화면 월에 대해 색상/리스트 갱신."""
# 모든 날짜 포맷 초기화
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
y = self.calendar.yearShown()
m = self.calendar.monthShown()
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
first = date(y, m, 1)
if m == 12:
last = date(y + 1, 1, 1) - timedelta(days=1)
else:
last = date(y, m + 1, 1) - timedelta(days=1)
view_start = first - timedelta(days=7)
view_end = last + timedelta(days=7)
# 휴일
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
view_end.isoformat()) \
if hasattr(self.db, 'get_holidays_in_range') else []
if not holidays:
holidays = self._fallback_holidays(view_start, view_end)
for h in holidays:
d = self._parse_date(h.get('date'))
if d is None:
continue
self._paint(d, _C_HOLIDAY, fg='white')
# 연차 (구체)
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
view_end.isoformat())
today = date.today()
for r in leaves:
d = self._parse_date(r.get('date'))
if d is None:
continue
days = float(r.get('days') or 0)
is_planned = d > today
if is_planned:
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
else:
if days >= 1.0:
color = _C_LEAVE_FULL_PAST
elif days >= 0.5:
color = _C_LEAVE_HALF_PAST
else:
color = _C_LEAVE_QUART_PAST
self._paint(d, color, fg='white')
# 반복 패턴 인스턴스
recurring = self.db.get_recurring_leaves()
for occ in expand_for_range(recurring, view_start, view_end):
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
existing = self.calendar.dateTextFormat(
QDate(occ.date.year, occ.date.month, occ.date.day))
if existing.background() != QBrush():
continue
self._paint(occ.date, _C_RECURRING, fg='white')
def _paint(self, d: date, color: QColor, fg: str = 'white'):
qd = QDate(d.year, d.month, d.day)
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor(fg)))
self.calendar.setDateTextFormat(qd, fmt)
# ------------------------------------------------------------- events
def _on_date_click(self, qd: QDate):
d = date(qd.year(), qd.month(), qd.day())
date_str = d.isoformat()
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
self.detail_list.clear()
# 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday:
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item)
elif d.weekday() in (5, 6):
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
self.detail_list.addItem(item)
# 연차 (구체)
for r in self.db.get_leave_records_by_date(date_str):
days = float(r.get('days') or 0)
t = r.get('leave_type', '연차')
memo = r.get('memo') or ''
label = f"📌 {t} {days}일"
if memo:
label += f" — {memo}"
label += f" [id={r['id']}]"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, ('concrete', r['id']))
self.detail_list.addItem(item)
# 반복 패턴 매치
recurring = self.db.get_recurring_leaves(active_on=date_str)
from core.recurring_leaves import expand_for_date
for occ in expand_for_date(recurring, d):
item = QListWidgetItem(
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
)
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
self.detail_list.addItem(item)
if self.detail_list.count() == 0:
self.detail_list.addItem("일정 없음")
def _on_page_change(self, year: int, month: int):
self._reload()
def _on_list_menu(self, pos):
item = self.detail_list.currentItem()
if not item:
return
data = item.data(Qt.UserRole)
if not data:
return
kind, _id = data
menu = QMenu(self)
del_act = menu.addAction("삭제")
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
if chosen == del_act:
self._delete_record(kind, _id)
def _delete_record(self, kind: str, _id: int):
if kind == 'concrete':
reply = QMessageBox.question(
self, "삭제 확인",
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_leave_record(_id)
self._reload()
# 상세 갱신
d = self.calendar.selectedDate()
self._on_date_click(d)
elif kind == 'recurring':
reply = QMessageBox.question(
self, "삭제 확인",
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(_id)
self._reload()
d = self.calendar.selectedDate()
self._on_date_click(d)
def _open_recurring_dialog(self):
from ui.recurring_leave_dialog import RecurringLeaveDialog
dlg = RecurringLeaveDialog(self, self.db)
dlg.exec_()
self._reload()
def _open_add_leave_dialog(self):
from ui.leave_view import AddLeaveDialog
dlg = AddLeaveDialog(self, self.db)
# 선택된 날짜로 기본값 설정
d = self.calendar.selectedDate()
if d.isValid():
dlg.date_edit.setDate(d)
if dlg.exec_() == dlg.Accepted:
self._reload()
self._on_date_click(d)
# ------------------------------------------------------------- helpers
@staticmethod
def _parse_date(s):
if not s:
return None
try:
return datetime.strptime(s, '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
if not hasattr(self.db, 'get_holiday'):
return []
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
out = []
cur = view_start
while cur <= view_end:
h = self.db.get_holiday(cur.isoformat())
if h:
out.append(h)
cur += timedelta(days=1)
return out
+2 -3
View File
@@ -688,11 +688,10 @@ class SettingsView(QDialog):
"한국 공휴일 자동 추가",
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n"
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
-98
View File
@@ -1,98 +0,0 @@
"""
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
정부 공인 데이터로 보강하기 위해 사용.
설계:
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
"""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
import urllib.error
from typing import List, Dict, Optional
# 공공데이터포털 dev 키 (특일정보 API 한정).
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
Returns:
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
"""
params = {
'serviceKey': _SERVICE_KEY,
'solYear': str(year),
'_type': 'json',
'numOfRows': '100',
'pageNo': '1',
}
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError,
json.JSONDecodeError, OSError, TimeoutError):
return None
return _parse_response(data)
def _parse_response(data: Dict) -> Optional[List[Dict]]:
"""API 응답 JSON을 표준 형식으로 정규화.
API 응답 패턴:
- resultCode == '00' → 정상
- items.item: 단일 결과면 dict, 여러 개면 list
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
"""
try:
response = data.get('response') or {}
header = response.get('header') or {}
if header.get('resultCode') != '00':
return None
body = response.get('body') or {}
items_root = body.get('items')
if not items_root:
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
item = items_root.get('item') if isinstance(items_root, dict) else None
if item is None:
return []
if isinstance(item, dict):
item = [item]
out = []
for entry in item:
locdate = entry.get('locdate')
name = entry.get('dateName')
is_holiday = (entry.get('isHoliday') == 'Y')
if not locdate or not name:
continue
# locdate: 20260501 (int 또는 str)
ds = str(locdate)
if len(ds) != 8 or not ds.isdigit():
continue
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
return out
except (AttributeError, TypeError, KeyError):
return None
def is_configured() -> bool:
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
-4
View File
@@ -98,10 +98,6 @@ class SystemTrayIcon(QSystemTrayIcon):
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
schedule_action = QAction("🗓️ 스케줄", self)
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
menu.addAction(schedule_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)