Initial release v2.2.0
CI / test (push) Has been cancelled

핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KINDNICK
2026-04-30 12:54:40 +09:00
co-authored by Claude Opus 4.7
commit bedbb1e9ec
63 changed files with 13353 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# core 모듈
+1603
View File
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
"""
Windows 이벤트 뷰어 모니터
시스템 부팅, 로그인, 종료 시간을 감지
"""
import win32evtlog
import win32evtlogutil
import win32con
import win32security
import subprocess
from datetime import datetime, timedelta, date
from typing import Optional, List, Dict
class EventMonitor:
"""Windows 이벤트 로그 모니터링 클래스"""
# 이벤트 ID 정의
EVENT_SYSTEM_BOOT = 6005 # 시스템 부팅
EVENT_SYSTEM_START = 6009 # 시스템 시작
EVENT_USER_LOGON = 4624 # 사용자 로그인
EVENT_SYSTEM_SHUTDOWN = 6006 # 시스템 종료
EVENT_SLEEP_ENTER = 42 # 절전 모드 진입 (Kernel-Power)
def __init__(self):
self.server = None # 로컬 컴퓨터
self.logtype = "System" # 시스템 로그
def get_boot_time_powershell(self) -> Optional[datetime]:
"""
PowerShell을 사용하여 시스템 부팅 시간 조회
오늘 부팅한 경우에만 반환
Returns:
datetime: 오늘의 부팅 시간, 오늘 부팅 안 했으면 None
"""
try:
# PowerShell 명령어 실행
result = subprocess.run(
['powershell', '-Command',
'(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString("yyyy-MM-dd HH:mm:ss")'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0 and result.stdout.strip():
boot_time_str = result.stdout.strip()
# "yyyy-MM-dd HH:mm:ss" 형식으로 파싱
boot_time = datetime.strptime(boot_time_str, "%Y-%m-%d %H:%M:%S")
# 오늘 부팅한 경우에만 반환
today = datetime.now().date()
if boot_time.date() == today:
return boot_time
else:
# 오늘 부팅하지 않음 (절전 모드에서 깨어난 경우)
print(f"Last boot was on {boot_time.date()}, not today ({today})")
return None
except Exception as e:
print(f"PowerShell boot time query failed: {e}")
return None
def get_today_boot_time(self) -> Optional[datetime]:
"""
오늘 첫 부팅 시간 조회 (절전 모드 복귀 포함)
오늘의 가장 빠른 시스템 시작 시간을 반환
Returns:
datetime: 첫 부팅/시작 시간, 없으면 None
"""
today = datetime.now().date()
earliest_time = None
# Kernel-General (Event ID 1, 12) - 시스템 시작/재개
kernel_general_events = self._get_events_by_id(1, limit=50)
for event in kernel_general_events:
event_time = event['time']
if event_time.date() == today:
if earliest_time is None or event_time < earliest_time:
earliest_time = event_time
# Event ID 12도 확인
kernel_general_12 = self._get_events_by_id(12, limit=50)
for event in kernel_general_12:
event_time = event['time']
if event_time.date() == today:
if earliest_time is None or event_time < earliest_time:
earliest_time = event_time
# Kernel-General에서 찾았으면 반환
if earliest_time:
return earliest_time
# 못 찾았으면 전통적인 부팅 이벤트 확인
# 6005 - 시스템 부팅
boot_events = self._get_events_by_id(self.EVENT_SYSTEM_BOOT, limit=10)
for event in boot_events:
event_time = event['time']
if event_time.date() == today:
if earliest_time is None or event_time < earliest_time:
earliest_time = event_time
# 6009 - 시스템 시작
start_events = self._get_events_by_id(self.EVENT_SYSTEM_START, limit=10)
for event in start_events:
event_time = event['time']
if event_time.date() == today:
if earliest_time is None or event_time < earliest_time:
earliest_time = event_time
return earliest_time
def get_last_shutdown_time(self) -> Optional[datetime]:
"""
마지막 종료 시간 조회
Returns:
datetime: 마지막 종료 시간, 없으면 None
"""
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=5)
if shutdown_events:
return shutdown_events[0]['time']
return None
def get_yesterday_shutdown_time(self) -> Optional[datetime]:
"""
어제의 종료 시간 조회 (퇴근 시간으로 사용)
Returns:
datetime: 어제의 종료 시간, 없으면 None
"""
yesterday = (datetime.now().date() - timedelta(days=1))
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=20)
for event in shutdown_events:
event_time = event['time']
if event_time.date() == yesterday:
return event_time
return None
def get_shutdown_time_by_date(self, target_date: date) -> Optional[datetime]:
"""
특정 날짜의 종료 시간 조회 (정상 종료 + 절전 모드)
Args:
target_date: 조회할 날짜
Returns:
datetime: 해당 날짜의 종료 시간, 없으면 None
"""
# 정상 종료 이벤트 검색
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=2000)
# 절전 모드 진입 이벤트도 검색
sleep_events = self._get_events_by_id(self.EVENT_SLEEP_ENTER, limit=2000)
# 해당 날짜의 모든 종료/절전 시간을 찾아서 가장 늦은 시간 반환
matching_events = []
# 정상 종료 이벤트 확인
for event in shutdown_events:
event_time = event['time']
if event_time.date() == target_date:
matching_events.append(('shutdown', event_time))
# 절전 모드 이벤트 확인
for event in sleep_events:
event_time = event['time']
if event_time.date() == target_date:
matching_events.append(('sleep', event_time))
if matching_events:
# 가장 늦은 시간 반환 (타입에 관계없이)
latest_type, latest_time = max(matching_events, key=lambda x: x[1])
return latest_time
return None
def get_today_logon_time(self) -> Optional[datetime]:
"""
오늘 첫 로그인 시간 조회 (보조 수단)
Returns:
datetime: 첫 로그인 시간, 없으면 None
"""
try:
today = datetime.now().date()
hand = win32evtlog.OpenEventLog(self.server, "Security")
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
events = []
total = 0
while total < 100: # 최근 100개만 확인
event_list = win32evtlog.ReadEventLog(hand, flags, 0)
if not event_list:
break
for event in event_list:
if event.EventID == self.EVENT_USER_LOGON:
event_time = datetime.fromtimestamp(int(event.TimeGenerated))
if event_time.date() == today:
events.append(event_time)
total += len(event_list)
win32evtlog.CloseEventLog(hand)
if events:
return min(events) # 가장 빠른 로그인 시간
except Exception as e:
print(f"로그인 이벤트 조회 실패: {e}")
return None
def _get_events_by_id(self, event_id: int, limit: int = 10) -> List[Dict]:
"""
특정 이벤트 ID로 이벤트 조회
Args:
event_id: 이벤트 ID
limit: 조회할 최대 개수
Returns:
List[Dict]: 이벤트 목록
"""
try:
hand = win32evtlog.OpenEventLog(self.server, self.logtype)
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
events = []
total = 0
while len(events) < limit and total < 10000:
event_list = win32evtlog.ReadEventLog(hand, flags, 0)
if not event_list:
break
for event in event_list:
# 하위 16비트가 실제 이벤트 ID
actual_event_id = event.EventID & 0xFFFF
if actual_event_id == event_id:
# pywintypes.datetime을 Python datetime으로 변환
time_generated = event.TimeGenerated
if hasattr(time_generated, 'timestamp'):
# pywintypes.datetime인 경우
event_time = datetime.fromtimestamp(time_generated.timestamp())
else:
# 이미 타임스탬프인 경우
event_time = datetime.fromtimestamp(int(time_generated))
events.append({
'event_id': actual_event_id,
'time': event_time,
'source': event.SourceName
})
if len(events) >= limit:
break
total += len(event_list)
win32evtlog.CloseEventLog(hand)
return events
except Exception as e:
print(f"이벤트 조회 실패: {e}")
return []
def get_work_start_time(self) -> Optional[datetime]:
"""
출근 시간 자동 감지 (부팅 또는 로그인 중 빠른 시간)
우선순위:
1. PowerShell을 통한 부팅 시간 (가장 확실)
2. 이벤트 뷰어를 통한 부팅 시간
3. 이벤트 뷰어를 통한 로그인 시간
Returns:
datetime: 출근 시간, 없으면 None
"""
# 1순위: PowerShell로 부팅 시간 조회 (가장 확실한 방법)
powershell_boot_time = self.get_boot_time_powershell()
if powershell_boot_time:
return powershell_boot_time
# 2순위: 이벤트 뷰어로 부팅 시간 조회
boot_time = self.get_today_boot_time()
if boot_time:
return boot_time
# 3순위: 로그인 시간 조회
logon_time = self.get_today_logon_time()
if logon_time:
return logon_time
return None
def test_event_log_access(self) -> bool:
"""
이벤트 로그 접근 가능 여부 테스트
Returns:
bool: 접근 가능하면 True
"""
try:
hand = win32evtlog.OpenEventLog(self.server, self.logtype)
win32evtlog.CloseEventLog(hand)
return True
except Exception as e:
print(f"이벤트 로그 접근 실패: {e}")
print("관리자 권한으로 실행해야 할 수 있습니다.")
return False
# 테스트 코드
if __name__ == "__main__":
import sys
# UTF-8 출력 설정
if sys.platform == 'win32':
import codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
monitor = EventMonitor()
print("=== Windows Event Monitor Test ===\n")
# PowerShell 부팅 시간 (1순위)
print("1. PowerShell Boot Time Check:")
powershell_boot = monitor.get_boot_time_powershell()
if powershell_boot:
print(f" [OK] Boot time: {powershell_boot.strftime('%Y-%m-%d %H:%M:%S')}")
else:
print(" [X] PowerShell query failed")
print("\n2. Event Log Access Check:")
if monitor.test_event_log_access():
print(" [OK] Event log access successful")
else:
print(" [X] Event log access failed (try running as administrator)")
print("\n3. Event Viewer Boot Time Check:")
boot_time = monitor.get_today_boot_time()
if boot_time:
print(f" [OK] Boot time: {boot_time.strftime('%Y-%m-%d %H:%M:%S')}")
else:
print(" [X] No boot record found today")
print("\n4. Event Viewer Logon Time Check:")
logon_time = monitor.get_today_logon_time()
if logon_time:
print(f" [OK] Logon time: {logon_time.strftime('%Y-%m-%d %H:%M:%S')}")
else:
print(" [X] No logon record found today")
print("\n" + "="*50)
# 출근 시간 (자동 감지 - 최종 결과)
work_start = monitor.get_work_start_time()
if work_start:
print(f"FINAL: Detected work start time")
print(f" {work_start.strftime('%Y-%m-%d %H:%M:%S')}")
else:
print("FINAL: Work start time detection FAILED")
print("="*50)
+568
View File
@@ -0,0 +1,568 @@
"""
경량 i18n.
`tr('key')` → 현재 언어의 번역 문자열. 키 미존재 시 ko 폴백, ko도 없으면
키 그대로 반환 (미번역도 동작). 점진 도입 가능.
새 언어 추가:
1. _DICT['en'] 옆에 'ja'/'zh' 등 추가
2. 사용자가 설정 → 언어 콤보에서 선택
"""
from __future__ import annotations
_current_lang = 'ko'
_DICT = {
'ko': {
# === 메뉴/버튼 ===
'menu.stats': '통계',
'menu.calendar': '캘린더',
'menu.daily_report': '일일보고',
'menu.help': '도움말',
'menu.settings': '설정',
'btn.clock_out': '퇴근하기',
'btn.clock_out_cancel': '🔄 퇴근 취소',
'btn.lunch_add': '점심시간 추가',
'btn.lunch_applied': '점심시간 (적용됨)',
'btn.dinner_add': '저녁시간 추가',
'btn.dinner_applied': '저녁시간 (적용됨)',
'btn.break_out': '🚪 외출 시작',
'btn.break_in': '↩️ 복귀',
'btn.save': '💾 저장',
'btn.close': '닫기',
'btn.apply': '적용',
'btn.cancel': '취소',
'btn.add': '추가',
'btn.delete': '삭제',
'btn.edit': '편집',
'btn.confirm': '확인',
'btn.ok': '확인',
# === 윈도우/다이얼로그 제목 ===
'window.main_title': '퇴근시간 계산기',
'window.settings': '⚙️ 설정',
'window.help': '📖 사용 설명서',
'window.stats': '📊 근무 통계',
'window.calendar': '📅 캘린더',
'window.mini_widget': '퇴근시간',
'window.clock_in_dialog': '출근 시간',
'window.break_view': '외출 관리',
'window.overtime_view': '연장근무 관리',
'window.leave_view': '연차 관리',
# === 라벨 ===
'label.remaining': '남은 시간',
'label.overtime_progress': '추가 근무 중',
'label.expected_clock_out': '예상 퇴근',
'label.clock_in_time': '출근 시간',
'label.clock_out_time': '퇴근 시간',
'label.work_hours_label': '하루 기본 근무:',
'label.lunch_default': '점심시간 기본:',
'label.dinner_default': '저녁시간 기본:',
'label.work_pattern': '근무 패턴:',
'label.time_format': '시간 형식:',
'label.theme': '테마:',
'label.language': '언어 / Language:',
'label.unit_hour': '시간',
'label.unit_minute': '분',
'label.unit_day': '일',
'label.unit_count': '개',
'label.weekday_mon': '월',
'label.weekday_tue': '화',
'label.weekday_wed': '수',
'label.weekday_thu': '목',
'label.weekday_fri': '금',
'label.weekday_sat': '토',
'label.weekday_sun': '일',
'label.am': '오전',
'label.pm': '오후',
# === 알림 ===
'notif.clock_out_soon.title': '⏰ 퇴근 시간 임박',
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
'notif.lunch_reminder.title': '🍱 점심시간 등록',
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
'notif.overtime_threshold.body': '적립된 연장근무가 {hours:.1f}시간 입니다.\n사용을 고려해보세요!',
'notif.health.title': '⚠️ 건강 경고',
'notif.health.body': '{days}일 연속 연장근무 중입니다.\n건강을 챙기세요!',
'notif.weekly_52.title': '🚨 주 52시간 초과',
'notif.weekly_52.body': '이번 주 총 근무시간이 {hours:.1f}시간입니다.\n법정 근로시간을 초과했습니다!',
# === 메시지박스 ===
'msg.save_success.title': '저장 완료',
'msg.save_success.body': '설정이 저장되었습니다.',
'msg.input_error.title': '입력 오류',
'msg.work_min_too_small': '하루 기본 근무 시간은 최소 30분 이상이어야 합니다.',
'msg.no_clock_in.title': '외출 불가',
'msg.no_clock_in.body': '출근하지 않은 상태입니다.',
'msg.already_break.body': '이미 외출 중입니다.',
'msg.no_record.title': '기록 없음',
'msg.no_record.body': '오늘 출근 기록이 없습니다.',
'msg.confirm_delete.title': '삭제 확인',
'msg.no_data.title': '데이터 없음',
# === 트레이 ===
'tray.open': '프로그램 열기',
'tray.mini_widget': '📌 미니 위젯',
'tray.toggle_lunch': '🍱 점심시간 토글',
'tray.quit': '종료',
'tray.tooltip_remaining': '퇴근까지: {time}',
'tray.tooltip_overtime': '추가 근무 중: {time}',
'tray.background': '프로그램이 트레이에서 실행 중입니다.',
# === 그룹 박스 ===
'group.work_time': '근무 시간 설정',
'group.notification': '알림 및 표시 설정',
'group.overtime': '연장근무 설정',
'group.leave': '휴가 설정',
'group.holiday': '공휴일 설정',
'group.data': '데이터 관리',
# === StatsView ===
'stats.title': '근무 통계',
'stats.tab_weekly': '주간',
'stats.tab_monthly': '월간',
'stats.tab_pattern': '패턴 분석',
'stats.weekly_summary': '이번 주 요약',
'stats.monthly_summary': '이번 달 요약',
'stats.total_hours': '총 근무시간:',
'stats.work_days': '근무일수:',
'stats.avg_hours': '평균 근무시간:',
'stats.total_overtime': '총 연장근무:',
'stats.pattern_insights': '근무 패턴 인사이트',
'stats.analyzing': '데이터를 분석 중입니다...',
'stats.no_data': '아직 충분한 데이터가 없습니다.',
# === CalendarView ===
'cal.title': '월간 근무 캘린더',
'cal.year_label': '년',
'cal.month_label': '월',
'cal.prev': '◀ 이전',
'cal.next': '다음 ▶',
'cal.today': '오늘',
'cal.no_record': '기록 없음',
'cal.edit_record': '기록 편집',
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
'help.tab_intro': '👋 시작하기',
'help.tab_work_hours': '🕘 근무시간',
'help.tab_overtime': '🏦 연장근무',
'help.tab_leave': '🌴 연차/휴가',
'help.tab_break': '🚪 외출/저녁',
'help.tab_faq': '❓ 자주 묻는 질문',
},
'en': {
# === Menu/Buttons ===
'menu.stats': 'Stats',
'menu.calendar': 'Calendar',
'menu.daily_report': 'Daily Report',
'menu.help': 'Help',
'menu.settings': 'Settings',
'btn.clock_out': 'Clock Out',
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
'btn.lunch_add': 'Add Lunch',
'btn.lunch_applied': 'Lunch (Applied)',
'btn.dinner_add': 'Add Dinner',
'btn.dinner_applied': 'Dinner (Applied)',
'btn.break_out': '🚪 Start Break',
'btn.break_in': '↩️ Return',
'btn.save': '💾 Save',
'btn.close': 'Close',
'btn.apply': 'Apply',
'btn.cancel': 'Cancel',
'btn.add': 'Add',
'btn.delete': 'Delete',
'btn.edit': 'Edit',
'btn.confirm': 'Confirm',
'btn.ok': 'OK',
# === Windows ===
'window.main_title': 'Clock-out Time Calculator',
'window.settings': '⚙️ Settings',
'window.help': '📖 User Guide',
'window.stats': '📊 Statistics',
'window.calendar': '📅 Calendar',
'window.mini_widget': 'Clock-out',
'window.clock_in_dialog': 'Clock-in Time',
'window.break_view': 'Break Management',
'window.overtime_view': 'Overtime Management',
'window.leave_view': 'Leave Management',
# === Labels ===
'label.remaining': 'Remaining',
'label.overtime_progress': 'Overtime',
'label.expected_clock_out': 'Expected Clock-out',
'label.clock_in_time': 'Clock-in Time',
'label.clock_out_time': 'Clock-out Time',
'label.work_hours_label': 'Daily work:',
'label.lunch_default': 'Lunch break:',
'label.dinner_default': 'Dinner break:',
'label.work_pattern': 'Work pattern:',
'label.time_format': 'Time format:',
'label.theme': 'Theme:',
'label.language': 'Language / 언어:',
'label.unit_hour': 'h',
'label.unit_minute': 'min',
'label.unit_day': 'day(s)',
'label.unit_count': '',
'label.weekday_mon': 'Mon',
'label.weekday_tue': 'Tue',
'label.weekday_wed': 'Wed',
'label.weekday_thu': 'Thu',
'label.weekday_fri': 'Fri',
'label.weekday_sat': 'Sat',
'label.weekday_sun': 'Sun',
'label.am': 'AM',
'label.pm': 'PM',
# === Notifications ===
'notif.clock_out_soon.title': '⏰ Clock-out Soon',
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
'notif.overtime_threshold.title': '💰 Overtime Balance High',
'notif.overtime_threshold.body': "{hours:.1f} hours of overtime banked.\nConsider using some.",
'notif.health.title': '⚠️ Health Warning',
'notif.health.body': "{days} consecutive days of overtime.\nTake care of your health!",
'notif.weekly_52.title': '🚨 Weekly 52h Exceeded',
'notif.weekly_52.body': "This week's total work hours: {hours:.1f}\nLegal limit exceeded!",
# === Message Boxes ===
'msg.save_success.title': 'Saved',
'msg.save_success.body': 'Settings saved successfully.',
'msg.input_error.title': 'Input Error',
'msg.work_min_too_small': 'Daily work time must be at least 30 minutes.',
'msg.no_clock_in.title': 'Cannot Take Break',
'msg.no_clock_in.body': 'Not clocked in.',
'msg.already_break.body': 'Already on break.',
'msg.no_record.title': 'No Record',
'msg.no_record.body': 'No clock-in record for today.',
'msg.confirm_delete.title': 'Confirm Delete',
'msg.no_data.title': 'No Data',
# === Tray ===
'tray.open': 'Open Program',
'tray.mini_widget': '📌 Mini Widget',
'tray.toggle_lunch': '🍱 Toggle Lunch',
'tray.quit': 'Quit',
'tray.tooltip_remaining': 'Until clock-out: {time}',
'tray.tooltip_overtime': 'Overtime: {time}',
'tray.background': 'Program is running in the tray.',
# === Groups ===
'group.work_time': 'Work Time Settings',
'group.notification': 'Notifications & Display',
'group.overtime': 'Overtime Settings',
'group.leave': 'Leave Settings',
'group.holiday': 'Holidays',
'group.data': 'Data Management',
# === StatsView ===
'stats.title': 'Work Statistics',
'stats.tab_weekly': 'Weekly',
'stats.tab_monthly': 'Monthly',
'stats.tab_pattern': 'Patterns',
'stats.weekly_summary': 'This Week',
'stats.monthly_summary': 'This Month',
'stats.total_hours': 'Total hours:',
'stats.work_days': 'Work days:',
'stats.avg_hours': 'Avg hours/day:',
'stats.total_overtime': 'Total overtime:',
'stats.pattern_insights': 'Work Pattern Insights',
'stats.analyzing': 'Analyzing...',
'stats.no_data': 'Not enough data yet.',
# === CalendarView ===
'cal.title': 'Monthly Calendar',
'cal.year_label': 'Y',
'cal.month_label': 'M',
'cal.prev': '◀ Prev',
'cal.next': 'Next ▶',
'cal.today': 'Today',
'cal.no_record': 'No record',
'cal.edit_record': 'Edit record',
# === HelpView ===
'help.tab_intro': '👋 Getting Started',
'help.tab_work_hours': '🕘 Work Hours',
'help.tab_overtime': '🏦 Overtime',
'help.tab_leave': '🌴 Leave',
'help.tab_break': '🚪 Break/Dinner',
'help.tab_faq': '❓ FAQ',
},
}
# === HelpView 큰 HTML 콘텐츠 (별도 사전) ===
_HELP_HTML = {
'ko': {
'help.html.intro': """
<h2>👋 환영합니다!</h2>
<p><b>퇴근시간 계산기</b>는 출근 시간 자동 감지부터 연장근무 적립·사용까지
하루 근무를 정리해 주는 데스크톱 앱입니다.</p>
<h3>한눈에 보는 기본 흐름</h3>
<ol>
<li><b>출근</b> — 컴퓨터 켜진 시각이 자동으로 출근 시간으로 기록돼요.</li>
<li><b>근무 중</b> — 메인 화면에 퇴근까지 남은 시간이 1초마다 갱신됩니다.</li>
<li><b>점심/저녁</b> — 식사 시간 버튼을 누르면 그만큼 근무시간이 늘어납니다.</li>
<li><b>외출</b> — "외출 시작/복귀" 버튼으로 잠깐 자리 비운 시간을 추적합니다.</li>
<li><b>퇴근</b> — 퇴근 버튼을 누르면 연장근무가 30분 단위로 자동 적립됩니다.</li>
</ol>
<h3>처음 켰다면 꼭 확인하세요</h3>
<ul>
<li><b>설정 → 근무 시간</b>: 본인 근무 패턴(8시간 / 단축 7h30m / 6시간 등)을
프리셋에서 선택하거나 직접 입력하세요.</li>
<li><b>설정 → 휴가 → 연간 연차</b>: 본인 연차 일수를 맞춰 두면 잔여 연차가
자동 계산됩니다.</li>
<li><b>관리자 권한</b>이 필요할 수 있어요. 부팅 시간이 자동 감지되지 않으면
관리자 권한으로 실행해 보세요.</li>
</ul>
""",
'help.html.work_hours': """
<h2>🕘 근무시간 설정</h2>
<h3>표준 근무 / 단축근무 / 시간제 모두 지원</h3>
<ul>
<li><b>표준 8시간</b> — 점심 60분 (기본값)</li>
<li><b>단축근무 7시간 30분</b> — 점심 30분</li>
<li><b>단축근무 7시간</b> — 점심 60분</li>
<li><b>단축근무 6시간</b> — 점심 30분</li>
<li><b>반일 4시간</b> — 점심 없음</li>
<li><b>사용자 정의</b> — 시간/분 직접 입력 (5분 단위)</li>
</ul>
<h3>💡 단축근무 사용자 안내</h3>
<p>예) 하루 7시간 30분 근무 + 점심 30분</p>
<ol>
<li>설정 → 근무 시간 → <b>근무 패턴</b>에서
<i>"단축근무 7시간 30분 (점심 30분)"</i> 선택</li>
<li>또는 직접 입력: <b>하루 기본 근무</b>에 <code>7 시간 30 분</code>,
<b>점심시간 기본</b>에 <code>30 분</code></li>
<li><b>저장</b> 클릭하면 즉시 메인 화면 계산이 갱신됩니다.</li>
</ol>
<h3>점심시간 자동 적용</h3>
<p>설정에서 <b>"자동 적용"</b>을 체크하면 출근 후 <b>4시간 경과</b> 시
점심시간이 자동으로 켜집니다.</p>
""",
'help.html.overtime': """
<h2>🏦 연장근무 30분 단위 적립 시스템</h2>
<h3>적립 규칙</h3>
<p>정규 퇴근시간 이후 일한 시간은 <b>30분 단위로 절삭</b>되어 적립됩니다.</p>
<ul>
<li>1시간 35분 일했다면 → <b>1시간 30분</b> 적립</li>
<li>55분 일했다면 → <b>30분</b> 적립</li>
<li>29분 일했다면 → <b>0분</b> 적립</li>
</ul>
<h3>사용 방법</h3>
<p>적립된 연장근무는 메인 화면 <b>"30분 사용" / "1시간 사용"</b> 버튼으로
쓸 수 있어요. 사용한 만큼 그날 퇴근시간이 앞당겨집니다.</p>
<h3>주말·공휴일 근무</h3>
<p>주말 또는 등록된 공휴일에 일한 시간은
<b>모든 시간이 연장근무로 적립</b>됩니다.</p>
""",
'help.html.leave': """
<h2>🌴 연차·반차 관리</h2>
<h3>연차 잔액 자동 계산</h3>
<p>잔액 = <b>연간 연차</b> − (프로그램 외 사용분 + 프로그램에서 기록된 사용분)</p>
<h3>반차·반반차 지원</h3>
<ul>
<li><b>1.0일</b> — 종일 연차</li>
<li><b>0.5일</b> — 반차 (4시간)</li>
<li><b>0.25일</b> — 반반차 (2시간)</li>
</ul>
<h3>단축근무자의 연차 환산</h3>
<p>1일 연차의 시간 길이는 설정의 <b>하루 기본 근무</b>를 따릅니다.
예: 7시간 30분 근무자는 1일 연차가 7시간 30분(=450분)으로 환산됩니다.</p>
""",
'help.html.break': """
<h2>🚪 외출 / 저녁시간</h2>
<h3>외출 (잠깐 자리 비움)</h3>
<p>병원, 잠깐 외근 등으로 자리를 비울 때 <b>외출 시작 → 복귀</b> 버튼으로
시간을 추적하세요.</p>
<h3>화면 잠금 자동 외출</h3>
<p>설정에서 <b>"화면 잠금 시 자동 외출/복귀"</b>를 켜면 PC 잠금 시 자동으로
외출이 시작되고, 풀리면 복귀로 처리됩니다.</p>
<h3>저녁시간</h3>
<p>야근하면서 저녁을 먹는다면 <b>저녁시간 추가</b> 버튼을 눌러주세요.</p>
""",
'help.html.faq': """
<h2>❓ 자주 묻는 질문</h2>
<h3>Q. 출근 시간이 잘못 잡혔어요</h3>
<p>메인 화면 출근 시각 옆 <b>편집(연필)</b> 아이콘으로 수정할 수 있어요.</p>
<h3>Q. 단축근무 7시간 30분으로 설정하고 싶어요</h3>
<p>설정 → 근무 시간 → 근무 패턴에서 프리셋을 선택하거나 시·분을 직접 입력하세요.</p>
<h3>Q. 데이터는 어디에 저장되나요?</h3>
<p>실행 폴더의 <code>database.db</code> (SQLite). 자동 백업은
<code>~/.clockout_backups/</code>에 1일 1회 회전됩니다.</p>
""",
},
'en': {
'help.html.intro': """
<h2>👋 Welcome!</h2>
<p><b>Clock-out Time Calculator</b> is a desktop app that organizes your daily work
— from auto-detecting clock-in time to banking and using overtime.</p>
<h3>Basic Flow</h3>
<ol>
<li><b>Clock in</b> — System boot time is auto-recorded as your clock-in.</li>
<li><b>Working</b> — Remaining time updates every second on the main screen.</li>
<li><b>Lunch/Dinner</b> — Press the meal buttons to extend work time.</li>
<li><b>Break</b> — Track time away with "Start Break / Return" buttons.</li>
<li><b>Clock out</b> — Press the button; overtime is banked in 30-min units.</li>
</ol>
<h3>First-Time Setup</h3>
<ul>
<li><b>Settings → Work Time</b>: pick your pattern from presets
(8h / 7h30m / 6h / 4h half-day) or enter custom values.</li>
<li><b>Settings → Leave</b>: set your annual leave days.</li>
<li><b>Admin rights</b> may be required for boot-time auto-detection.</li>
</ul>
""",
'help.html.work_hours': """
<h2>🕘 Work Time Settings</h2>
<h3>Supports Standard / Reduced / Part-time</h3>
<ul>
<li><b>Standard 8h</b> — 60-min lunch (default)</li>
<li><b>Reduced 7h30m</b> — 30-min lunch</li>
<li><b>Reduced 7h</b> — 60-min lunch</li>
<li><b>Reduced 6h</b> — 30-min lunch</li>
<li><b>Half-day 4h</b> — no lunch</li>
<li><b>Custom</b> — direct input (5-min granularity)</li>
</ul>
<h3>💡 For Reduced-Hours Users</h3>
<p>Example: 7h30m daily + 30-min lunch</p>
<ol>
<li>Settings → Work Time → pick <i>"Reduced 7h30m (30-min lunch)"</i> preset</li>
<li>Or enter directly: 7h 30min daily, 30min lunch</li>
<li>Click <b>Save</b> — main screen recalculates immediately.</li>
</ol>
<h3>Auto Lunch</h3>
<p>Enable "Auto Apply" in settings to automatically turn on lunch
after 4 hours from clock-in.</p>
""",
'help.html.overtime': """
<h2>🏦 30-Minute Overtime Banking</h2>
<h3>Banking Rule</h3>
<p>Time worked past your scheduled clock-out is <b>truncated to 30-min units</b>.</p>
<ul>
<li>1h35m worked → <b>1h30m</b> banked</li>
<li>55min worked → <b>30min</b> banked</li>
<li>29min worked → <b>0min</b> banked</li>
</ul>
<h3>Using Banked Overtime</h3>
<p>Use buttons on the main screen to consume banked overtime.
Each unit lets you clock out earlier on a chosen day.</p>
<h3>Weekend/Holiday Work</h3>
<p>All hours worked on weekends or registered holidays are
<b>banked entirely as overtime</b>.</p>
""",
'help.html.leave': """
<h2>🌴 Annual Leave Management</h2>
<h3>Auto Balance Calculation</h3>
<p>Balance = annual leave − (pre-program usage + recorded usage)</p>
<h3>Half / Quarter Day</h3>
<ul>
<li><b>1.0 day</b> — full leave</li>
<li><b>0.5 day</b> — half (4h)</li>
<li><b>0.25 day</b> — quarter (2h)</li>
</ul>
<h3>Reduced-Hours Conversion</h3>
<p>1 leave day equals your configured daily work time.
Example: 7h30m worker → 1 day = 7h30m (450 min).</p>
""",
'help.html.break': """
<h2>🚪 Break / Dinner</h2>
<h3>Break (Briefly Away)</h3>
<p>For short absences (medical, errands), use <b>Start Break / Return</b>.</p>
<h3>Auto Break on Screen Lock</h3>
<p>Enable "Auto break on screen lock" in settings — when the PC locks,
a break starts automatically; on unlock, you return.</p>
<h3>Dinner</h3>
<p>For overtime with dinner, press <b>Add Dinner</b>.</p>
""",
'help.html.faq': """
<h2>❓ FAQ</h2>
<h3>Q. Clock-in time is wrong</h3>
<p>Click the pencil icon next to clock-in time on the main screen to edit.</p>
<h3>Q. How to set 7h30m work day?</h3>
<p>Settings → Work Time → pick the preset or enter hours/minutes directly.</p>
<h3>Q. Where is data stored?</h3>
<p><code>database.db</code> in the program folder (SQLite). Daily auto-backups
rotate in <code>~/.clockout_backups/</code>.</p>
""",
},
}
def set_language(lang: str) -> None:
global _current_lang
if lang in _DICT:
_current_lang = lang
def get_language() -> str:
return _current_lang
def tr(key: str, **kwargs) -> str:
"""번역 조회. 키 미존재 시 ko 폴백 → 키 그대로."""
table = _DICT.get(_current_lang, _DICT['ko'])
text = table.get(key) or _DICT['ko'].get(key) or key
if kwargs:
try:
return text.format(**kwargs)
except (KeyError, IndexError):
return text
return text
def tr_html(key: str) -> str:
"""HTML 콘텐츠 조회 (HelpView용)."""
table = _HELP_HTML.get(_current_lang, _HELP_HTML['ko'])
return table.get(key) or _HELP_HTML['ko'].get(key) or f"<p>missing: {key}</p>"
def available_languages() -> list:
return list(_DICT.keys())
def language_label(code: str) -> str:
return {'ko': '한국어', 'en': 'English'}.get(code, code)
+187
View File
@@ -0,0 +1,187 @@
"""
알림 시스템
퇴근 시간 알림, 점심시간 알림 등
"""
from datetime import datetime, timedelta
from typing import Optional
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
from core.settings_keys import (
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
)
from core.i18n import tr
class Notifier(QObject):
"""알림 시스템 클래스"""
notification_signal = pyqtSignal(str, str) # (제목, 메시지)
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db # 설정 키 가드용 (None이면 모든 알림 활성)
self.timer = QTimer()
self.timer.timeout.connect(self.check_notifications)
self.timer.start(60000) # 1분마다 체크
# 알림 상태 추적
self.notified_30min = False
self.notified_lunch = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False
self.notified_threshold = False
self.last_check_date = datetime.now().date()
def _enabled(self, key: str) -> bool:
"""설정에서 해당 알림이 켜져 있는지. db 없으면 기본 켜짐."""
if self.db is None:
return True
val = self.db.get_setting(key, 'true')
return str(val).lower() in ('1', 'true', 'yes')
def check_notifications(self):
"""알림 체크"""
now = datetime.now()
current_date = now.date()
# 날짜가 바뀌면 알림 상태 리셋
if current_date != self.last_check_date:
self.reset_notifications()
self.last_check_date = current_date
def check_clock_out_soon(self, clock_out_time: datetime, current_time: Optional[datetime] = None):
"""
퇴근 30분 전 알림
Args:
clock_out_time: 예상 퇴근 시간
current_time: 현재 시간 (None이면 지금)
"""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_CLOCK_OUT):
return
time_diff = clock_out_time - current_time
# 30분 이내, 아직 알림 안 했으면
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
minutes_left = int(time_diff.total_seconds() / 60)
self.notification_signal.emit(
tr('notif.clock_out_soon.title'),
tr('notif.clock_out_soon.body', minutes=minutes_left),
)
self.notified_30min = True
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
current_time: Optional[datetime] = None):
"""
점심시간 등록 알림
Args:
clock_in_time: 출근 시간
lunch_enabled: 점심시간 등록 여부
current_time: 현재 시간
"""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_LUNCH):
return
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
if lunch_enabled or self.notified_lunch:
return
# 출근 후 4시간 경과 (점심시간으로 추정)
time_since_clock_in = current_time - clock_in_time
if time_since_clock_in.total_seconds() >= 4 * 3600:
self.notification_signal.emit(
tr('notif.lunch_reminder.title'),
tr('notif.lunch_reminder.body'),
)
self.notified_lunch = True
def check_overtime_earning(self, overtime_minutes: int):
"""
연장근무 적립 알림
Args:
overtime_minutes: 예상 연장근무 시간 (분)
"""
if not self._enabled(NOTIF_OVERTIME):
return
if overtime_minutes >= 30 and not self.notified_overtime:
hours = overtime_minutes // 60
mins = overtime_minutes % 60
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
self.notification_signal.emit(
tr('notif.overtime_earning.title'),
tr('notif.overtime_earning.body', time_str=time_str),
)
self.notified_overtime = True
def notify_overtime_threshold(self, total_overtime_hours: float):
"""연장근무 누적 알림 (20시간 이상)"""
if not self._enabled(NOTIF_OVERTIME):
return
if total_overtime_hours >= 20 and not self.notified_threshold:
self.notification_signal.emit(
tr('notif.overtime_threshold.title'),
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
)
self.notified_threshold = True
def notify_health_warning(self, consecutive_overtime_days: int):
"""건강 경고 (연속 연장근무 일수)"""
if not self._enabled(NOTIF_HEALTH):
return
if consecutive_overtime_days >= 3 and not self.notified_health:
self.notification_signal.emit(
tr('notif.health.title'),
tr('notif.health.body', days=consecutive_overtime_days),
)
self.notified_health = True
def notify_weekly_hours(self, total_hours: float):
"""주 52시간 경고"""
if not self._enabled(NOTIF_HEALTH):
return
if total_hours > 52 and not self.notified_weekly:
self.notification_signal.emit(
tr('notif.weekly_52.title'),
tr('notif.weekly_52.body', hours=total_hours),
)
self.notified_weekly = True
def reset_notifications(self):
"""알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False
self.notified_lunch = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False
self.notified_threshold = False
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication, QMessageBox
import sys
app = QApplication(sys.argv)
notifier = Notifier()
# 시그널 연결 (테스트)
def show_notification(title, message):
QMessageBox.information(None, title, message)
notifier.notification_signal.connect(show_notification)
# 테스트: 퇴근 30분 전
clock_out = datetime.now() + timedelta(minutes=25)
notifier.check_clock_out_soon(clock_out)
print("Notifier test completed")
+52
View File
@@ -0,0 +1,52 @@
"""
설정 키 상수.
여러 모듈에서 raw 문자열로 쓰던 키를 단일 출처로 모음. 오타 방지·grep 용이.
새 키 추가 시 여기에 등록 후 `init_default_settings()`에 기본값도 추가.
"""
# 근무시간
WORK_HOURS = 'work_hours'
WORK_MINUTES = 'work_minutes'
LUNCH_DURATION_MINUTES = 'lunch_duration_minutes'
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'
NOTIF_OVERTIME = 'notification_overtime'
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'
# UI/표시
THEME = 'theme'
TIME_FORMAT = 'time_format'
LANGUAGE = 'language'
OVERTIME_UNIT = 'overtime_unit'
# 통합/외부
DB_PATH_OVERRIDE = 'db_path_override'
# 백업
LAST_BACKUP_DATE = 'last_backup_date'
# 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
+326
View File
@@ -0,0 +1,326 @@
"""
시간 계산 로직
근무시간, 퇴근시간, 연장근무 계산
"""
from datetime import datetime, time, timedelta
from typing import Tuple, Optional
class TimeCalculator:
"""근무시간 계산 클래스"""
def __init__(self, work_hours=None, lunch_duration_minutes: int = 60, dinner_duration_minutes: int = 60,
work_minutes: int = None):
"""
Args:
work_hours: 기본 근무시간 (시간 단위, float 허용 - 호환성용)
work_minutes: 기본 근무시간 (분 단위, 지정 시 work_hours보다 우선)
lunch_duration_minutes: 점심시간 (분)
dinner_duration_minutes: 저녁시간 (분)
"""
if work_minutes is not None:
self.work_minutes = int(work_minutes)
elif work_hours is not None:
self.work_minutes = int(round(float(work_hours) * 60))
else:
self.work_minutes = 480
self.lunch_duration_minutes = lunch_duration_minutes
self.dinner_duration_minutes = dinner_duration_minutes
@property
def work_hours(self):
return self.work_minutes / 60.0
def calculate_clock_out_time(self, clock_in: datetime,
include_lunch: bool = False,
include_dinner: bool = False,
break_minutes: int = 0) -> datetime:
"""
퇴근시간 계산
Args:
clock_in: 출근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
break_minutes: 외출 시간 (분)
Returns:
datetime: 예상 퇴근 시간
"""
total_minutes = self.work_minutes + break_minutes
if include_lunch:
total_minutes += self.lunch_duration_minutes
if include_dinner:
total_minutes += self.dinner_duration_minutes
clock_out = clock_in + timedelta(minutes=total_minutes)
return clock_out
def calculate_remaining_time(self, clock_in: datetime,
include_lunch: bool = False,
include_dinner: bool = False,
current_time: Optional[datetime] = None,
break_minutes: int = 0) -> timedelta:
"""
퇴근까지 남은 시간 계산
Args:
clock_in: 출근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
current_time: 현재 시간 (None이면 지금 시간 사용)
break_minutes: 외출 시간 (분)
Returns:
timedelta: 남은 시간 (음수면 이미 퇴근 시간 경과)
"""
if current_time is None:
current_time = datetime.now()
# calculate_clock_out_time()에서 이미 break_minutes를 처리하므로
# 여기서 중복으로 추가하지 않음
clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
remaining = clock_out - current_time
return remaining
def calculate_work_progress(self, clock_in: datetime,
include_lunch: bool = False,
include_dinner: bool = False,
current_time: Optional[datetime] = None,
break_minutes: int = 0,
overtime_used_minutes: int = 0) -> float:
"""
근무 진행률 계산 (0.0 ~ 1.0)
Args:
clock_in: 출근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
current_time: 현재 시간
break_minutes: 외출 시간 (분) - 필요 근무시간 증가
overtime_used_minutes: 사용한 추가근무 (분) - 필요 근무시간 감소
Returns:
float: 진행률 (0.0 ~ 1.0)
"""
if current_time is None:
current_time = datetime.now()
# 전체 필요 근무 시간 (초)
# = 기본 근무시간 + 점심시간 + 저녁시간 + 외출시간 - 추가근무 사용시간
base_work_seconds = self.work_minutes * 60
lunch_seconds = self.lunch_duration_minutes * 60 if include_lunch else 0
dinner_seconds = self.dinner_duration_minutes * 60 if include_dinner else 0
total_work_seconds = base_work_seconds + lunch_seconds + dinner_seconds + (break_minutes * 60) - (overtime_used_minutes * 60)
# 경과 시간 (초)
elapsed_seconds = (current_time - clock_in).total_seconds()
# 진행률 계산 (0.0 ~ 1.0 범위로 제한)
if total_work_seconds <= 0:
# 필요 시간이 0 이하면 100% 완료로 처리
return 1.0
progress = elapsed_seconds / total_work_seconds
return max(min(progress, 1.0), 0.0)
def calculate_total_work_time(self, clock_in: datetime,
clock_out: datetime) -> float:
"""
총 근무시간 계산 (시간 단위)
Args:
clock_in: 출근 시간
clock_out: 퇴근 시간
Returns:
float: 총 근무시간 (시간)
"""
work_duration = clock_out - clock_in
return work_duration.total_seconds() / 3600
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]:
"""
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
Args:
clock_in: 출근 시간
clock_out: 퇴근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
Returns:
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
"""
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
if clock_out <= expected_clock_out:
return 0, 0 # 연장근무 없음
overtime_duration = clock_out - expected_clock_out
overtime_minutes = int(overtime_duration.total_seconds() / 60)
# 30분 단위로 절삭
overtime_earned = (overtime_minutes // 30) * 30
return overtime_minutes, overtime_earned
def format_time_delta(self, td: timedelta) -> str:
"""
timedelta를 HH:MM:SS 형식으로 변환
Args:
td: timedelta 객체
Returns:
str: "HH:MM:SS" 형식 문자열
"""
total_seconds = int(abs(td.total_seconds()))
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
sign = "-" if td.total_seconds() < 0 else ""
return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
def format_overtime_tokens(self, total_minutes: int) -> Tuple[int, str]:
"""
연장근무 시간을 토큰 형식으로 변환
Args:
total_minutes: 총 연장근무 시간 (분)
Returns:
Tuple[int, str]: (토큰 개수, 시간 문자열)
"""
tokens = total_minutes // 30 # 30분 = 1토큰
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0 and minutes > 0:
time_str = f"{hours}시간 {minutes}분"
elif hours > 0:
time_str = f"{hours}시간"
else:
time_str = f"{minutes}분"
return tokens, time_str
def parse_time_string(self, time_str: str) -> datetime:
"""
시간 문자열을 datetime으로 변환
Args:
time_str: "HH:MM:SS" 또는 "HH:MM" 형식
Returns:
datetime: 오늘 날짜 + 해당 시간
"""
try:
# "HH:MM:SS" 형식
if len(time_str.split(':')) == 3:
t = datetime.strptime(time_str, "%H:%M:%S").time()
else:
# "HH:MM" 형식
t = datetime.strptime(time_str, "%H:%M").time()
return datetime.combine(datetime.today(), t)
except ValueError as e:
raise ValueError(f"잘못된 시간 형식: {time_str}") from e
def is_overtime_needed(self, clock_in: datetime,
target_overtime_minutes: int,
include_lunch: bool = False,
include_dinner: bool = False) -> datetime:
"""
특정 연장근무 시간을 채우기 위한 퇴근 시간 계산
Args:
clock_in: 출근 시간
target_overtime_minutes: 목표 연장근무 시간 (분)
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
Returns:
datetime: 목표 달성을 위한 퇴근 시간
"""
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
def is_weekend(self, date_obj: datetime) -> bool:
"""
주말 여부 확인
Args:
date_obj: 확인할 날짜
Returns:
bool: 토요일(5) 또는 일요일(6)이면 True
"""
return date_obj.weekday() in [5, 6]
def is_holiday(self, date_obj: datetime, db=None) -> bool:
"""
공휴일 여부 확인
Args:
date_obj: 확인할 날짜
db: Database 인스턴스 (None이면 False 반환)
Returns:
bool: 공휴일이면 True
"""
if db is None:
return False
date_str = date_obj.strftime("%Y-%m-%d")
return db.is_holiday(date_str)
def is_non_working_day(self, date_obj: datetime, db=None) -> bool:
"""
비근무일 여부 확인 (주말 또는 공휴일)
Args:
date_obj: 확인할 날짜
db: Database 인스턴스 (공휴일 체크용)
Returns:
bool: 주말 또는 공휴일이면 True
"""
if self.is_weekend(date_obj):
return True
return self.is_holiday(date_obj, db)
def get_day_type(self, date_obj: datetime, db=None) -> str:
"""
근무일 유형 반환
Args:
date_obj: 확인할 날짜
db: Database 인스턴스
Returns:
str: 'weekend', 'holiday', 'normal' 중 하나
"""
if self.is_weekend(date_obj):
return 'weekend'
if self.is_holiday(date_obj, db):
return 'holiday'
return 'normal'
# 테스트 코드
if __name__ == "__main__":
calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
print("=== 시간 계산기 테스트 ===\n")
# 출근 시간 설정
clock_in = datetime.now().replace(hour=9, minute=0, second=0, microsecond=0)
print(f"출근 시간: {clock_in.strftime('%H:%M:%S')}")
# 퇴근 시간 계산 (점심 없음)
clock_out_no_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=False)
print(f"퇴근 시간 (점심 제외): {clock_out_no_lunch.strftime('%H:%M:%S')}")
# 퇴근 시간 계산 (점심 포함)
clock_out_with_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=True)
print(f"퇴근 시간 (점심 포함): {clock_out_with_lunch.strftime('%H:%M:%S')}")
# 현재 시간 기준 남은 시간
remaining = calc.calculate_remaining_time(clock_in, include_lunch=True)
print(f"\n남은 시간: {calc.format_time_delta(remaining)}")
# 진행률
progress = calc.calculate_work_progress(clock_in, include_lunch=True)
print(f"진행률: {progress * 100:.1f}%")
# 연장근무 계산
actual_clock_out = clock_out_with_lunch + timedelta(hours=2, minutes=15)
overtime_actual, overtime_earned = calc.calculate_overtime(
clock_in, actual_clock_out, include_lunch=True
)
print(f"\n실제 퇴근: {actual_clock_out.strftime('%H:%M:%S')}")
print(f"연장근무: {overtime_actual}분 (적립: {overtime_earned}분)")
# 토큰 형식
tokens, time_str = calc.format_overtime_tokens(overtime_earned)
print(f"토큰: 🕐 × {tokens} ({time_str})")
+7
View File
@@ -0,0 +1,7 @@
"""
앱 버전 상수.
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.2.0'