from datetime import date, datetime, time, timezone from uuid import UUID from fastapi import HTTPException from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.audit_log import AuditLog from app.models.time_entry import EntrySource, EntryStatus, TimeEntry from app.models.user import User, UserRole from app.models.work_schedule import WorkSchedule from app.schemas.time_entry import ( BalanceResponse, ManualEntryCreate, StampInRequest, TimeEntryUpdate, ) def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]: """ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht.""" start_mins = start.hour * 60 + start.minute end_mins = end.hour * 60 + end.minute if end_mins <= start_mins: end_mins += 24 * 60 # Nachtschicht total_mins = end_mins - start_mins worked_mins = total_mins - break_minutes worked_hours = worked_mins / 60 warnings: list[str] = [] if worked_hours > 10: warnings.append( f"Maximale Arbeitszeit von 10 Stunden überschritten " f"({worked_hours:.1f}h gearbeitet) – ArbZG §3" ) if total_mins >= 9 * 60 and break_minutes < 45: warnings.append( "Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben – ArbZG §4" ) elif total_mins >= 6 * 60 and break_minutes < 30: warnings.append( "Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben – ArbZG §4" ) return warnings def _check_rest_period(prev_end: time | None, prev_date: date | None, new_start: time, new_date: date) -> list[str]: """Mindestruhezeit 11h zwischen Schichten – ArbZG §5. Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen Stempelungen am gleichen Tag (z.B. Korrektur oder Pause). """ if prev_end is None or prev_date is None: return [] # Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar if prev_date == new_date: return [] prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None) new_start_dt = datetime.combine(new_date, new_start, tzinfo=None) rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600 # Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen if 0 < rest_hours < 11: return [ f"Mindestruhezeit von 11h unterschritten " f"({rest_hours:.1f}h seit letzter Schicht) – ArbZG §5" ] return [] class TimeService: # ── Stempeluhr ──────────────────────────────────────────────────────────── async def stamp_in( self, user: User, data: StampInRequest, db: AsyncSession, ) -> tuple[TimeEntry, list[str]]: today = datetime.now(timezone.utc).date() now_time = datetime.now(timezone.utc).time().replace(tzinfo=None) # Offenen Eintrag für heute prüfen open_entry = await self._get_open_entry(user.id, db) if open_entry is not None: raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.") # Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen last_entry = await db.scalar( select(TimeEntry) .where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None)) .order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc()) .limit(1) ) warnings = _check_rest_period( last_entry.end_time if last_entry else None, last_entry.date if last_entry else None, now_time, today, ) entry = TimeEntry( user_id=user.id, date=today, start_time=now_time, break_minutes=0, source=data.source, project_id=data.project_id, note=data.note, status=EntryStatus.PENDING, ) db.add(entry) await db.flush() return entry, warnings async def stamp_out( self, user: User, note: str | None, db: AsyncSession, ) -> tuple[TimeEntry, list[str]]: entry = await self._get_open_entry(user.id, db) if entry is None: raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.") now_time = datetime.now(timezone.utc).time().replace(tzinfo=None) # Aktive Pause beenden falls vergessen if entry.break_start is not None: extra_break = self._calc_break_minutes(entry.break_start, now_time) entry.break_minutes += extra_break entry.break_start = None entry.end_time = now_time entry.updated_at = datetime.now(timezone.utc) if note: entry.note = note warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes) return entry, warnings async def break_start(self, user: User, db: AsyncSession) -> TimeEntry: entry = await self._get_open_entry(user.id, db) if entry is None: raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.") if entry.break_start is not None: raise HTTPException(status_code=409, detail="Pause bereits aktiv.") now_time = datetime.now(timezone.utc).time().replace(tzinfo=None) entry.break_start = now_time entry.updated_at = datetime.now(timezone.utc) return entry async def break_end(self, user: User, db: AsyncSession) -> TimeEntry: entry = await self._get_open_entry(user.id, db) if entry is None: raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.") if entry.break_start is None: raise HTTPException(status_code=409, detail="Keine aktive Pause.") now_time = datetime.now(timezone.utc).time().replace(tzinfo=None) extra = self._calc_break_minutes(entry.break_start, now_time) entry.break_minutes += extra entry.break_start = None entry.updated_at = datetime.now(timezone.utc) return entry # ── Einträge ────────────────────────────────────────────────────────────── async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]: today = datetime.now(timezone.utc).date() result = await db.scalars( select(TimeEntry) .where(TimeEntry.user_id == user.id, TimeEntry.date == today) .order_by(TimeEntry.start_time) ) return list(result.all()) async def list_entries( self, company_id: UUID, current_user: User, db: AsyncSession, user_id: UUID | None = None, date_from: date | None = None, date_to: date | None = None, status: EntryStatus | None = None, skip: int = 0, limit: int = 50, ) -> tuple[int, list[TimeEntry]]: # Basis: nur Einträge der eigenen Company # Subquery: JOIN user für company_id Filter q = ( select(TimeEntry) .join(User, TimeEntry.user_id == User.id) .where(User.company_id == company_id) ) # EMPLOYEE sieht nur eigene Einträge if current_user.role == UserRole.EMPLOYEE: q = q.where(TimeEntry.user_id == current_user.id) elif user_id: q = q.where(TimeEntry.user_id == user_id) if date_from: q = q.where(TimeEntry.date >= date_from) if date_to: q = q.where(TimeEntry.date <= date_to) if status: q = q.where(TimeEntry.status == status) total = await db.scalar(select(func.count()).select_from(q.subquery())) entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit)) return total or 0, list(entries.all()) async def create_manual( self, data: ManualEntryCreate, current_user: User, db: AsyncSession, ) -> tuple[TimeEntry, list[str]]: target_user_id = current_user.id # Employees need explicit permission to create manual entries _elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry: raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.") if data.user_id and data.user_id != current_user.id: if current_user.role not in _elevated: raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.") target = await db.get(User, data.user_id) if target is None or target.company_id != current_user.company_id: raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.") target_user_id = data.user_id entry = TimeEntry( user_id=target_user_id, date=data.date, start_time=data.start_time, end_time=data.end_time, break_minutes=data.break_minutes, project_id=data.project_id, note=data.note, source=data.source, status=EntryStatus.PENDING, ) db.add(entry) await db.flush() warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes) return entry, warnings async def update_entry( self, entry_id: UUID, data: TimeEntryUpdate, current_user: User, db: AsyncSession, ) -> TimeEntry: _manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) entry = await self._get_entry_or_404(entry_id, db) await self._assert_access(entry, current_user, db) if entry.status == EntryStatus.APPROVED: if current_user.role not in _manager_roles: raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.") if not data.correction_note: raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.") # Vorherigen Zustand für AuditLog sichern old_snapshot = { "started_at": entry.started_at.isoformat() if entry.started_at else None, "ended_at": entry.ended_at.isoformat() if entry.ended_at else None, "break_minutes": entry.break_minutes, "note": entry.note, "correction_note": entry.correction_note, } changes = data.model_dump(exclude_none=True) for field, value in changes.items(): setattr(entry, field, value) entry.updated_at = datetime.now(timezone.utc) if entry.status == EntryStatus.APPROVED: new_snapshot = { "started_at": entry.started_at.isoformat() if entry.started_at else None, "ended_at": entry.ended_at.isoformat() if entry.ended_at else None, "break_minutes": entry.break_minutes, "note": entry.note, "correction_note": entry.correction_note, } user_obj = await db.get(User, entry.user_id) db.add(AuditLog( company_id=current_user.company_id, user_id=current_user.id, action="time_entry_approved_edit", entity_type="time_entry", entity_id=entry.id, old_value=old_snapshot, new_value={**new_snapshot, "changed_by": str(current_user.id), "target_user": str(entry.user_id), "target_user_name": user_obj.full_name if user_obj else None}, )) return entry async def approve_entry( self, entry_id: UUID, current_user: User, db: AsyncSession, ) -> TimeEntry: if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.") entry = await self._get_entry_or_404(entry_id, db) # Cross-Tenant-Schutz entry_user = await db.get(User, entry.user_id) if entry_user is None or entry_user.company_id != current_user.company_id: raise HTTPException(status_code=403, detail="Zugriff verweigert.") # Self-Approval-Schutz (L-03) if entry.user_id == current_user.id: raise HTTPException( status_code=409, detail="Eigene Zeiteinträge können nicht selbst genehmigt werden." ) if entry.status != EntryStatus.PENDING: raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.") entry.status = EntryStatus.APPROVED entry.approved_by = current_user.id entry.updated_at = datetime.now(timezone.utc) return entry async def reject_entry( self, entry_id: UUID, current_user: User, correction_note: str | None, db: AsyncSession, ) -> TimeEntry: if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.") entry = await self._get_entry_or_404(entry_id, db) if entry.status != EntryStatus.PENDING: raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.") entry.status = EntryStatus.REJECTED entry.approved_by = current_user.id if correction_note: entry.correction_note = correction_note entry.updated_at = datetime.now(timezone.utc) return entry async def delete_entry( self, entry_id: UUID, current_user: User, db: AsyncSession, ) -> None: entry = await self._get_entry_or_404(entry_id, db) await self._assert_access(entry, current_user, db) # Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden if entry.status == EntryStatus.APPROVED: if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN): raise HTTPException( status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden." ) await db.delete(entry) async def get_balance( self, user_id: UUID, current_user: User, db: AsyncSession, period_start: date | None = None, period_end: date | None = None, ) -> BalanceResponse: # Zugriff prüfen if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE: raise HTTPException(status_code=403, detail="Keine Berechtigung.") today = datetime.now(timezone.utc).date() if period_start is None: period_start = today.replace(day=1) if period_end is None: period_end = today # Genehmigte Einträge summieren approved_entries = await db.scalars( select(TimeEntry).where( and_( TimeEntry.user_id == user_id, TimeEntry.date >= period_start, TimeEntry.date <= period_end, TimeEntry.status == EntryStatus.APPROVED, TimeEntry.end_time.isnot(None), ) ) ) approved_list = list(approved_entries.all()) total_worked = sum(e.worked_hours or 0.0 for e in approved_list) # Ausstehende Einträge zählen pending_count = await db.scalar( select(func.count(TimeEntry.id)).where( and_( TimeEntry.user_id == user_id, TimeEntry.date >= period_start, TimeEntry.date <= period_end, TimeEntry.status == EntryStatus.PENDING, ) ) ) or 0 # Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan) schedule = await db.scalar( select(WorkSchedule) .join(User, WorkSchedule.company_id == User.company_id) .where( User.id == user_id, WorkSchedule.valid_from <= period_start, ) .order_by(WorkSchedule.valid_from.desc()) .limit(1) ) expected = self._calc_expected_hours(period_start, period_end, schedule) return BalanceResponse( user_id=user_id, period_start=period_start, period_end=period_end, total_hours_worked=round(total_worked, 2), expected_hours=round(expected, 2), overtime_hours=round(total_worked - expected, 2), approved_entries=len(approved_list), pending_entries=pending_count, ) # ── Arbeitspläne ────────────────────────────────────────────────────────── async def create_work_schedule( self, company_id: UUID, data, db: AsyncSession, ) -> WorkSchedule: schedule = WorkSchedule(company_id=company_id, **data.model_dump()) db.add(schedule) await db.flush() return schedule async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]: result = await db.scalars( select(WorkSchedule) .where(WorkSchedule.company_id == company_id) .order_by(WorkSchedule.valid_from.desc()) ) return list(result.all()) # ── Helpers ─────────────────────────────────────────────────────────────── async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None: return await db.scalar( select(TimeEntry).where( TimeEntry.user_id == user_id, TimeEntry.end_time.is_(None), ).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1) ) async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry: entry = await db.get(TimeEntry, entry_id) if entry is None: raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.") return entry async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None: if entry.user_id != user.id and user.role not in ( UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN ): raise HTTPException(status_code=403, detail="Keine Berechtigung.") entry_user = await db.get(User, entry.user_id) if entry_user is None or entry_user.company_id != user.company_id: raise HTTPException(status_code=403, detail="Zugriff verweigert.") @staticmethod def _calc_break_minutes(start: time, end: time) -> int: s = start.hour * 60 + start.minute e = end.hour * 60 + end.minute if e < s: e += 24 * 60 return max(0, e - s) @staticmethod def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float: """Soll-Stunden für den Zeitraum berechnen.""" from datetime import timedelta total = 0.0 current = period_start while current <= period_end: wd = current.weekday() # 0=Mon if schedule: total += float(schedule.hours_for_weekday(wd)) else: # Fallback: 8h Mo-Fr if wd < 5: total += 8.0 current += timedelta(days=1) return total time_service = TimeService()