Files
timemaster/backend/app/schemas/absence.py
T
patrickandClaude Opus 4.8 c9c197ddae
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat: agent-11 PR2 – Urlaubsanspruch (Verfall scharf, Pro-rata, Teilzeit)
Korrektheit der Urlaubskonten (Feature-Parität mit Urlaubsverwaltung):

- Verfall scharfgeschaltet: neuer effektiv verfügbarer Saldo (available_days)
  schließt verfallenen, noch nicht verbrauchten Resturlaub aus; Konto-Warnung
  beim Antrag nutzt jetzt available statt remaining. (Verfallsdatum bleibt in
  company.settings, UI bereits vorhanden.)
- Anteilige Berechnung (Zwölftel) im Ein-/Austrittsjahr anhand neuer Felder
  users.entry_date / exit_date; opt-in pro Firma (vacation_prorate_first_year).
- Teilzeit: Anspruch optional aus Arbeitstagen/Woche des WorkSchedule abgeleitet
  (vacation_from_schedule), Basis vacation_default_days.
- _get_or_create_balance berechnet den Grundanspruch jetzt frisch
  (_compute_entitlement); _carryover_expired/effective_available als Service-API,
  Router delegiert.

Frontend: CompanySettingsPage (Jahresurlaub + Pro-rata- und Teilzeit-Toggles),
UsersPage (Ein-/Austrittsdatum im Edit-Modal), AbsencesPage ("Verfügbar" + Hinweis
bei verfallenem Resturlaub).

Migration 0036. 183/183 Tests grün. Deployed auf 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:42 +02:00

245 lines
7.9 KiB
Python

import uuid
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, Field, model_validator
from app.models.absence import AbsenceStatus
from app.models.absence_type import AbsenceCategory
# ── AbsenceType ───────────────────────────────────────────────────────────────
class AbsenceTypeOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
color: str
category: AbsenceCategory
requires_approval: bool
deducts_vacation: bool
affects_overtime_balance: bool
requires_certificate: bool
certificate_after_days: int
is_paid: bool
max_days_per_year: int | None
is_active: bool
class AbsenceTypeCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory = AbsenceCategory.OTHER
requires_approval: bool = True
deducts_vacation: bool = False
affects_overtime_balance: bool = False
requires_certificate: bool = False
certificate_after_days: int = Field(3, ge=0, le=365)
is_paid: bool = True
max_days_per_year: int | None = Field(None, ge=1)
class AbsenceTypeUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory | None = None
requires_approval: bool | None = None
deducts_vacation: bool | None = None
affects_overtime_balance: bool | None = None
requires_certificate: bool | None = None
certificate_after_days: int | None = Field(None, ge=0, le=365)
is_paid: bool | None = None
max_days_per_year: int | None = Field(None, ge=1)
is_active: bool | None = None
# ── Absence ───────────────────────────────────────────────────────────────────
class AbsenceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool
half_day_end: bool
working_days: float
fza_hours: Decimal | None = None
status: AbsenceStatus
approved_by: uuid.UUID | None
substitute_id: uuid.UUID | None
note: str | None
correction_note: str | None
rejection_reason: str | None
certificate_required_by: date | None = None
certificate_received_at: date | None = None
created_at: datetime
class AbsenceCreate(BaseModel):
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool = False
half_day_end: bool = False
substitute_id: uuid.UUID | None = None
note: str | None = None
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
fza_hours: Decimal | None = Field(
None,
ge=Decimal("0.25"),
le=Decimal("24"),
description="FZA in Stunden (statt Tagen); nur bei eintägigem Zeitraum erlaubt.",
)
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
if self.fza_hours is not None and self.start_date != self.end_date:
raise ValueError("fza_hours ist nur erlaubt wenn start_date == end_date (eintägiger FZA).")
class AbsenceUpdate(BaseModel):
type_id: uuid.UUID | None = None
start_date: date | None = None
end_date: date | None = None
half_day_start: bool | None = None
half_day_end: bool | None = None
substitute_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
def model_post_init(self, __context) -> None:
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class AbsenceReject(BaseModel):
rejection_reason: str = Field(min_length=1)
class CancellationRequest(BaseModel):
reason: str | None = Field(None, max_length=1000)
# ── Kommentare ────────────────────────────────────────────────────────────────
class AbsenceCommentOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
absence_id: uuid.UUID
author_id: uuid.UUID | None
author_name: str | None = None
body: str
is_system: bool
created_at: datetime
class AbsenceCommentCreate(BaseModel):
body: str = Field(min_length=1, max_length=2000)
class AbsenceListResponse(BaseModel):
total: int
items: list[AbsenceOut]
# ── Krankmeldung ──────────────────────────────────────────────────────────────
class QuickSickIn(BaseModel):
start_date: date
end_date: date
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class CertificateMarkIn(BaseModel):
received_at: date | None = None # default = heute
class SickStatsOut(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
episodes: int
total_days: float
bradford_factor: float
certificates_overdue: int
# ── VacationBalance ───────────────────────────────────────────────────────────
class VacationBalanceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
year: int
entitled_days: int
special_days: int = 0
carried_over: int
used_days: int
total_days: int
remaining_days: int
# Effektiv verfügbar: berücksichtigt verfallenen Resturlaub (zur Laufzeit befüllt).
available_days: int = 0
pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None
carried_over_expired: bool = False
class VacationBalanceUpdate(BaseModel):
entitled_days: int | None = Field(None, ge=0, le=365)
special_days: int | None = Field(None, ge=0, le=365)
carried_over: int | None = Field(None, ge=0, le=365)
# ── PublicHoliday ─────────────────────────────────────────────────────────────
class PublicHolidayOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
country: str
state: str | None
date: date
name: str
year: int
class PublicHolidayCreate(BaseModel):
country: str = Field("DE", min_length=2, max_length=10)
state: str | None = Field(None, max_length=10)
date: date
name: str = Field(min_length=1, max_length=255)
# ── OvertimeBalance ───────────────────────────────────────────────────────────
class OvertimeBalanceOut(BaseModel):
total_hours: float
taken_hours: float
available_hours: float
# ── Calendar ──────────────────────────────────────────────────────────────────
class CalendarEntry(BaseModel):
user_id: uuid.UUID
user_name: str
absence_id: uuid.UUID
type_name: str
type_color: str
start_date: date
end_date: date
status: AbsenceStatus
working_days: float