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
+76
View File
@@ -0,0 +1,76 @@
from datetime import date
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
from app.models.special_assignment import AssignmentMode
class SpecialAssignmentCreate(BaseModel):
date_from: date
date_to: date
factor: Decimal = Field(gt=0, le=10, decimal_places=3)
mode: AssignmentMode = AssignmentMode.both
description: str | None = None
label: str | None = Field(None, max_length=100)
@model_validator(mode="after")
def dates_valid(self) -> "SpecialAssignmentCreate":
if self.date_from > self.date_to:
raise ValueError("date_from darf nicht nach date_to liegen")
return self
class SpecialAssignmentUpdate(BaseModel):
date_from: date | None = None
date_to: date | None = None
factor: Decimal | None = Field(None, gt=0, le=10)
mode: AssignmentMode | None = None
description: str | None = None
label: str | None = Field(None, max_length=100)
class SpecialAssignmentOut(BaseModel):
id: UUID
user_id: UUID
company_id: UUID
date_from: date
date_to: date
factor: Decimal
mode: AssignmentMode
description: str | None = None
label: str | None = None
model_config = {"from_attributes": True}
# ── Payroll-Report-Schemas ────────────────────────────────────────────────────
class PayrollAssignmentEntry(BaseModel):
"""Einzelner Zeitraum mit Faktor für den Payroll-Report."""
assignment_id: UUID
label: str | None
date_from: date
date_to: date
factor: Decimal
normal_hours: float # Stunden ohne Faktor
factor_hours: float # Stunden * Faktor (effektiv für Abrechnung)
extra_hours: float # factor_hours - normal_hours (Mehrwert)
class PayrollAssignmentRow(BaseModel):
"""Zusammenfassung pro Mitarbeiter."""
user_id: UUID
user_name: str
personnel_number: str | None
assignments: list[PayrollAssignmentEntry]
total_normal_hours: float
total_factor_hours: float
total_extra_hours: float
class PayrollAssignmentReport(BaseModel):
year: int
month: int
rows: list[PayrollAssignmentRow]