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:
KINDNICK 2026-05-01 12:54:24 +09:00
parent c5df37ca57
commit d41e5cb921
5 changed files with 250 additions and 86 deletions

View File

@ -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()

View File

@ -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:
"""
주말 여부 확인

View File

@ -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

View File

@ -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,

View File

@ -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 업데이트