Files
timemaster/backend/app/services/time_service.py
T
patrick e83a3fbbdd fix: agent-08 Kiosk-Härtung + 24h-Zeiteintrag-Bug
- fix: worked_minutes nutzt jetzt Sekunden statt Minuten für Overnight-Vergleich
  (end < start statt end <= start) – verhindert 24h-Anzeige bei Schnell-Stempel
  in derselben Minute (z.B. 23:34:46 → 23:34:48)
- fix: _check_arbzg() gleicher Sec-basierter Fix
- fix: KioskDeviceStatus Enum values_callable → kiosk list crasht nicht mehr
- feat: kiosk rotate-key CLI-Kommando (Status→pending, Re-Enrollment)
- feat: Kiosk-Settings in CompanyOut/CompanyUpdate Schema (require_approval,
  track_current_user, heartbeat_interval_sec)
- feat: Kiosk-Terminal-Einstellungsblock in CompanySettingsPage (🖥️)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:42:08 +02:00

536 lines
21 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.
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_secs = start.hour * 3600 + start.minute * 60 + start.second
end_secs = end.hour * 3600 + end.minute * 60 + end.second
if end_secs < start_secs:
end_secs += 24 * 3600 # Nachtschicht
total_mins = (end_secs - start_secs) // 60
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)
# Überstundenkonto neuberechnen (Gap-2-Fix)
from app.services.report_service import _recalculate_overtime_balance
schedule = await db.scalar(
select(WorkSchedule)
.where(WorkSchedule.company_id == entry_user.company_id)
.order_by(WorkSchedule.valid_from.desc())
.limit(1)
)
await _recalculate_overtime_balance(entry_user, schedule, db)
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()