v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect): - auto_overtime: when OFF, prompt user on clock-out before banking - overtime_unit: 15/30/60-min truncation choice now actually applied - notification_before_minutes: 30-min hardcode -> user-configurable 1~120 Removed dead keys (no readers in business logic): - auto_detect_boot, notification_enabled, annual_leave_used Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b91b229731
commit
14d88656fe
20
CHANGELOG.md
20
CHANGELOG.md
@ -4,6 +4,26 @@ 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.2.4] — 2026-04-30
|
||||
|
||||
### Added
|
||||
- **`auto_overtime` 옵션 실제 동작** — 기존엔 UI만 있고 동작 안 했음
|
||||
- **OFF**: 퇴근 시 적립 가능한 분이 있으면 "적립할까요?" 다이얼로그 표시 (Y/N)
|
||||
- **ON** (기본): 자동 적립 (이전과 동일)
|
||||
- **`overtime_unit` 실제 동작** — 30/15/60분 단위 적립 선택 반영
|
||||
- 기존엔 무조건 30분 절삭. 이제 설정값 사용 (`time_calculator.calculate_overtime` `unit_minutes` 파라미터)
|
||||
- **퇴근 알림 시점 사용자 설정** (`notification_before_minutes`)
|
||||
- 기존 30분 하드코드 → 1~120분 SpinBox로 사용자 지정
|
||||
- 설정 → 알림 그룹에 "퇴근 알림 시점" SpinBox
|
||||
|
||||
### Removed (죽은 옵션 정리)
|
||||
- `auto_detect_boot` — 부팅 감지 비활성화 모드. 어디서도 안 쓰임
|
||||
- `notification_enabled` — 마스터 스위치. 4개 개별 NOTIF_* 키로 충분
|
||||
- `annual_leave_used` — `leave_balance`로 대체된 레거시 카운터
|
||||
|
||||
### Fixed
|
||||
- 죽은 옵션이 사용자에게 "동작하는 것처럼" 보이던 신뢰성 문제 일괄 해소
|
||||
|
||||
## [2.2.3] — 2026-04-30
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -520,21 +520,19 @@ class Database:
|
||||
'work_minutes': '480',
|
||||
'lunch_duration_minutes': '60',
|
||||
'dinner_duration_minutes': '60',
|
||||
'auto_detect_boot': 'true',
|
||||
'auto_lunch': 'false',
|
||||
'auto_overtime': 'true',
|
||||
'theme': 'light',
|
||||
'notification_enabled': 'true',
|
||||
'notification_before_minutes': '30',
|
||||
'notification_clock_out': 'true',
|
||||
'notification_lunch': 'true',
|
||||
'notification_overtime': 'true',
|
||||
'notification_health': 'true',
|
||||
'annual_leave_total': '15',
|
||||
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화)
|
||||
'annual_leave_used': '0',
|
||||
'annual_leave_days': '15', # annual_leave_total과 자동 동기화
|
||||
'workday_boundary_hour': '6',
|
||||
'overtime_unit': '30',
|
||||
'time_format': '24'
|
||||
'time_format': '24',
|
||||
}
|
||||
|
||||
conn = self.get_connection()
|
||||
|
||||
@ -65,8 +65,17 @@ class Notifier(QObject):
|
||||
return
|
||||
time_diff = clock_out_time - current_time
|
||||
|
||||
# 30분 이내, 아직 알림 안 했으면
|
||||
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
|
||||
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
|
||||
threshold_min = 30
|
||||
if self.db is not None:
|
||||
try:
|
||||
threshold_min = int(self.db.get_setting('notification_before_minutes', '30') or '30')
|
||||
threshold_min = max(1, min(120, threshold_min))
|
||||
except (ValueError, TypeError):
|
||||
threshold_min = 30
|
||||
threshold_sec = threshold_min * 60
|
||||
|
||||
if 0 < time_diff.total_seconds() <= threshold_sec and not self.notified_30min:
|
||||
minutes_left = int(time_diff.total_seconds() / 60)
|
||||
self.notification_signal.emit(
|
||||
tr('notif.clock_out_soon.title'),
|
||||
|
||||
@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
|
||||
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
||||
|
||||
# 자동화
|
||||
AUTO_DETECT_BOOT = 'auto_detect_boot'
|
||||
AUTO_LUNCH = 'auto_lunch'
|
||||
AUTO_OVERTIME = 'auto_overtime'
|
||||
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
||||
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
||||
|
||||
# 알림
|
||||
NOTIFICATION_ENABLED = 'notification_enabled'
|
||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
|
||||
# 연차
|
||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||
ANNUAL_LEAVE_USED = 'annual_leave_used'
|
||||
LEAVE_BALANCE = 'leave_balance'
|
||||
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
||||
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
||||
|
||||
@ -135,17 +135,19 @@ class TimeCalculator:
|
||||
|
||||
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
||||
include_lunch: bool = False, include_dinner: bool = False,
|
||||
break_minutes: int = 0) -> Tuple[int, int]:
|
||||
break_minutes: int = 0,
|
||||
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||
"""
|
||||
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
|
||||
연장근무 시간 계산 (실제 시간, unit_minutes 단위 적립)
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
clock_out: 퇴근 시간
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
||||
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
|
||||
Returns:
|
||||
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
|
||||
Tuple[int, int]: (실제 연장근무 분, unit_minutes 단위 적립 분)
|
||||
"""
|
||||
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
||||
|
||||
@ -155,8 +157,8 @@ class TimeCalculator:
|
||||
overtime_duration = clock_out - expected_clock_out
|
||||
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
||||
|
||||
# 30분 단위로 절삭
|
||||
overtime_earned = (overtime_minutes // 30) * 30
|
||||
unit = unit_minutes if unit_minutes > 0 else 30
|
||||
overtime_earned = (overtime_minutes // unit) * unit
|
||||
|
||||
return overtime_minutes, overtime_earned
|
||||
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.2.3'
|
||||
__version__ = '2.2.4'
|
||||
|
||||
@ -8,7 +8,10 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.updater_client import _parse_version, is_newer, RELEASES_API
|
||||
from utils.updater_client import (
|
||||
_parse_version, is_newer, RELEASES_API,
|
||||
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionParsing:
|
||||
@ -59,6 +62,31 @@ class TestApiUrl:
|
||||
importlib.reload(updater_client)
|
||||
|
||||
|
||||
class TestCheckForUpdate:
|
||||
"""check_for_update returns (info, reason) tuple."""
|
||||
|
||||
def test_returns_tuple(self, monkeypatch):
|
||||
# 네트워크 호출이 실패하도록 잘못된 URL
|
||||
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'http://127.0.0.1:1/nope')
|
||||
import importlib
|
||||
from utils import updater_client
|
||||
importlib.reload(updater_client)
|
||||
try:
|
||||
result = updater_client.check_for_update('1.0.0', timeout=1)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
info, reason = result
|
||||
assert info is None
|
||||
assert reason == updater_client.NETWORK_ERROR
|
||||
finally:
|
||||
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
|
||||
importlib.reload(updater_client)
|
||||
|
||||
def test_constants_distinct(self):
|
||||
# 4개 상수가 모두 서로 다른 값
|
||||
values = {UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET}
|
||||
assert len(values) == 4
|
||||
|
||||
|
||||
class TestUpdaterScript:
|
||||
"""updater.py 자체 로직."""
|
||||
|
||||
|
||||
@ -1081,18 +1081,21 @@ class MainWindow(QMainWindow):
|
||||
# 오늘의 외출 시간 가져오기
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
|
||||
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
|
||||
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:
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
||||
work_minutes = int(total_hours * 60)
|
||||
if self.lunch_break_enabled:
|
||||
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외
|
||||
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 -= self.time_calc.dinner_duration_minutes
|
||||
work_minutes -= break_minutes
|
||||
work_minutes = max(0, work_minutes)
|
||||
# 30분 단위로 절삭
|
||||
overtime_earned = (work_minutes // 30) * 30
|
||||
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
|
||||
overtime_actual = work_minutes
|
||||
else:
|
||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||
@ -1100,9 +1103,27 @@ class MainWindow(QMainWindow):
|
||||
self.clock_in_time, now,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
||||
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
||||
if not auto_overtime and overtime_earned > 0:
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
|
||||
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
||||
ask = QMessageBox.question(
|
||||
self,
|
||||
"연장근무 적립 확인",
|
||||
f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n"
|
||||
f"적립하시겠습니까?\n"
|
||||
f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if ask != QMessageBox.Yes:
|
||||
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
|
||||
|
||||
# DB 업데이트
|
||||
today = datetime.now().date().isoformat()
|
||||
clock_out_str = now.strftime("%H:%M:%S")
|
||||
@ -1601,16 +1622,30 @@ class MainWindow(QMainWindow):
|
||||
def check_for_updates(self, silent: bool = False):
|
||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||
from core.version import __version__
|
||||
from utils.updater_client import check_for_update, download_update, apply_update
|
||||
from utils.updater_client import (
|
||||
check_for_update, download_update, apply_update,
|
||||
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||
)
|
||||
|
||||
info = check_for_update(__version__)
|
||||
info, reason = check_for_update(__version__)
|
||||
if info is None:
|
||||
if not silent:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"업데이트 확인",
|
||||
f"현재 최신 버전입니다 (v{__version__})."
|
||||
)
|
||||
if silent:
|
||||
return
|
||||
# 사용자가 명시적으로 트리거한 경우만 메시지 표시
|
||||
messages = {
|
||||
UP_TO_DATE: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."),
|
||||
NETWORK_ERROR: ("연결 실패",
|
||||
"업데이트 서버에 연결할 수 없습니다.\n"
|
||||
"네트워크 상태를 확인해 주세요."),
|
||||
NO_RELEASE: ("릴리스 없음",
|
||||
"업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n"
|
||||
"(저장소 비공개 또는 첫 릴리스 전)"),
|
||||
NO_ASSET: ("자산 누락",
|
||||
"새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n"
|
||||
"관리자에게 문의하세요."),
|
||||
}
|
||||
title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다."))
|
||||
QMessageBox.information(self, title, body)
|
||||
return
|
||||
|
||||
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
|
||||
|
||||
@ -18,6 +18,7 @@ from core.settings_keys import (
|
||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
NOTIFICATION_BEFORE_MINUTES,
|
||||
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||
@ -275,6 +276,22 @@ class SettingsView(QDialog):
|
||||
check_row2.addWidget(self.health_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
|
||||
# 퇴근 N분 전 알림 시점 설정
|
||||
before_row = QHBoxLayout()
|
||||
before_label = QLabel("퇴근 알림 시점:")
|
||||
before_label.setFixedWidth(110)
|
||||
self.notif_before_spin = QSpinBox()
|
||||
self.notif_before_spin.setRange(1, 120)
|
||||
self.notif_before_spin.setSingleStep(5)
|
||||
self.notif_before_spin.setValue(30)
|
||||
self.notif_before_spin.setSuffix(" 분 전")
|
||||
self.notif_before_spin.setFixedWidth(110)
|
||||
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||
before_row.addWidget(before_label)
|
||||
before_row.addWidget(self.notif_before_spin)
|
||||
before_row.addStretch()
|
||||
layout.addLayout(before_row)
|
||||
|
||||
# 시간 형식 + 테마 한 줄에
|
||||
format_row = QHBoxLayout()
|
||||
time_format_label = QLabel("시간 형식:")
|
||||
@ -796,6 +813,11 @@ class SettingsView(QDialog):
|
||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
||||
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
||||
if hasattr(self, 'notif_before_spin'):
|
||||
try:
|
||||
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||
except (ValueError, TypeError):
|
||||
self.notif_before_spin.setValue(30)
|
||||
|
||||
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
||||
time_format = settings.get(TIME_FORMAT, '24')
|
||||
@ -879,6 +901,7 @@ class SettingsView(QDialog):
|
||||
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
||||
TIME_FORMAT: self.time_format_combo.currentData(),
|
||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||
|
||||
@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
|
||||
return _parse_version(remote) > _parse_version(local)
|
||||
|
||||
|
||||
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
|
||||
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
|
||||
# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
|
||||
UP_TO_DATE = 'up_to_date'
|
||||
NETWORK_ERROR = 'network_error'
|
||||
NO_RELEASE = 'no_release' # latest 응답 자체가 없음 (404 등)
|
||||
NO_ASSET = 'no_asset' # release는 있으나 main.exe 자산 없음
|
||||
|
||||
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
|
||||
|
||||
def check_for_update(current_version: str, timeout: int = 5):
|
||||
"""Releases API 조회.
|
||||
|
||||
Returns:
|
||||
(ReleaseInfo, None) — 새 버전 있음
|
||||
(None, UP_TO_DATE) — 이미 최신
|
||||
(None, NETWORK_ERROR) — 네트워크/타임아웃 실패
|
||||
(None, NO_RELEASE) — 저장소 비공개/리소스 없음 (404)
|
||||
(None, NO_ASSET) — 새 버전이지만 main.exe 자산 미첨부
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
||||
return None
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None, NO_RELEASE
|
||||
return None, NETWORK_ERROR
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError):
|
||||
return None, NETWORK_ERROR
|
||||
|
||||
tag = data.get('tag_name', '')
|
||||
if not tag or not is_newer(tag, current_version):
|
||||
return None
|
||||
if not tag:
|
||||
return None, NO_RELEASE
|
||||
if not is_newer(tag, current_version):
|
||||
return None, UP_TO_DATE
|
||||
|
||||
asset_url = None
|
||||
for asset in data.get('assets', []):
|
||||
if asset.get('name') == ASSET_NAME:
|
||||
asset_url = asset.get('browser_download_url')
|
||||
break
|
||||
|
||||
if not asset_url:
|
||||
return None
|
||||
return None, NO_ASSET
|
||||
|
||||
return ReleaseInfo(
|
||||
info = ReleaseInfo(
|
||||
version=tag,
|
||||
asset_url=asset_url,
|
||||
notes=data.get('body', ''),
|
||||
published_at=data.get('published_at', ''),
|
||||
)
|
||||
return info, None
|
||||
|
||||
|
||||
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user