Files
timemaster/backend/app/services/kimai_import_service.py
T
sysops 1fedd683e0 Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:27 +02:00

365 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kimai CSV Import Service parst Kimai-Export und erzeugt TimeEntries + Absences."""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User
# ---------------------------------------------------------------------------
# Datenstrukturen für die Vorschau
# ---------------------------------------------------------------------------
@dataclass
class KimaiRow:
date: date
start: time
end: time
duration_sec: int
projekt: str
taetigkeit: str
beschreibung: str
@dataclass
class ImportPreviewEntry:
kind: str # "time" | "absence"
date_from: str
date_to: str
start: str | None
end: str | None
break_minutes: int
worked_hours: float | None
absence_type: str | None
note: str | None
skipped: bool = False
skip_reason: str | None = None
@dataclass
class ImportResult:
preview: list[ImportPreviewEntry] = field(default_factory=list)
time_imported: int = 0
absence_imported: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _parse_time(s: str) -> time:
parts = s.strip().split(":")
return time(int(parts[0]), int(parts[1]))
def _parse_date(s: str) -> date:
return datetime.strptime(s.strip(), "%Y-%m-%d").date()
def _gross_minutes(start: time, end: time) -> int:
return (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
def _break_minutes(row: KimaiRow) -> int:
gross = _gross_minutes(row.start, row.end)
net = row.duration_sec // 60
return max(0, gross - net)
def _worked_hours(row: KimaiRow) -> float:
net_min = row.duration_sec / 60
return round(net_min / 60, 2)
def _is_vacation_row(row: KimaiRow) -> bool:
return row.projekt.strip().lower() == "urlaub"
def _note(row: KimaiRow) -> str | None:
"""
Notiz aus Beschreibung; falls leer, Tätigkeit außer 'Reguläre Arbeitszeit'
(das ist der Standard und braucht keine eigene Notiz).
"""
desc = row.beschreibung.strip()
if desc:
return desc
taet = row.taetigkeit.strip()
if taet and taet.lower() != "reguläre arbeitszeit":
return taet
return None
def _absence_type_name(row: KimaiRow) -> str:
"""Ermittelt Abwesenheitstyp aus Beschreibung."""
desc = row.beschreibung.strip().lower()
if "sonderurlaub" in desc:
return "Sonderurlaub"
return "Urlaub"
def _group_vacation_rows(rows: list[KimaiRow]) -> list[tuple[date, date, str, str]]:
"""
Gruppiert aufeinanderfolgende Urlaubszeilen (gleicher Typ) zu Abwesenheitsblöcken.
Gibt Liste von (start_date, end_date, abs_type_name, note) zurück.
"""
if not rows:
return []
rows_sorted = sorted(rows, key=lambda r: r.date)
groups: list[tuple[date, date, str, str]] = []
cur_start = rows_sorted[0].date
cur_end = rows_sorted[0].date
cur_type = _absence_type_name(rows_sorted[0])
cur_note = rows_sorted[0].beschreibung.strip()
for row in rows_sorted[1:]:
t = _absence_type_name(row)
# Aufeinanderfolgend = max. 3 Tage Abstand (Wochenende überbrücken)
gap = (row.date - cur_end).days
if t == cur_type and gap <= 3:
cur_end = row.date
if row.beschreibung.strip() and row.beschreibung.strip() not in cur_note:
cur_note = (cur_note + " / " + row.beschreibung.strip()).strip(" /")
else:
groups.append((cur_start, cur_end, cur_type, cur_note))
cur_start = row.date
cur_end = row.date
cur_type = t
cur_note = row.beschreibung.strip()
groups.append((cur_start, cur_end, cur_type, cur_note))
return groups
# ---------------------------------------------------------------------------
# CSV-Parser
# ---------------------------------------------------------------------------
def parse_kimai_csv(content: bytes) -> tuple[list[KimaiRow], list[str]]:
"""Parst Kimai-CSV-Bytes, gibt (rows, errors) zurück."""
rows: list[KimaiRow] = []
errors: list[str] = []
text = content.decode("utf-8-sig") # BOM-safe
reader = csv.DictReader(io.StringIO(text))
for i, row in enumerate(reader, start=2):
try:
rows.append(KimaiRow(
date=_parse_date(row["Datum"]),
start=_parse_time(row["Von"]),
end=_parse_time(row["Bis"]),
duration_sec=int(row["Dauer"]),
projekt=row.get("Projekt", ""),
taetigkeit=row.get("Tätigkeit", ""),
beschreibung=row.get("Beschreibung", ""),
))
except Exception as e:
errors.append(f"Zeile {i}: {e}")
return rows, errors
# ---------------------------------------------------------------------------
# Preview (keine DB-Änderungen)
# ---------------------------------------------------------------------------
async def preview_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen laden
types_q = await db.execute(select(AbsenceType))
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# Zeiteinträge
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
skip = slot in existing_slots or slot in seen_slots
if not skip:
seen_slots.add(slot)
brk = _break_minutes(row)
result.preview.append(ImportPreviewEntry(
kind="time",
date_from=row.date.isoformat(),
date_to=row.date.isoformat(),
start=row.start.strftime("%H:%M"),
end=row.end.strftime("%H:%M"),
break_minutes=brk,
worked_hours=_worked_hours(row),
absence_type=None,
note=_note(row),
skipped=skip,
skip_reason="Bereits vorhanden (gleiche Zeit)" if skip else None,
))
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(AbsenceType.id).where(AbsenceType.id.in_([t.id for t in abs_types.values()]))
)
from app.models.absence import Absence as AbsenceModel
existing_abs_q2 = await db.execute(
select(AbsenceModel.start_date, AbsenceModel.end_date, AbsenceModel.type_id)
.where(AbsenceModel.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q2}
# Urlaubsblöcke
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
already_exists = t is not None and (start, end, t.id) in existing_absences
skip = t is None or already_exists
result.preview.append(ImportPreviewEntry(
kind="absence",
date_from=start.isoformat(),
date_to=end.isoformat(),
start=None,
end=None,
break_minutes=0,
worked_hours=None,
absence_type=type_name,
note=note or None,
skipped=skip,
skip_reason=(
f"Abwesenheitstyp '{type_name}' nicht gefunden" if t is None
else "Bereits vorhanden" if already_exists
else None
),
))
result.skipped = sum(1 for p in result.preview if p.skipped)
return result
# ---------------------------------------------------------------------------
# Eigentlicher Import (mit DB-Änderungen)
# ---------------------------------------------------------------------------
async def run_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
approver_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# User + Company laden
user_q = await db.execute(select(User).where(User.id == target_user_id))
user = user_q.scalar_one_or_none()
if not user:
result.errors.append("Ziel-User nicht gefunden.")
return result
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen
types_q = await db.execute(
select(AbsenceType).where(AbsenceType.company_id == user.company_id)
)
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# ---- Zeiteinträge ----
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
if slot in existing_slots or slot in seen_slots:
result.skipped += 1
continue
seen_slots.add(slot)
brk = _break_minutes(row)
entry = TimeEntry(
id=uuid.uuid4(),
user_id=target_user_id,
date=row.date,
start_time=row.start,
end_time=row.end,
break_minutes=brk,
status=EntryStatus.APPROVED,
source=EntrySource.API,
approved_by=approver_id,
note=_note(row),
)
db.add(entry)
result.time_imported += 1
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(Absence.start_date, Absence.end_date, Absence.type_id)
.where(Absence.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q}
# ---- Urlaubsblöcke ----
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
if not t:
result.errors.append(f"Abwesenheitstyp '{type_name}' nicht gefunden übersprungen.")
result.skipped += 1
continue
if (start, end, t.id) in existing_absences:
result.skipped += 1
continue
# Arbeitstage zählen (MoFr, keine Feiertage)
working_days = sum(
1 for n in range((end - start).days + 1)
if (start + timedelta(days=n)).weekday() < 5
)
absence = Absence(
id=uuid.uuid4(),
user_id=target_user_id,
type_id=t.id,
start_date=start,
end_date=end,
working_days=working_days,
status=AbsenceStatus.APPROVED,
approved_by=approver_id,
note=note or None,
)
db.add(absence)
result.absence_imported += 1
await db.commit()
return result