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
+278
View File
@@ -0,0 +1,278 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.time_entry import EntryStatus
from app.models.user import User, UserRole
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
RejectRequest,
StampInRequest,
StampOutRequest,
TimeEntryListResponse,
TimeEntryOut,
TimeEntryUpdate,
TimeEntryWithWarnings,
WorkScheduleCreate,
WorkScheduleOut,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Stempeluhr ────────────────────────────────────────────────────────────────
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
async def stamp_in(
data: StampInRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Einstempeln startet einen neuen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_in(current_user, data, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
async def stamp_out(
data: StampOutRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstempeln schließt den offenen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/break-start", response_model=TimeEntryOut)
async def break_start(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beginnen."""
entry = await time_service.break_start(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/break-end", response_model=TimeEntryOut)
async def break_end(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beenden."""
entry = await time_service.break_end(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
# ── Heute ─────────────────────────────────────────────────────────────────────
@router.get("/today", response_model=list[TimeEntryOut])
async def get_today(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
entries = await time_service.get_today(current_user, db)
return [TimeEntryOut.model_validate(e) for e in entries]
# ── Einträge ──────────────────────────────────────────────────────────────────
@router.get("/entries", response_model=TimeEntryListResponse)
async def list_entries(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
status: EntryStatus | None = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
total, entries = await time_service.list_entries(
current_user.company_id, current_user, db,
user_id=user_id, date_from=date_from, date_to=date_to,
status=status, skip=skip, limit=limit,
)
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
async def create_manual_entry(
data: ManualEntryCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Manuellen Zeiterfassungseintrag anlegen."""
entry, warnings = await time_service.create_manual(data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
async def update_entry(
entry_id: UUID,
data: TimeEntryUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag korrigieren."""
entry = await time_service.update_entry(entry_id, data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
async def approve_entry(
entry_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag genehmigen."""
entry = await time_service.approve_entry(entry_id, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
async def reject_entry(
entry_id: UUID,
data: RejectRequest,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag ablehnen."""
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.delete("/entries/{entry_id}", status_code=204)
async def delete_entry(
entry_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
await time_service.delete_entry(entry_id, current_user, db)
await db.commit()
# ── Überstundenkonto ──────────────────────────────────────────────────────────
@router.get("/balance/me", response_model=BalanceResponse)
async def get_own_balance(
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstundenkonto."""
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
@router.get("/balance/{user_id}", response_model=BalanceResponse)
async def get_balance(
user_id: UUID,
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenkonto für einen Benutzer."""
if user_id != current_user.id:
if current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
@router.get("/schedules", response_model=list[WorkScheduleOut])
async def list_schedules(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
schedules = await time_service.list_work_schedules(current_user.company_id, db)
return [WorkScheduleOut.model_validate(s) for s in schedules]
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
async def create_schedule(
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
async def update_schedule(
schedule_id: UUID,
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
for field, value in data.model_dump().items():
setattr(schedule, field, value)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: UUID,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
await db.delete(schedule)
await db.commit()