""" 데이터베이스 관리 모듈 SQLite를 사용하여 근무 기록, 연장근무, 휴가 등을 관리 """ import sqlite3 from datetime import datetime, date from typing import Optional, List, Dict, Tuple import os from core.settings_keys import ( WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, ) class Database: def __init__(self, db_path: str = "database.db"): self.db_path = db_path self.init_database() self._enable_concurrency() def get_connection(self) -> sqlite3.Connection: """데이터베이스 연결 반환. timeout=5초: 다른 PC/프로세스가 쓰는 동안 락 충돌 시 대기. 클라우드 동기화(OneDrive 등)로 같은 DB를 두 PC에서 쓸 때 안전. """ conn = sqlite3.connect(self.db_path, timeout=5.0) conn.row_factory = sqlite3.Row return conn def _enable_concurrency(self): """WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화.""" try: conn = sqlite3.connect(self.db_path, timeout=5.0) conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA synchronous = NORMAL") conn.commit() conn.close() except sqlite3.OperationalError: # WAL 미지원 환경(읽기전용 폴더 등) — 기본 모드로 fallback pass def init_database(self): """데이터베이스 초기화 및 테이블 생성""" conn = self.get_connection() cursor = conn.cursor() # 일일 근무 기록 테이블 cursor.execute(''' CREATE TABLE IF NOT EXISTS work_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, date DATE NOT NULL UNIQUE, clock_in TIME NOT NULL, clock_out TIME, lunch_break BOOLEAN DEFAULT 0, total_hours REAL, overtime_minutes INTEGER DEFAULT 0, overtime_earned INTEGER DEFAULT 0, overtime_used INTEGER DEFAULT 0, work_type TEXT DEFAULT 'normal', memo TEXT, is_manual BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 연장근무 적립 내역 cursor.execute(''' CREATE TABLE IF NOT EXISTS overtime_bank ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_record_id INTEGER, earned_minutes INTEGER NOT NULL, date DATE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (work_record_id) REFERENCES work_records(id) ) ''') # 연장근무 사용 내역 cursor.execute(''' CREATE TABLE IF NOT EXISTS overtime_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_record_id INTEGER, used_minutes INTEGER NOT NULL, date DATE NOT NULL, reason TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (work_record_id) REFERENCES work_records(id) ) ''') # 휴가 기록 cursor.execute(''' CREATE TABLE IF NOT EXISTS leave_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, date DATE NOT NULL, leave_type TEXT NOT NULL, days REAL, memo TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 설정 cursor.execute(''' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 업적 cursor.execute(''' CREATE TABLE IF NOT EXISTS achievements ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, earned_date DATE, badge_icon TEXT ) ''') # 외출 기록 cursor.execute(''' CREATE TABLE IF NOT EXISTS break_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_record_id INTEGER, date DATE NOT NULL, break_out TIME NOT NULL, break_in TIME, total_minutes INTEGER, reason TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE ) ''') # 공휴일 테이블 cursor.execute(''' CREATE TABLE IF NOT EXISTS holidays ( id INTEGER PRIMARY KEY AUTOINCREMENT, date DATE NOT NULL UNIQUE, name TEXT NOT NULL, is_recurring BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') conn.commit() conn.close() # 데이터베이스 마이그레이션 실행 self.migrate_break_records_cascade() self.migrate_lunch_duration_to_minutes() self.migrate_leave_records_hours_to_days() self.migrate_add_dinner_break() self.migrate_cleanup_balance_adjustments() self.migrate_work_hours_to_minutes() self.migrate_annual_leave_keys() self.migrate_v23_break_type() self.migrate_v23_notification_log() self.migrate_v23_onboarding_for_existing() # 기본 설정 초기화 self.init_default_settings() def migrate_break_records_cascade(self): """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션)""" conn = self.get_connection() cursor = conn.cursor() # 기존 테이블에 CASCADE가 있는지 확인 cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='break_records'") result = cursor.fetchone() if result and 'ON DELETE CASCADE' not in result[0]: # CASCADE가 없으면 테이블 재생성 try: # 1. 기존 데이터 백업 cursor.execute('SELECT * FROM break_records') backup_data = cursor.fetchall() # 2. 기존 테이블 삭제 cursor.execute('DROP TABLE IF EXISTS break_records') # 3. CASCADE 포함한 새 테이블 생성 cursor.execute(''' CREATE TABLE break_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_record_id INTEGER, date DATE NOT NULL, break_out TIME NOT NULL, break_in TIME, total_minutes INTEGER, reason TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE ) ''') # 4. 데이터 복원 for row in backup_data: cursor.execute(''' INSERT INTO break_records (id, work_record_id, date, break_out, break_in, total_minutes, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', tuple(row)) conn.commit() print("break_records 테이블 CASCADE 마이그레이션 완료") except Exception as e: conn.rollback() print(f"마이그레이션 오류: {e}") conn.close() def migrate_lunch_duration_to_minutes(self): """lunch_duration을 시간 단위에서 분 단위로 마이그레이션""" conn = self.get_connection() cursor = conn.cursor() try: # 기존 lunch_duration 설정 확인 cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration'") result = cursor.fetchone() if result: # 기존 값이 있으면 시간 단위로 저장되어 있으므로 분으로 변환 lunch_hours = float(result['value']) lunch_minutes = int(lunch_hours * 60) # lunch_duration_minutes로 저장 cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('lunch_duration_minutes', ?, CURRENT_TIMESTAMP) ''', (str(lunch_minutes),)) # 기존 lunch_duration은 삭제하지 않음 (호환성 유지) # lunch_duration_minutes가 없으면 기본값 설정 cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration_minutes'") if not cursor.fetchone(): cursor.execute(''' INSERT OR IGNORE INTO settings (key, value) VALUES ('lunch_duration_minutes', '60') ''') conn.commit() except Exception as e: # 마이그레이션 실패 시 무시 (이미 마이그레이션됨) # 단, 예상치 못한 오류는 로그에 기록 import sys if "no such column" not in str(e).lower() and "already exists" not in str(e).lower(): print(f"lunch_duration 마이그레이션 경고: {e}", file=sys.stderr) finally: conn.close() def migrate_leave_records_hours_to_days(self): """leave_records.hours 컬럼을 days로 변경 (마이그레이션)""" conn = self.get_connection() cursor = conn.cursor() try: # 현재 테이블 스키마 확인 cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='leave_records'") result = cursor.fetchone() if result and 'hours REAL' in result[0]: # hours 컬럼이 있으면 days로 변경 # 1. 기존 데이터 백업 cursor.execute('SELECT * FROM leave_records') backup_data = cursor.fetchall() # 2. 기존 테이블 삭제 cursor.execute('DROP TABLE IF EXISTS leave_records') # 3. days 컬럼으로 새 테이블 생성 cursor.execute(''' CREATE TABLE leave_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, date DATE NOT NULL, leave_type TEXT NOT NULL, days REAL, memo TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 4. 데이터 복원 (hours -> days, 같은 값) for row in backup_data: cursor.execute(''' INSERT INTO leave_records (id, date, leave_type, days, memo, created_at) VALUES (?, ?, ?, ?, ?, ?) ''', tuple(row)) conn.commit() print("leave_records 테이블 hours->days 마이그레이션 완료") except Exception as e: conn.rollback() print(f"leave_records 마이그레이션 오류: {e}") finally: conn.close() def migrate_add_dinner_break(self): """work_records 테이블에 dinner_break 컬럼 추가 (마이그레이션)""" conn = self.get_connection() cursor = conn.cursor() try: # 현재 테이블 스키마 확인 cursor.execute("PRAGMA table_info(work_records)") columns = [row[1] for row in cursor.fetchall()] if 'dinner_break' not in columns: # dinner_break 컬럼 추가 cursor.execute(''' ALTER TABLE work_records ADD COLUMN dinner_break BOOLEAN DEFAULT 0 ''') conn.commit() print("work_records 테이블에 dinner_break 컬럼 추가 완료") except Exception as e: import sys if "duplicate column name" not in str(e).lower(): print(f"dinner_break 컬럼 추가 경고: {e}", file=sys.stderr) finally: conn.close() def migrate_cleanup_balance_adjustments(self): """기존 잘못된 조정 데이터 정리 마이그레이션 이전 버전에서 '덮어쓰기' 방식으로 생성된 조정 레코드들을 정리: - overtime_bank: work_record_id가 NULL인 레코드는 삭제 (초기값은 settings로 이동) - leave_records: 'manual' 타입이고 '이전 사용분 일괄 추가' 메모가 있는 레코드 삭제 (초기값은 settings로 이동) """ conn = self.get_connection() cursor = conn.cursor() try: # 마이그레이션 완료 여부 확인 (v2로 버전 업) cursor.execute("SELECT value FROM settings WHERE key = 'balance_adjustment_migrated_v2'") result = cursor.fetchone() if result: # 이미 마이그레이션 완료 conn.close() return # 1. overtime_bank에서 수동 추가 레코드 삭제 # (work_record_id가 NULL인 것 - 이전 방식의 수동 조정) cursor.execute(''' DELETE FROM overtime_bank WHERE work_record_id IS NULL ''') deleted_overtime = cursor.rowcount # 2. leave_records에서 '이전 사용분 일괄 추가' 레코드 삭제 cursor.execute(''' DELETE FROM leave_records WHERE leave_type = 'manual' AND memo LIKE '%이전 사용분 일괄 추가%' ''') deleted_leave = cursor.rowcount # 마이그레이션 완료 표시 cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('balance_adjustment_migrated_v2', 'true', CURRENT_TIMESTAMP) ''') conn.commit() if deleted_overtime > 0 or deleted_leave > 0: print(f"잔액 조정 마이그레이션 v2 완료: 연장근무 {deleted_overtime}건, 연차 {deleted_leave}건 삭제") except Exception as e: conn.rollback() import sys print(f"잔액 조정 마이그레이션 오류: {e}", file=sys.stderr) finally: conn.close() def migrate_work_hours_to_minutes(self): """work_hours(시간 단위, 정수)를 work_minutes(분 단위)로 마이그레이션. 단축근무자(예: 7시간 30분)를 위해 분 단위 저장이 필요. 기존 work_hours는 호환성 유지를 위해 보존. """ conn = self.get_connection() cursor = conn.cursor() try: # work_minutes가 이미 있으면 스킵 cursor.execute("SELECT value FROM settings WHERE key = 'work_minutes'") if cursor.fetchone(): conn.close() return # work_hours에서 분으로 변환 cursor.execute("SELECT value FROM settings WHERE key = 'work_hours'") row = cursor.fetchone() if row: try: # float 허용 (혹시 외부에서 7.5 등 저장된 경우) work_hours_val = float(row[0]) work_minutes_val = int(round(work_hours_val * 60)) except (ValueError, TypeError): work_minutes_val = 480 else: work_minutes_val = 480 cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('work_minutes', ?, CURRENT_TIMESTAMP) ''', (str(work_minutes_val),)) conn.commit() except Exception as e: conn.rollback() import sys print(f"work_minutes 마이그레이션 경고: {e}", file=sys.stderr) finally: conn.close() def migrate_annual_leave_keys(self): """annual_leave_total(레거시) ↔ annual_leave_days(UI) 동기화. UI는 annual_leave_days를 사용하지만 일부 메서드는 annual_leave_total을 읽음. 둘 중 하나만 있으면 다른 쪽에 복사. sentinel로 1회만 실행. """ conn = self.get_connection() cursor = conn.cursor() try: # sentinel 체크: 이미 마이그레이션 완료면 스킵 cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_keys_migrated'") if cursor.fetchone(): return cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_days'") days_row = cursor.fetchone() cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_total'") total_row = cursor.fetchone() if days_row and not total_row: cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('annual_leave_total', ?, CURRENT_TIMESTAMP) ''', (days_row[0],)) elif total_row and not days_row: cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('annual_leave_days', ?, CURRENT_TIMESTAMP) ''', (total_row[0],)) cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('annual_leave_keys_migrated', 'true', CURRENT_TIMESTAMP) ''') conn.commit() except Exception as e: conn.rollback() import sys print(f"annual_leave 키 동기화 경고: {e}", file=sys.stderr) finally: conn.close() def migrate_v23_break_type(self): """break_records에 break_type 컬럼 추가 (v2.3.0). 값: 'break'(기본 외출) / 'lunch' / 'dinner'. 기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용. """ conn = self.get_connection() cursor = conn.cursor() try: cursor.execute("PRAGMA table_info(break_records)") cols = [row[1] for row in cursor.fetchall()] if 'break_type' not in cols: cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'") conn.commit() except Exception as e: conn.rollback() import sys print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr) finally: conn.close() def migrate_v23_notification_log(self): """알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계.""" conn = self.get_connection() cursor = conn.cursor() try: cursor.execute(''' CREATE TABLE IF NOT EXISTS notification_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, event_type TEXT NOT NULL, payload TEXT, sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, success BOOLEAN DEFAULT 1 ) ''') cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_notif_log_event ON notification_log(event_type, sent_at) ''') conn.commit() except Exception as e: conn.rollback() import sys print(f"notification_log 생성 경고: {e}", file=sys.stderr) finally: conn.close() def log_notification(self, channel: str, event_type: str, payload: str = None, success: bool = True) -> None: """알림 발송 이력 기록 (중복 방지 가드용).""" conn = self.get_connection() cursor = conn.cursor() try: cursor.execute( "INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)", (channel, event_type, payload, success) ) conn.commit() finally: conn.close() def has_notification_today(self, channel: str, event_type: str) -> bool: """오늘 같은 (channel, event_type) 발송 이력 존재 여부.""" conn = self.get_connection() cursor = conn.cursor() try: cursor.execute( "SELECT COUNT(*) FROM notification_log " "WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')", (channel, event_type) ) return cursor.fetchone()[0] > 0 finally: conn.close() def migrate_v23_onboarding_for_existing(self): """기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리. v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드. """ conn = self.get_connection() cursor = conn.cursor() try: # 이미 완료/스킵 마크 있으면 패스 cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'") row = cursor.fetchone() if row and row[0] == 'true': return # 기존 work_records 데이터가 1건 이상 있으면 자동 완료 cursor.execute("SELECT COUNT(*) FROM work_records") count = cursor.fetchone()[0] if count > 0: cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP) ''') conn.commit() except Exception as e: conn.rollback() import sys print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr) finally: conn.close() def get_setting_int(self, key: str, default: int = 0) -> int: """설정을 int로 안전하게 조회 (변환 실패 시 default).""" raw = self.get_setting(key, None) if raw is None: return default try: return int(raw) except (ValueError, TypeError): return default def get_setting_float(self, key: str, default: float = 0.0) -> float: """설정을 float로 안전하게 조회.""" raw = self.get_setting(key, None) if raw is None: return default try: return float(raw) except (ValueError, TypeError): return default def get_setting_bool(self, key: str, default: bool = False) -> bool: """설정을 bool로 안전하게 조회 ('true'/'1'/'yes' = True).""" raw = self.get_setting(key, None) if raw is None: return default return str(raw).lower() in ('1', 'true', 'yes') def get_work_minutes(self) -> int: """기본 근무시간 (분 단위) 조회. work_minutes 우선, 없으면 work_hours * 60으로 폴백. 7시간 30분 같은 단축근무 케이스에서 분 단위 정확도 보장. """ wm = self.get_setting(WORK_MINUTES, None) if wm is not None: try: return int(wm) except (ValueError, TypeError): pass wh = self.get_setting(WORK_HOURS, '8') try: return int(round(float(wh) * 60)) except (ValueError, TypeError): return 480 def init_default_settings(self): """기본 설정 초기화""" default_settings = { 'work_hours': '8', 'work_minutes': '480', 'lunch_duration_minutes': '60', 'dinner_duration_minutes': '60', 'auto_lunch': 'false', 'auto_overtime': 'true', 'theme': 'light', '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', # annual_leave_total과 자동 동기화 'workday_boundary_hour': '6', 'overtime_unit': '30', 'time_format': '24', # v2.3.0 'onboarding_completed': 'false', 'salary_enabled': 'false', 'hourly_wage': '0', 'overtime_rate': '1.5', 'health_break_enabled': 'true', 'health_break_hours': '4', 'discord_webhook_url': '', 'discord_notif_clock_in': 'true', 'discord_notif_clock_out': 'true', 'discord_notif_health': 'true', # v2.4.0 'goal_overtime_max_monthly': '0', # 0=비활성, >0=분 단위 상한 'goal_avg_hours_daily': '0', # 0=비활성 } conn = self.get_connection() cursor = conn.cursor() for key, value in default_settings.items(): cursor.execute(''' INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) ''', (key, value)) conn.commit() conn.close() # ===== 근무 기록 관련 메서드 ===== def add_work_record(self, date: str, clock_in: str, lunch_break: bool = False, is_manual: bool = False) -> int: """근무 기록 추가""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO work_records (date, clock_in, lunch_break, is_manual) VALUES (?, ?, ?, ?) ''', (date, clock_in, lunch_break, is_manual)) record_id = cursor.lastrowid conn.commit() conn.close() return record_id def update_clock_out(self, date: str, clock_out: str, total_hours: float, overtime_minutes: int, overtime_earned: int): """퇴근 시간 및 연장근무 업데이트""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET clock_out = ?, total_hours = ?, overtime_minutes = ?, overtime_earned = ?, updated_at = CURRENT_TIMESTAMP WHERE date = ? ''', (clock_out, total_hours, overtime_minutes, overtime_earned, date)) conn.commit() conn.close() def cancel_clock_out(self, date: str) -> bool: """퇴근 취소 (퇴근 시간 및 연장근무 기록 삭제) Returns: bool: 성공 여부 """ conn = self.get_connection() cursor = conn.cursor() try: # 1. 해당 날짜의 work_record 조회 cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) record = cursor.fetchone() if not record: conn.close() return False work_record_id = record[0] # 2. 해당 날짜의 연장근무 적립 내역 삭제 cursor.execute(''' DELETE FROM overtime_bank WHERE work_record_id = ? AND date = ? ''', (work_record_id, date)) # 3. work_records의 퇴근 관련 필드 초기화 cursor.execute(''' UPDATE work_records SET clock_out = NULL, total_hours = NULL, overtime_minutes = 0, overtime_earned = 0, updated_at = CURRENT_TIMESTAMP WHERE date = ? ''', (date,)) conn.commit() conn.close() return True except Exception as e: conn.rollback() conn.close() raise e def get_today_record(self) -> Optional[Dict]: """오늘 근무 기록 조회""" today = date.today().isoformat() return self.get_work_record(today) def get_work_record(self, date: str) -> Optional[Dict]: """특정 날짜 근무 기록 조회""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM work_records WHERE date = ? ''', (date,)) row = cursor.fetchone() conn.close() if row: return dict(row) return None def update_lunch_break(self, date: str, lunch_break: bool): """점심시간 사용 여부 업데이트""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET lunch_break = ?, updated_at = CURRENT_TIMESTAMP WHERE date = ? ''', (lunch_break, date)) conn.commit() conn.close() def update_dinner_break(self, date: str, dinner_break: bool): """저녁시간 사용 여부 업데이트""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET dinner_break = ?, updated_at = CURRENT_TIMESTAMP WHERE date = ? ''', (dinner_break, date)) conn.commit() conn.close() def delete_work_record(self, date: str): """특정 날짜의 근무 기록 삭제""" conn = self.get_connection() cursor = conn.cursor() # 먼저 해당 기록의 ID 조회 cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) record = cursor.fetchone() if record: record_id = record[0] # 연관된 연장근무 적립 기록 삭제 cursor.execute('DELETE FROM overtime_bank WHERE work_record_id = ?', (record_id,)) # 연관된 연장근무 사용 기록 삭제 cursor.execute('DELETE FROM overtime_usage WHERE work_record_id = ?', (record_id,)) # 근무 기록 삭제 cursor.execute('DELETE FROM work_records WHERE id = ?', (record_id,)) conn.commit() conn.close() def get_work_records_by_range(self, start_date: str, end_date: str) -> List[Dict]: """기간별 근무 기록 조회""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM work_records WHERE date BETWEEN ? AND ? ORDER BY date DESC ''', (start_date, end_date)) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] # ===== 연장근무 관련 메서드 ===== def add_overtime_earned(self, work_record_id: int, earned_minutes: int, date: str): """연장근무 적립""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO overtime_bank (work_record_id, earned_minutes, date) VALUES (?, ?, ?) ''', (work_record_id, earned_minutes, date)) conn.commit() conn.close() def add_overtime_usage(self, work_record_id: int, used_minutes: int, date: str, reason: str = None): """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" conn = self.get_connection() cursor = conn.cursor() try: cursor.execute(''' INSERT INTO overtime_usage (work_record_id, used_minutes, date, reason) VALUES (?, ?, ?, ?) ''', (work_record_id, used_minutes, date, reason)) # work_records 테이블도 업데이트 (work_record_id가 있을 때만) if work_record_id is not None: cursor.execute(''' UPDATE work_records SET overtime_used = overtime_used + ? WHERE id = ? ''', (used_minutes, work_record_id)) conn.commit() except Exception as e: conn.rollback() raise e finally: conn.close() def get_total_overtime_balance(self) -> int: """총 연장근무 잔액 조회 (초기값 + 적립 - 사용)""" conn = self.get_connection() cursor = conn.cursor() # 초기값 (프로그램 사용 전 쌓인 연장근무) initial_overtime = int(self.get_setting(INITIAL_OVERTIME_MINUTES, '0')) # 단일 쿼리로 적립과 사용을 동시에 조회 (원자성 보장) cursor.execute(''' SELECT COALESCE((SELECT SUM(earned_minutes) FROM overtime_bank), 0) - COALESCE((SELECT SUM(used_minutes) FROM overtime_usage), 0) AS balance ''') balance = cursor.fetchone()[0] conn.close() return initial_overtime + balance def get_today_overtime_usage(self) -> int: """오늘 사용한 추가근무 시간 조회 (분)""" from datetime import date today = date.today().isoformat() conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT SUM(used_minutes) FROM overtime_usage WHERE date = ? ''', (today,)) used = cursor.fetchone()[0] or 0 conn.close() return used def get_today_leave_minutes(self) -> int: """오늘 사용한 연차/반차 시간 조회 (분)""" from datetime import date today = date.today().isoformat() conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT SUM(days) FROM leave_records WHERE date = ? ''', (today,)) days = cursor.fetchone()[0] or 0.0 conn.close() return int(days * self.get_work_minutes()) def add_initial_overtime_balance(self, minutes: int): """초기 연장근무 잔액 추가""" from datetime import datetime conn = self.get_connection() cursor = conn.cursor() today = datetime.now().date().isoformat() # work_record_id 없이 직접 추가 cursor.execute(''' INSERT INTO overtime_bank (work_record_id, earned_minutes, date) VALUES (NULL, ?, ?) ''', (minutes, today)) conn.commit() conn.close() def get_overtime_history(self, limit: int = 30) -> List[Dict]: """연장근무 내역 조회 (적립 + 사용)""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT 'earned' as type, earned_minutes as minutes, date, wr.clock_in, wr.clock_out FROM overtime_bank ob LEFT JOIN work_records wr ON ob.work_record_id = wr.id UNION ALL SELECT 'used' as type, used_minutes as minutes, date, wr.clock_in, wr.clock_out FROM overtime_usage ou LEFT JOIN work_records wr ON ou.work_record_id = wr.id ORDER BY date DESC LIMIT ? ''', (limit,)) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] # ===== 휴가 관련 메서드 ===== def add_leave_record(self, date: str, leave_type: str, days: float, memo: str = None): """휴가 기록 추가""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO leave_records (date, leave_type, days, memo) VALUES (?, ?, ?, ?) ''', (date, leave_type, days, memo)) conn.commit() conn.close() def get_leave_records(self, start_date: str = None, end_date: str = None, exclude_bulk: bool = False) -> List[Dict]: """휴가 기록 조회""" conn = self.get_connection() cursor = conn.cursor() if start_date and end_date: if exclude_bulk: cursor.execute(''' SELECT * FROM leave_records WHERE date BETWEEN ? AND ? AND COALESCE(memo, '') != '이전 사용분 일괄 추가' ORDER BY date DESC ''', (start_date, end_date)) else: cursor.execute(''' SELECT * FROM leave_records WHERE date BETWEEN ? AND ? ORDER BY date DESC ''', (start_date, end_date)) else: if exclude_bulk: cursor.execute(''' SELECT * FROM leave_records WHERE COALESCE(memo, '') != '이전 사용분 일괄 추가' ORDER BY date DESC ''') else: cursor.execute('SELECT * FROM leave_records ORDER BY date DESC') rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def get_annual_leave_balance(self) -> Tuple[float, float]: """연차 잔여 조회 (총 연차, 사용한 연차) Note: 현재 UI에서는 get_leave_balance()만 사용됨. 이 메서드는 leave_records 테이블에서 직접 계산하므로 settings.leave_balance와 불일치할 수 있음. 향후 연차 관리 기능 개선 시 활용 가능. """ total = float(self.get_setting(ANNUAL_LEAVE_TOTAL, '15')) conn = self.get_connection() cursor = conn.cursor() # manual 타입이 아닌 모든 연차 사용 기록 합산 cursor.execute(''' SELECT SUM(days) FROM leave_records WHERE leave_type IS NULL OR leave_type NOT IN ('manual', 'bulk') ''') used = cursor.fetchone()[0] or 0 conn.close() return total, used # ===== 설정 관련 메서드 ===== def get_setting(self, key: str, default: str = None) -> str: """설정 값 조회""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) row = cursor.fetchone() conn.close() if row: return row[0] return default def set_setting(self, key: str, value: str): """설정 값 저장""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ''', (key, value)) conn.commit() conn.close() # ===== 통계 관련 메서드 ===== def get_weekly_stats(self) -> Dict: """주간 통계""" from datetime import datetime, timedelta today = datetime.now().date() week_ago = today - timedelta(days=7) records = self.get_work_records_by_range(week_ago.isoformat(), today.isoformat()) total_hours = sum(r.get('total_hours', 0) or 0 for r in records) total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) work_days = len([r for r in records if r.get('clock_out')]) return { 'total_hours': total_hours, 'total_overtime_minutes': total_overtime, 'work_days': work_days, 'avg_hours_per_day': total_hours / work_days if work_days > 0 else 0 } def get_consecutive_overtime_days(self, threshold_minutes: int = 30) -> int: """오늘부터 거꾸로 연속 연장근무한 일수. Args: threshold_minutes: 연장근무로 카운트할 최소 분 (기본 30분) Returns: 연속 일수 (오늘 미적립이거나 휴무일이면 0) """ from datetime import date, timedelta count = 0 d = date.today() for _ in range(60): # 최대 60일까지만 거슬러 검사 rec = self.get_work_record(d.isoformat()) if not rec or not rec.get('clock_out'): break if (rec.get('overtime_minutes') or 0) < threshold_minutes: break count += 1 d -= timedelta(days=1) return count def get_monthly_stats(self, year: int, month: int) -> Dict: """월간 통계""" from calendar import monthrange start_date = f"{year}-{month:02d}-01" last_day = monthrange(year, month)[1] end_date = f"{year}-{month:02d}-{last_day}" records = self.get_work_records_by_range(start_date, end_date) total_hours = sum(r.get('total_hours', 0) or 0 for r in records) total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) work_days = len([r for r in records if r.get('clock_out')]) return { 'year': year, 'month': month, 'total_hours': total_hours, 'total_overtime_minutes': total_overtime, 'work_days': work_days, 'records': records } # ===== 휴가 관련 메서드 (중복 제거됨 - 356줄의 함수 사용) ===== def get_leave_record(self, date: str) -> Optional[Dict]: """특정 날짜의 휴가 기록 조회""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(''' SELECT * FROM leave_records WHERE date = ? ''', (date,)) row = cursor.fetchone() conn.close() return dict(row) if row else None def get_all_leave_records(self, limit: int = 100) -> List[Dict]: """모든 휴가 기록 조회 (최신순)""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(''' SELECT * FROM leave_records ORDER BY date DESC LIMIT ? ''', (limit,)) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def delete_leave_record(self, leave_id: int): """휴가 기록 삭제""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute('DELETE FROM leave_records WHERE id = ?', (leave_id,)) conn.commit() conn.close() # ===== 설정 관련 메서드 ===== def get_settings(self) -> Dict: """설정 가져오기""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute('SELECT * FROM settings') rows = cursor.fetchall() conn.close() # 딕셔너리로 변환 settings = {} for row in rows: key = row['key'] value = row['value'] # 타입 변환 if value.lower() in ['true', 'false']: settings[key] = value.lower() == 'true' else: # 정수 변환 시도 (음수 포함) try: settings[key] = int(value) except ValueError: # float 변환 시도 try: settings[key] = float(value) except ValueError: settings[key] = value return settings def save_settings(self, settings: Dict): """설정 저장. 키 동기화 처리: - work_minutes 저장 시 work_hours도 갱신 (호환성) - work_hours 저장 시 work_minutes도 갱신 - annual_leave_days ↔ annual_leave_total 양방향 동기화 """ # 동기화 키 미리 보강 (호출자가 일부만 줘도 양쪽 다 저장) synced = dict(settings) if 'work_minutes' in synced and 'work_hours' not in synced: try: # floor로 통일 (settings_view와 일관성: 450분 → 7시간) synced['work_hours'] = int(float(synced['work_minutes'])) // 60 except (ValueError, TypeError): pass elif 'work_hours' in synced and 'work_minutes' not in synced: try: synced['work_minutes'] = int(round(float(synced['work_hours']) * 60)) except (ValueError, TypeError): pass if 'annual_leave_days' in synced and 'annual_leave_total' not in synced: synced['annual_leave_total'] = synced['annual_leave_days'] elif 'annual_leave_total' in synced and 'annual_leave_days' not in synced: synced['annual_leave_days'] = synced['annual_leave_total'] conn = sqlite3.connect(self.db_path) cursor = conn.cursor() for key, value in synced.items(): value_str = str(value) cursor.execute(''' INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?) ''', (key, value_str)) conn.commit() conn.close() # ===== 외출 관련 메서드 ===== def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None, break_type: str = 'break') -> int: """외출 기록 추가. break_type: 'break'(외출) / 'lunch' / 'dinner'.""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO break_records (work_record_id, date, break_out, reason, break_type) VALUES (?, ?, ?, ?, ?) ''', (work_record_id, date, break_out, reason, break_type)) record_id = cursor.lastrowid conn.commit() conn.close() return record_id def add_meal_record(self, date: str, start_time: str, end_time: str, meal_type: str = 'lunch') -> int: """식사 시간 기록 (시작·종료 둘 다 알 때). Args: date: 'YYYY-MM-DD' start_time, end_time: 'HH:MM:SS' meal_type: 'lunch' or 'dinner' Returns: 새 break_record id """ from datetime import datetime as _dt # 분 계산 start_dt = _dt.strptime(start_time, '%H:%M:%S') end_dt = _dt.strptime(end_time, '%H:%M:%S') if end_dt < start_dt: from datetime import timedelta as _td end_dt += _td(days=1) total_min = int((end_dt - start_dt).total_seconds() / 60) rec = self.get_today_record() if date == _dt.now().date().isoformat() else None wid = rec['id'] if rec else None conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO break_records (work_record_id, date, break_out, break_in, total_minutes, break_type) VALUES (?, ?, ?, ?, ?, ?) ''', (wid, date, start_time, end_time, total_min, meal_type)) rid = cursor.lastrowid conn.commit() conn.close() return rid def get_meal_minutes_today(self, meal_type: str = 'lunch') -> int: """오늘의 식사 시간 합계 (분). 수동 입력된 경우만.""" from datetime import date as _date today = _date.today().isoformat() conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT COALESCE(SUM(total_minutes), 0) FROM break_records WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL ''', (today, meal_type)) result = cursor.fetchone()[0] or 0 conn.close() return int(result) def update_break_return(self, break_id: int, break_in: str): """외출 복귀 시간 업데이트""" conn = self.get_connection() cursor = conn.cursor() # 복귀 시간 업데이트 cursor.execute(''' UPDATE break_records SET break_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (break_in, break_id)) # 총 외출 시간 계산 cursor.execute(''' SELECT break_out, break_in FROM break_records WHERE id = ? ''', (break_id,)) row = cursor.fetchone() if row and row['break_out'] and row['break_in']: from datetime import datetime, timedelta break_out_time = datetime.strptime(row['break_out'], "%H:%M:%S") break_in_time = datetime.strptime(row['break_in'], "%H:%M:%S") # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 if break_in_time < break_out_time: break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) # 음수 방지 (혹시 모를 케이스) if total_minutes < 0: total_minutes = 0 cursor.execute(''' UPDATE break_records SET total_minutes = ? WHERE id = ? ''', (total_minutes, break_id)) conn.commit() conn.close() def get_today_break_records(self) -> List[Dict]: """오늘의 외출 기록 조회""" from datetime import date today = date.today().isoformat() return self.get_break_records_by_date(today) def get_break_records_by_date(self, date: str) -> List[Dict]: """특정 날짜의 외출 기록 조회""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM break_records WHERE date = ? ORDER BY break_out ASC ''', (date,)) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def get_active_break_record(self, target_date: str = None) -> Optional[Dict]: """현재 진행 중인 외출 기록 조회 (복귀하지 않은 외출) Args: target_date: 조회할 날짜 (YYYY-MM-DD), None이면 오늘 """ from datetime import date if target_date is None: target_date = date.today().isoformat() conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM break_records WHERE date = ? AND break_in IS NULL ORDER BY break_out DESC LIMIT 1 ''', (target_date,)) row = cursor.fetchone() conn.close() if row: return dict(row) return None def update_break_record(self, break_id: int, break_out: str, break_in: str = None, reason: str = None): """외출 기록 수정""" conn = self.get_connection() cursor = conn.cursor() if break_in: # 총 외출 시간 계산 from datetime import datetime, timedelta break_out_time = datetime.strptime(break_out, "%H:%M:%S") break_in_time = datetime.strptime(break_in, "%H:%M:%S") # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 if break_in_time < break_out_time: break_in_time += timedelta(days=1) total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) # 음수 방지 (혹시 모를 케이스) if total_minutes < 0: total_minutes = 0 cursor.execute(''' UPDATE break_records SET break_out = ?, break_in = ?, total_minutes = ?, reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (break_out, break_in, total_minutes, reason, break_id)) else: cursor.execute(''' UPDATE break_records SET break_out = ?, reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (break_out, reason, break_id)) conn.commit() conn.close() def delete_break_record(self, break_id: int): """외출 기록 삭제""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) conn.commit() conn.close() def get_total_break_minutes_today(self) -> int: """오늘의 총 외출 시간 (분), 진행 중인 외출 포함""" from datetime import date, datetime today = date.today().isoformat() conn = self.get_connection() cursor = conn.cursor() # 완료된 외출 시간 합계 cursor.execute(''' SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND total_minutes IS NOT NULL ''', (today,)) total = cursor.fetchone()[0] or 0 # 진행 중인 외출 시간 계산 cursor.execute(''' SELECT break_out FROM break_records WHERE date = ? AND break_in IS NULL ORDER BY break_out DESC LIMIT 1 ''', (today,)) active_break = cursor.fetchone() if active_break: break_out_str = active_break[0] now = datetime.now() break_out_time = datetime.strptime(f"{today} {break_out_str}", "%Y-%m-%d %H:%M:%S") active_minutes = int((now - break_out_time).total_seconds() / 60) total += active_minutes conn.close() return total def update_work_memo(self, date: str, memo: str): """근무 기록 메모 업데이트""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET memo = ?, updated_at = CURRENT_TIMESTAMP WHERE date = ? ''', (memo, date)) conn.commit() conn.close() def get_leave_balance(self) -> float: """연차 잔여 개수 조회 (총 연차 - 초기 사용량 - 프로그램 기록 사용량)""" from datetime import datetime # 총 연차 일수 total_annual = int(self.get_setting(ANNUAL_LEAVE_DAYS, '15')) # 초기 사용 연차 (프로그램 사용 전) initial_leave_hours = float(self.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) initial_leave_days = initial_leave_hours / 8.0 # 올해 프로그램에서 기록된 사용량 current_year = datetime.now().year all_leaves = self.get_all_leave_records(limit=365) year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] used_days = sum(r['days'] for r in year_leaves) # 잔여 = 총 - 초기사용 - 프로그램기록 return total_annual - initial_leave_days - used_days def set_leave_balance(self, balance: float): """연차 잔여 개수 설정""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('leave_balance', ?, CURRENT_TIMESTAMP) ''', (str(balance),)) conn.commit() conn.close() def use_leave(self, days: float, date: str, leave_type: str = "연차", memo: str = None): """연차 사용 Args: days: 사용할 연차 일수 (예: 1.0=하루, 0.5=반차, 0.25=반반차) Note: leave_records 테이블의 'days' 컬럼에 일수를 저장함 예: 1.0 = 1일, 0.5 = 반차, 0.125 = 1시간(8분의 1일) """ current_balance = self.get_leave_balance() if current_balance < days: raise ValueError(f"연차 잔여 개수가 부족합니다. (잔여: {current_balance}일)") conn = self.get_connection() cursor = conn.cursor() # 연차 기록 추가 cursor.execute(''' INSERT INTO leave_records (date, leave_type, days, memo) VALUES (?, ?, ?, ?) ''', (date, leave_type, days, memo)) conn.commit() conn.close() # 잔여 개수 차감 self.set_leave_balance(current_balance - days) # ===== 공휴일 관련 메서드 ===== def add_holiday(self, date: str, name: str, is_recurring: bool = False) -> int: """공휴일 추가 Args: date: 공휴일 날짜 (YYYY-MM-DD) name: 공휴일 이름 is_recurring: 매년 반복 여부 (음력 명절 등은 False) Returns: int: 추가된 공휴일 ID """ conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT OR REPLACE INTO holidays (date, name, is_recurring, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ''', (date, name, is_recurring)) holiday_id = cursor.lastrowid conn.commit() conn.close() return holiday_id def delete_holiday(self, holiday_id: int): """공휴일 삭제""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('DELETE FROM holidays WHERE id = ?', (holiday_id,)) conn.commit() conn.close() def delete_holiday_by_date(self, date: str): """날짜로 공휴일 삭제""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('DELETE FROM holidays WHERE date = ?', (date,)) conn.commit() conn.close() def is_holiday(self, date: str) -> bool: """해당 날짜가 공휴일인지 확인 Args: date: 확인할 날짜 (YYYY-MM-DD) Returns: bool: 공휴일이면 True """ conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT id FROM holidays WHERE date = ? ''', (date,)) result = cursor.fetchone() conn.close() return result is not None def get_holiday(self, date: str) -> Optional[Dict]: """해당 날짜의 공휴일 정보 조회 Args: date: 조회할 날짜 (YYYY-MM-DD) Returns: Dict: 공휴일 정보 또는 None """ conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM holidays WHERE date = ? ''', (date,)) row = cursor.fetchone() conn.close() if row: return dict(row) return None def get_holidays_by_year(self, year: int) -> List[Dict]: """해당 연도의 공휴일 목록 조회 Args: year: 조회할 연도 Returns: List[Dict]: 공휴일 목록 """ conn = self.get_connection() cursor = conn.cursor() # LIKE 대신 정확한 날짜 범위 비교 사용 (더 효율적) cursor.execute(''' SELECT * FROM holidays WHERE date >= ? AND date < ? ORDER BY date ASC ''', (f"{year}-01-01", f"{year + 1}-01-01")) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def get_all_holidays(self) -> List[Dict]: """모든 공휴일 목록 조회""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM holidays ORDER BY date ASC ''') rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def add_korean_holidays(self, year: int): """한국 공휴일 일괄 추가 (고정 공휴일만) Args: year: 추가할 연도 Note: 음력 기반 명절(설날, 추석)은 매년 날짜가 변경되므로 수동으로 추가해야 합니다. """ fixed_holidays = [ (f"{year}-01-01", "신정"), (f"{year}-03-01", "삼일절"), (f"{year}-05-05", "어린이날"), (f"{year}-06-06", "현충일"), (f"{year}-08-15", "광복절"), (f"{year}-10-03", "개천절"), (f"{year}-10-09", "한글날"), (f"{year}-12-25", "크리스마스"), ] for date, name in fixed_holidays: self.add_holiday(date, name, is_recurring=True) def add_korean_holidays_auto(self, year: int) -> int: """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. Returns: 추가된 공휴일 개수. 패키지 미설치 시 -1. """ try: import holidays as _holidays except ImportError: return -1 kr = _holidays.country_holidays('KR', years=year) added = 0 for d, name in kr.items(): date_str = d.isoformat() # 이미 등록된 동일 날짜는 스킵 (중복 방지) if not self.is_holiday(date_str): self.add_holiday(date_str, name, is_recurring=False) added += 1 return added def copy_recurring_holidays(self, from_year: int, to_year: int): """반복 공휴일을 다음 연도로 복사 Args: from_year: 복사할 원본 연도 to_year: 복사 대상 연도 """ holidays = self.get_holidays_by_year(from_year) for holiday in holidays: if holiday['is_recurring']: new_date = holiday['date'].replace(str(from_year), str(to_year)) self.add_holiday(new_date, holiday['name'], is_recurring=True)