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>
200 lines
7.8 KiB
Python
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}")
|