345002944e
Gap-1: Überziehschutz für Überstundenkonto
- Company.overtime_overdraft_allowed (default: true) – blockiert FZA wenn deaktiviert
- Company.overtime_warning_threshold_hours (default: 0) – Warnung wenn Konto unter Schwelle fällt
- warnings[] jetzt in approve_absence Response (AbsenceApproveOut)
- Migration 0028_overtime_fza_config.py
Gap-2: total_hours wird bei Zeiteintrag-Genehmigung neu berechnet
- time_service.approve_entry() ruft _recalculate_overtime_balance() auf
- last_calculated Timestamp wird gesetzt
Gap-3: Stornierung genehmigter FZA-Anträge bucht taken_hours zurück
- _refund_overtime() Helfer hinzugefügt
- cancel_absence() erlaubt jetzt HR/Admin auch genehmigte Abwesenheiten zu stornieren
- DELETE /absences/{id} gibt jetzt AbsenceOut zurück (statt 204)
- Mitarbeiter können genehmigte FZA-Anträge nicht selbst stornieren (409)
Frontend:
- CompanySettingsPage: neuer Abschnitt 'Freizeitausgleich' mit Toggle + Schwellwert-Eingabe
Tests: backend/tests/test_fza.py mit 6 Tests (alle 3 Gaps)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
536 lines
21 KiB
Python
536 lines
21 KiB
Python
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)
|
||
|
||
# Ü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()
|