68893236+KINDNICK@users.noreply.github.com c5df37ca57 v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화
Added — 도전과제 시스템 (153개 자동 평가)
- core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제
- ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿)
- 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push
- achievements 테이블 확장 (code/category/tier/is_secret/progress/target)
- hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키

Changed — 다크 테마 디자인 리뉴얼
- ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress)
- 통계/도움말/도전과제 다이얼로그 일관 다크 톤
- matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend)
- 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드)

Fixed — 안정성·일관성
- 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch)
- DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환
- DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자)
- crash_handler 다단계 폴백 (DB → 파일 → stderr)
- updater PID race: 지수 backoff 재시도 (총 ~9초)
- Discord URL 형식 검증 (snowflake regex)
- 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증
- check_dinner_reminder 신규, 알림 임계값 5개 설정화
- closeEvent timer/notifier 정리 (aboutToQuit hook)
- 마이그레이션 12개 모두 _conn() + try/finally
- DB 인덱스 5개 추가 (break/overtime/leave date)

Tests
- pytest 116/116 PASS, 통합 시나리오 48/48 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:11:13 +09:00

200 lines
7.8 KiB
Python

"""
CSV 내보내기 유틸리티
"""
import csv
from datetime import datetime
from typing import List, Dict
import os
class CSVExporter:
"""CSV 내보내기 클래스"""
@staticmethod
def export_work_records(records: List[Dict], filename: str = None,
db=None) -> str:
"""
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
Args:
records: 근무 기록 리스트
filename: 저장할 파일명 (None이면 자동 생성)
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
Returns:
str: 저장된 파일 경로
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_{timestamp}.csv"
# 표시용 기본 분 (옵션)
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = [
'날짜', '출근시간', '퇴근시간',
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
'총근무시간', '연장근무(분)', '적립(분)', '메모',
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
lunch_on = bool(record.get('lunch_break'))
dinner_on = bool(record.get('dinner_break'))
row = {
'날짜': record.get('date', ''),
'출근시간': record.get('clock_in', ''),
'퇴근시간': record.get('clock_out', '미기록'),
'점심시간': '사용' if lunch_on else '미사용',
'점심(분)': lunch_default if lunch_on else 0,
'저녁시간': '사용' if dinner_on else '미사용',
'저녁(분)': dinner_default if dinner_on else 0,
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
'연장근무(분)': record.get('overtime_minutes', 0),
'적립(분)': record.get('overtime_earned', 0),
'메모': record.get('memo', ''),
}
writer.writerow(row)
return filename
@staticmethod
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
db=None) -> str:
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_reimport_{timestamp}.csv"
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = ['date', 'clock_in', 'clock_out',
'lunch_minutes', 'dinner_minutes', 'memo']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
writer.writerow({
'date': record.get('date', ''),
'clock_in': record.get('clock_in', ''),
'clock_out': record.get('clock_out', '') or '',
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
'memo': record.get('memo', '') or '',
})
return filename
@staticmethod
def export_overtime_summary(db, filename: str = None) -> str:
"""
연장근무 요약 내보내기
Args:
db: Database 인스턴스
filename: 저장할 파일명
Returns:
str: 저장된 파일 경로
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"overtime_summary_{timestamp}.csv"
# 연장근무 내역 가져오기
overtime_history = db.get_overtime_history(limit=100)
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = ['유형', '날짜', '시간(분)', '출근', '퇴근']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for item in overtime_history:
row = {
'유형': '적립' if item['type'] == 'earned' else '사용',
'날짜': item.get('date', ''),
'시간(분)': item.get('minutes', 0),
'출근': item.get('clock_in', ''),
'퇴근': item.get('clock_out', '')
}
writer.writerow(row)
return filename
@staticmethod
def export_monthly_summary(db, year: int, month: int, filename: str = None) -> str:
"""
월간 요약 내보내기
Args:
db: Database 인스턴스
year: 년도
month: 월
filename: 저장할 파일명
Returns:
str: 저장된 파일 경로
"""
if filename is None:
filename = f"monthly_summary_{year}{month:02d}.csv"
stats = db.get_monthly_stats(year, month)
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.writer(csvfile)
# 요약 정보
writer.writerow([f"{year}{month}월 근무 요약"])
writer.writerow([])
writer.writerow(['항목', ''])
writer.writerow(['총 근무시간', f"{stats['total_hours']:.1f}시간"])
writer.writerow(['근무일수', f"{stats['work_days']}"])
if stats['work_days'] > 0:
avg = stats['total_hours'] / stats['work_days']
writer.writerow(['평균 근무시간', f"{avg:.1f}시간"])
overtime_hours = stats['total_overtime_minutes'] // 60
overtime_mins = stats['total_overtime_minutes'] % 60
writer.writerow(['총 연장근무', f"{overtime_hours}시간 {overtime_mins}"])
writer.writerow([])
writer.writerow([])
# 상세 기록
writer.writerow(['날짜', '출근', '퇴근', '총근무시간', '연장근무'])
for record in stats['records']:
overtime_min = record.get('overtime_earned', 0)
overtime_h = overtime_min // 60
overtime_m = overtime_min % 60
overtime_str = f"{overtime_h}시간 {overtime_m}" if overtime_min > 0 else "-"
writer.writerow([
record.get('date', ''),
record.get('clock_in', ''),
record.get('clock_out', '미기록'),
f"{record.get('total_hours', 0):.1f}시간",
overtime_str
])
return filename
# 테스트 코드
if __name__ == "__main__":
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
db = Database()
# 테스트: 이번 달 기록 내보내기
now = datetime.now()
filename = CSVExporter.export_monthly_summary(db, now.year, now.month)
print(f"Exported to: {filename}")