1fedd683e0
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>
279 lines
10 KiB
Python
279 lines
10 KiB
Python
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()
|