KINDNICK 14d88656fe v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect):
- auto_overtime: when OFF, prompt user on clock-out before banking
- overtime_unit: 15/30/60-min truncation choice now actually applied
- notification_before_minutes: 30-min hardcode -> user-configurable 1~120

Removed dead keys (no readers in business logic):
- auto_detect_boot, notification_enabled, annual_leave_used

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:29:44 +09:00

1602 lines
54 KiB
Python

"""
데이터베이스 관리 모듈
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.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 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',
}
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) -> int:
"""외출 기록 추가"""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, reason)
VALUES (?, ?, ?, ?)
''', (work_record_id, date, break_out, reason))
record_id = cursor.lastrowid
conn.commit()
conn.close()
return record_id
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)