From d41e5cb921b0bcfc4fc2aee261a9794dfb0d4724 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Fri, 1 May 2026 12:54:24 +0900 Subject: [PATCH] fix(holiday): live hot-path now banks overtime from minute 1 on holidays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 회귀 시나리오 --- _integration_test.py | 25 +++ core/time_calculator.py | 30 +++ tests/test_time_calculator.py | 74 ++++++++ ui/controllers/notification_orchestrator.py | 9 +- ui/main_window.py | 198 ++++++++++++-------- 5 files changed, 250 insertions(+), 86 deletions(-) diff --git a/_integration_test.py b/_integration_test.py index 9b4e25d..4591b3a 100644 --- a/_integration_test.py +++ b/_integration_test.py @@ -696,6 +696,30 @@ def s51_accessibility_keys(): assert db.get_setting_bool('high_contrast') is True +@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 @@ -764,6 +788,7 @@ def main(): s49_discord_empty() s50_goals() s51_accessibility_keys() + s52a_holiday_hotpath() s52_csv_overtime() print() diff --git a/core/time_calculator.py b/core/time_calculator.py index e3b9a52..047d58d 100644 --- a/core/time_calculator.py +++ b/core/time_calculator.py @@ -236,6 +236,36 @@ 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 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: """ 주말 여부 확인 diff --git a/tests/test_time_calculator.py b/tests/test_time_calculator.py index 076ae7a..5b1628b 100644 --- a/tests/test_time_calculator.py +++ b/tests/test_time_calculator.py @@ -98,3 +98,77 @@ 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 diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py index 3e18b3b..e75f2e0 100644 --- a/ui/controllers/notification_orchestrator.py +++ b/ui/controllers/notification_orchestrator.py @@ -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, diff --git a/ui/main_window.py b/ui/main_window.py index d77cc00..b445919 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -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 업데이트