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>
1602 lines
54 KiB
Python
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)
|