feat: Sondervertretungs-Faktoren (special_assignments)

- Neues Model SpecialAssignment mit AssignmentMode (fza|payroll|both)
- CRUD-Endpunkte unter /users/{id}/special-assignments
- Payroll-Report: GET /reports/special-assignments/payroll?year=&month=
- Migration 0029: special_assignments Tabelle + btree_gist Overlap-Constraint
- _recalculate_overtime_balance berücksichtigt FZA-Faktoren
- Frontend: Sondervertretungs-Zeiträume im UsersPage Edit-Modal
- Frontend: ReportsPage neuer Tab 'Sondervertretungen' mit Payroll-Tabelle + CSV-Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:55:47 +02:00
parent 1170e59e49
commit d60349df67
12 changed files with 837 additions and 39 deletions
+29 -5
View File
@@ -181,7 +181,14 @@ async def _get_or_create_overtime_balance(user: User, db: AsyncSession) -> Overt
async def _recalculate_overtime_balance(
user: User, schedule: WorkSchedule | None, db: AsyncSession
) -> OvertimeBalance:
"""Berechnet das Überstundenguthaben neu aus allen genehmigten Zeiteinträgen."""
"""Berechnet das Überstundenguthaben neu aus allen genehmigten Zeiteinträgen.
Sondervertretungs-Faktoren (mode=fza|both) werden berücksichtigt:
Für jeden Zeiteintrag wird geprüft ob der Tag in einem aktiven Sondervertretungs-
Zeitraum liegt; falls ja wird worked_hours mit dem Faktor multipliziert.
"""
from app.models.special_assignment import AssignmentMode, SpecialAssignment
entries = list(await db.scalars(
select(TimeEntry).where(
TimeEntry.user_id == user.id,
@@ -197,10 +204,27 @@ async def _recalculate_overtime_balance(
bal.last_calculated = datetime.utcnow()
return bal
date_from = min(e.date for e in entries)
date_to = max(e.date for e in entries)
expected = _expected_hours(schedule, date_from, date_to)
worked = sum(e.worked_hours or 0.0 for e in entries)
# Sondervertretungs-Zuweisungen laden (nur FZA-relevante)
date_from_all = min(e.date for e in entries)
date_to_all = max(e.date for e in entries)
special_assignments = list(await db.scalars(
select(SpecialAssignment).where(
SpecialAssignment.user_id == user.id,
SpecialAssignment.mode.in_([AssignmentMode.fza, AssignmentMode.both]),
SpecialAssignment.date_from <= date_to_all,
SpecialAssignment.date_to >= date_from_all,
)
))
def _fza_factor(entry_date: date) -> float:
"""Gibt den Faktor für einen Tag zurück (1.0 wenn keine Zuweisung aktiv)."""
for sa in special_assignments:
if sa.date_from <= entry_date <= sa.date_to:
return float(sa.factor)
return 1.0
expected = _expected_hours(schedule, date_from_all, date_to_all)
worked = sum((e.worked_hours or 0.0) * _fza_factor(e.date) for e in entries)
overtime = max(0.0, worked - expected)
bal.total_hours = Decimal(str(round(overtime, 2)))