""" CSV 가져오기 — 우리 표준 포맷. 표준 포맷: date,clock_in,clock_out,lunch_minutes,memo 2026-04-01,09:00:00,18:30:00,60,"메모" - 헤더 첫 줄 필수 - date: YYYY-MM-DD - clock_in/out: HH:MM:SS 또는 HH:MM - lunch_minutes: 정수 (0이면 점심 미포함) - memo: 선택 (따옴표 가능) 기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정. """ from __future__ import annotations import csv from datetime import datetime from pathlib import Path from typing import List, Dict, Iterator, Tuple def parse_csv(path: str) -> List[Dict]: """CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError.""" rows = [] p = Path(path) if not p.exists(): raise FileNotFoundError(f"파일 없음: {path}") with open(p, 'r', encoding='utf-8-sig', newline='') as f: reader = csv.DictReader(f) required = {'date', 'clock_in'} if not required.issubset(reader.fieldnames or []): raise ValueError( f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n" f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo" ) for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더) try: clean = _normalize_row(row) rows.append(clean) except ValueError as e: raise ValueError(f"줄 {i}: {e}") return rows def _normalize_row(row: Dict) -> Dict: """단일 행 검증 + 정규화.""" date_str = (row.get('date') or '').strip() if not date_str: raise ValueError("date 비어있음") try: datetime.strptime(date_str, '%Y-%m-%d') except ValueError: raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)") ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in') co_raw = (row.get('clock_out') or '').strip() co = _normalize_time(co_raw, 'clock_out') if co_raw else None lunch = 0 lm = (row.get('lunch_minutes') or '').strip() if lm: try: lunch = int(lm) if lunch < 0: raise ValueError except ValueError: raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'") memo = (row.get('memo') or '').strip() return { 'date': date_str, 'clock_in': ci, 'clock_out': co, 'lunch_minutes': lunch, 'memo': memo, } def _normalize_time(s: str, field_name: str) -> str: """'HH:MM' 또는 'HH:MM:SS' → 'HH:MM:SS'.""" if not s: raise ValueError(f"{field_name} 비어있음") parts = s.split(':') if len(parts) == 2: s = f"{s}:00" elif len(parts) != 3: raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)") try: datetime.strptime(s, '%H:%M:%S') except ValueError: raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'") return s def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]: """파싱된 rows를 DB에 일괄 입력. Args: db: Database 인스턴스 rows: parse_csv 결과 on_conflict: 'skip' | 'overwrite' Returns: (added, updated, skipped) 수 """ if on_conflict not in ('skip', 'overwrite'): raise ValueError("on_conflict는 'skip' 또는 'overwrite'") added = updated = skipped = 0 from core.time_calculator import TimeCalculator work_minutes = db.get_work_minutes() lunch_default = db.get_setting_int('lunch_duration_minutes', 60) for row in rows: existing = db.get_work_record(row['date']) if existing and on_conflict == 'skip': skipped += 1 continue if existing and on_conflict == 'overwrite': # 기존 레코드 삭제 후 재추가 (단순화) conn = db.get_connection() cursor = conn.cursor() try: cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],)) conn.commit() finally: conn.close() updated += 1 else: added += 1 wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True) if row.get('clock_out'): ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S') co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S') calc = TimeCalculator(work_minutes=work_minutes, lunch_duration_minutes=row.get('lunch_minutes') or lunch_default) include_lunch = (row.get('lunch_minutes') or 0) > 0 total = (co_dt - ci_dt).total_seconds() / 3600 ot_actual, ot_earned = calc.calculate_overtime( ci_dt, co_dt, include_lunch=include_lunch ) db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned) if include_lunch: db.update_lunch_break(row['date'], True) if ot_earned > 0: db.add_overtime_earned(wid, ot_earned, row['date']) return added, updated, skipped