fix(holiday): live hot-path now banks overtime from minute 1 on holidays
3 bugs found and fixed: A. update_display() 1Hz 루프에 is_non_working_day 분기 누락 - 휴일/주말 출근 시 '남은 시간 8h'부터 카운트다운 → 실제 휴일 추가근무 시작 안 됨 - 수정: is_non_working_day=True면 calculate_holiday_overtime로 즉시 음수 remaining 표시 - 그룹 타이틀: '주말 근무 (전체 적립)' / '공휴일 근무 (전체 적립)' - 진행바: 휴일은 100% 고정 (의미 없음) - 예상 퇴근: '휴일 근무 (정해진 퇴근시각 없음)' B. check_clock_out_soon 알림이 휴일에도 fire - 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 - 수정: orchestrator.tick(is_holiday=True)면 스킵 - 점심/저녁/장시간 휴식 알림은 휴일에도 유지 (식사·건강은 휴일에도 챙김) C. 자동복구 퇴근 3곳이 (work_minutes // 30) * 30 하드코딩 - main_window.py:1385, 1512, 1581 — 사용자 overtime_unit (15/30/60) 무시 - 수정: 모두 settings에서 unit_minutes 읽어 calculate_holiday_overtime/calculate_overtime에 전달 리팩터링: 4곳에 중복되던 휴일 연장 계산 로직을 TimeCalculator.calculate_holiday_overtime로 추출. Tests: - tests/test_time_calculator.py: 9개 신규 (TestHolidayOvertime) - _integration_test.py: S52A 휴일 hot-path 회귀 시나리오
This commit is contained in:
@@ -82,10 +82,13 @@ 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) -> None:
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||
is_holiday: bool = False) -> None:
|
||||
n = self.notifier
|
||||
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||
if not is_holiday:
|
||||
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,
|
||||
|
||||
+115
-83
@@ -728,21 +728,42 @@ class MainWindow(QMainWindow):
|
||||
# 총 차감 시간 (추가근무 + 연차/반차)
|
||||
total_time_off = overtime_used_today + 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)
|
||||
# 휴일/주말이면 출근 직후부터 모든 시간이 연장근무로 흐름.
|
||||
# 정해진 퇴근 시각이 없으므로 calculate_remaining_time 분기를 건너뛴다.
|
||||
is_holiday = self.time_calc.is_non_working_day(now, self.db)
|
||||
|
||||
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,
|
||||
)
|
||||
# 추가근무/반차 사용분만큼 시작점을 늦춤 (즉 그만큼 적게 적립)
|
||||
actual_ot = max(0, actual_ot - total_time_off)
|
||||
remaining = -timedelta(minutes=actual_ot)
|
||||
else:
|
||||
# 평일: 정상 남은 시간 계산
|
||||
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:
|
||||
# 추가 근무 중
|
||||
self.remaining_time_group.setTitle("추가 근무 중")
|
||||
# 추가 근무 중 (휴일이면 출근 직후부터 항상 이 분기)
|
||||
day_type = self.time_calc.get_day_type(now, self.db)
|
||||
if day_type == 'weekend':
|
||||
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
|
||||
elif day_type == 'holiday':
|
||||
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
|
||||
else:
|
||||
self.remaining_time_group.setTitle("추가 근무 중")
|
||||
# + 기호로 표시
|
||||
total_seconds = int(abs(remaining.total_seconds()))
|
||||
hours = total_seconds // 3600
|
||||
@@ -765,35 +786,46 @@ class MainWindow(QMainWindow):
|
||||
self._set_text_if_changed(self.remaining_time_label, remaining_str)
|
||||
|
||||
# 진행률 업데이트
|
||||
# - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함)
|
||||
# - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨)
|
||||
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))
|
||||
# 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 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))
|
||||
|
||||
# 예상 퇴근 시간 (외출 시간 포함)
|
||||
# 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감
|
||||
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)}"
|
||||
)
|
||||
# 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미)
|
||||
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)}"
|
||||
)
|
||||
|
||||
# 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함)
|
||||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds())
|
||||
# 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅.
|
||||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(),
|
||||
is_holiday=is_holiday)
|
||||
|
||||
# 트레이 / 미니 위젯 갱신
|
||||
if remaining.total_seconds() < 0:
|
||||
@@ -1156,16 +1188,14 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
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
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외)
|
||||
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,
|
||||
)
|
||||
else:
|
||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||
@@ -1342,24 +1372,25 @@ 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:
|
||||
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
|
||||
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,
|
||||
)
|
||||
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
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
||||
@@ -1498,26 +1529,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:
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외)
|
||||
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
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외)
|
||||
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,
|
||||
)
|
||||
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
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
||||
@@ -1569,23 +1600,24 @@ 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:
|
||||
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
|
||||
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,
|
||||
)
|
||||
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
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# DB 업데이트
|
||||
|
||||
Reference in New Issue
Block a user