feat: Freizeitausgleich-Lücken geschlossen (Gap 1-3) + konfigurierbare Schwellwerte
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>
This commit is contained in:
@@ -52,6 +52,10 @@ class Company(Base):
|
||||
# Mobile-Konfiguration
|
||||
mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Freizeitausgleich-Konfiguration
|
||||
overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
||||
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
||||
|
||||
@@ -278,26 +278,36 @@ async def get_absence(
|
||||
return AbsenceOut.model_validate(absence)
|
||||
|
||||
|
||||
@router.delete("/absences/{absence_id}", status_code=204)
|
||||
@router.delete("/absences/{absence_id}", response_model=AbsenceOut)
|
||||
async def cancel_absence(
|
||||
absence_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Eigenen ausstehenden Antrag stornieren."""
|
||||
await absence_service.cancel_absence(absence_id, current_user, db)
|
||||
"""Antrag stornieren.
|
||||
Eigene PENDING-Anträge: alle Rollen.
|
||||
APPROVED-Anträge: nur HR/COMPANY_ADMIN/SUPER_ADMIN (mit Rückbuchung von FZA-Stunden).
|
||||
"""
|
||||
absence = await absence_service.cancel_absence(absence_id, current_user, db)
|
||||
await db.commit()
|
||||
return AbsenceOut.model_validate(absence)
|
||||
|
||||
|
||||
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
|
||||
class AbsenceApproveOut(AbsenceOut):
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.post("/absences/{absence_id}/approve", response_model=AbsenceApproveOut)
|
||||
async def approve_absence(
|
||||
absence_id: UUID,
|
||||
current_user: User = require_role(*_manager_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
absence = await absence_service.approve_absence(absence_id, current_user, db)
|
||||
absence, warnings = await absence_service.approve_absence(absence_id, current_user, db)
|
||||
await db.commit()
|
||||
return AbsenceOut.model_validate(absence)
|
||||
out = AbsenceApproveOut.model_validate(absence)
|
||||
out.warnings = warnings
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
|
||||
|
||||
@@ -22,6 +22,8 @@ class CompanyOut(BaseModel):
|
||||
personnel_number_mode: PersonnelNumberModeT = "manual"
|
||||
personnel_number_next: int = 1
|
||||
mobile_stamping_enabled: bool = True
|
||||
overtime_overdraft_allowed: bool = True
|
||||
overtime_warning_threshold_hours: int = 0
|
||||
|
||||
|
||||
class CompanyUpdate(BaseModel):
|
||||
@@ -32,6 +34,8 @@ class CompanyUpdate(BaseModel):
|
||||
personnel_number_mode: PersonnelNumberModeT | None = None
|
||||
personnel_number_next: int | None = Field(None, ge=1)
|
||||
mobile_stamping_enabled: bool | None = None
|
||||
overtime_overdraft_allowed: bool | None = None
|
||||
overtime_warning_threshold_hours: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class DepartmentOut(BaseModel):
|
||||
|
||||
@@ -298,10 +298,28 @@ class AbsenceService:
|
||||
absence = await db.get(Absence, absence_id)
|
||||
if absence is None:
|
||||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||
if absence.user_id != current_user.id:
|
||||
|
||||
is_admin = current_user.role in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
|
||||
if absence.user_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
|
||||
if absence.status != AbsenceStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
|
||||
|
||||
if absence.status == AbsenceStatus.APPROVED:
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Genehmigte Anträge können nur von HR/Admin storniert werden."
|
||||
)
|
||||
# Überstunden zurückbuchen wenn Freizeitausgleich
|
||||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
await self._refund_overtime(absence.user_id, absence.working_days, db)
|
||||
elif absence.status != AbsenceStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Nur ausstehende oder genehmigte Anträge können storniert werden."
|
||||
)
|
||||
|
||||
absence.status = AbsenceStatus.CANCELLED
|
||||
|
||||
# Audit-Log (DSGVO)
|
||||
@@ -357,8 +375,9 @@ class AbsenceService:
|
||||
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
|
||||
|
||||
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||
fza_warnings: list[str] = []
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||
fza_warnings = await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||
|
||||
# Audit-Log (DSGVO)
|
||||
db.add(AuditLog(
|
||||
@@ -383,7 +402,7 @@ class AbsenceService:
|
||||
from app.services.caldav_service import caldav_service
|
||||
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
||||
|
||||
return absence
|
||||
return absence, fza_warnings
|
||||
|
||||
async def reject_absence(
|
||||
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
|
||||
@@ -562,11 +581,8 @@ class AbsenceService:
|
||||
balance = await self._get_or_create_balance(user_id, year, db)
|
||||
balance.used_days += days
|
||||
|
||||
async def _deduct_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
) -> None:
|
||||
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
|
||||
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
|
||||
async def _calc_daily_hours(self, user_id: UUID, db: AsyncSession) -> Decimal:
|
||||
"""Tägliche Soll-Stunden aus Arbeitsplan ermitteln (Fallback: 8h)."""
|
||||
user = await db.get(User, user_id)
|
||||
daily_hours = Decimal("8.00")
|
||||
if user and user.work_schedule_id:
|
||||
@@ -579,20 +595,64 @@ class AbsenceService:
|
||||
)
|
||||
if working_days_in_week > 0:
|
||||
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
|
||||
return daily_hours
|
||||
|
||||
async def _deduct_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
) -> list[str]:
|
||||
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab.
|
||||
Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
|
||||
user = await db.get(User, user_id)
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is None:
|
||||
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
|
||||
company_id = user.company_id if user else None
|
||||
if not company_id:
|
||||
return
|
||||
return []
|
||||
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
|
||||
db.add(ob)
|
||||
await db.flush()
|
||||
|
||||
# Firmen-Konfiguration für Überziehen laden
|
||||
company = await db.get(Company, ob.company_id)
|
||||
overdraft_allowed = company.overtime_overdraft_allowed if company else True
|
||||
warning_threshold = Decimal(str(company.overtime_warning_threshold_hours if company else 0))
|
||||
|
||||
available = ob.available_hours
|
||||
warnings: list[str] = []
|
||||
|
||||
if available < hours_to_deduct and not overdraft_allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Nicht genug Überstunden für Freizeitausgleich. "
|
||||
f"Verfügbar: {float(available):.1f}h, benötigt: {float(hours_to_deduct):.1f}h."
|
||||
),
|
||||
)
|
||||
|
||||
after_deduction = available - hours_to_deduct
|
||||
if warning_threshold > 0 and after_deduction < warning_threshold:
|
||||
sign = "-" if after_deduction < 0 else ""
|
||||
warnings.append(
|
||||
f"Überstundenkonto sinkt unter die Warnschwelle "
|
||||
f"({float(warning_threshold):.0f}h). Verbleibend: {sign}{abs(float(after_deduction)):.1f}h."
|
||||
)
|
||||
|
||||
ob.taken_hours += hours_to_deduct
|
||||
return warnings
|
||||
|
||||
async def _refund_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
) -> None:
|
||||
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_refund = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is not None:
|
||||
ob.taken_hours = max(Decimal("0"), ob.taken_hours - hours_to_refund)
|
||||
|
||||
async def _get_holiday_dates(
|
||||
self, company_id: UUID, year: int, db: AsyncSession
|
||||
|
||||
@@ -336,6 +336,17 @@ class TimeService:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user