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>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+524
View File
@@ -0,0 +1,524 @@
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()