Geplante Erinnerungen (Feature-Parität mit Urlaubsverwaltung): - APScheduler (AsyncIOScheduler) in der FastAPI-Lifespan; tägliche Jobs ab settings.reminder_hour. Redis-Tageslock gegen Doppelversand bei mehreren Prozessen; jeder Job mit eigener Session + RLS-Bypass. - Drei Jobs (auch einzeln aufrufbar): offene Anträge an Genehmiger, Resturlaub-Verfall-Vorwarnung an Mitarbeiter, fehlende AU an HR. - Pro-User notification_prefs (JSONB, opt-out); GET/PATCH /users/me/notification-prefs + ProfilePage-UI; Vertreter-Mail respektiert die Prefs. - Manueller Trigger POST /companies/me/run-reminders (Admin) – gleiche Logik, firmen-scoped (testbar ohne Warten). - Bugfix: GET-/PATCH-Urlaubskonto (update_balance) nutzte nicht existente Felder (base_days/carried_over_days/ip_address) → korrigiert auf entitled_days/carried_over/ip + company_id; available_days ergänzt. Migration 0037 (users.notification_prefs). 188/188 Tests grün. Deployed 137 + 164. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1157 lines
50 KiB
Python
1157 lines
50 KiB
Python
import asyncio
|
||
import math
|
||
from datetime import date, timedelta
|
||
from uuid import UUID
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from decimal import Decimal
|
||
|
||
from app.models.absence import Absence, AbsenceStatus
|
||
from app.models.absence_type import AbsenceCategory, AbsenceType
|
||
from app.models.audit_log import AuditLog
|
||
from app.models.company import Company
|
||
from app.models.overtime_balance import OvertimeBalance
|
||
from app.models.public_holiday import PublicHoliday
|
||
from app.models.user import User, UserRole
|
||
from app.models.vacation_balance import VacationBalance
|
||
from app.models.work_schedule import WorkSchedule
|
||
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
|
||
|
||
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||
|
||
|
||
class AbsenceService:
|
||
|
||
# ── AbsenceTypes ─────────────────────────────────────────────────────────
|
||
|
||
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
|
||
result = await db.scalars(
|
||
select(AbsenceType)
|
||
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
|
||
.order_by(AbsenceType.name)
|
||
)
|
||
return list(result.all())
|
||
|
||
async def create_type(
|
||
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
|
||
) -> AbsenceType:
|
||
at = AbsenceType(company_id=company_id, **data.model_dump())
|
||
db.add(at)
|
||
await db.flush()
|
||
return at
|
||
|
||
async def update_type(
|
||
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
|
||
) -> AbsenceType:
|
||
at = await self._get_type_or_404(type_id, company_id, db)
|
||
for field, value in data.model_dump(exclude_none=True).items():
|
||
setattr(at, field, value)
|
||
return at
|
||
|
||
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
|
||
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
|
||
defaults = [
|
||
{
|
||
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
|
||
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||
},
|
||
{
|
||
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
|
||
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
|
||
"requires_certificate": True, "certificate_after_days": 3,
|
||
},
|
||
{
|
||
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
|
||
"requires_approval": True, "deducts_vacation": False,
|
||
"affects_overtime_balance": True, "is_paid": True,
|
||
},
|
||
{
|
||
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
|
||
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||
"max_days_per_year": 5,
|
||
},
|
||
{
|
||
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
|
||
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||
},
|
||
{
|
||
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
|
||
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||
},
|
||
{
|
||
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
|
||
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||
},
|
||
]
|
||
for d in defaults:
|
||
db.add(AbsenceType(company_id=company_id, **d))
|
||
|
||
# Standard-Arbeitsplan: Mo–Fr 8h
|
||
schedule = WorkSchedule(
|
||
company_id=company_id,
|
||
name="Vollzeit (40h)",
|
||
valid_from=date.today(),
|
||
)
|
||
db.add(schedule)
|
||
await db.flush()
|
||
|
||
# ── Absences ──────────────────────────────────────────────────────────────
|
||
|
||
async def list_absences(
|
||
self,
|
||
company_id: UUID,
|
||
current_user: User,
|
||
db: AsyncSession,
|
||
user_id: UUID | None = None,
|
||
type_id: UUID | None = None,
|
||
status: AbsenceStatus | None = None,
|
||
year: int | None = None,
|
||
as_substitute: bool = False,
|
||
) -> tuple[int, list[Absence]]:
|
||
q = (
|
||
select(Absence)
|
||
.join(User, Absence.user_id == User.id)
|
||
.where(User.company_id == company_id)
|
||
)
|
||
if as_substitute:
|
||
# Anträge, in denen der aktuelle User als Vertretung eingetragen ist
|
||
q = q.where(Absence.substitute_id == current_user.id)
|
||
elif current_user.role == UserRole.EMPLOYEE:
|
||
q = q.where(Absence.user_id == current_user.id)
|
||
elif user_id:
|
||
q = q.where(Absence.user_id == user_id)
|
||
|
||
if type_id:
|
||
q = q.where(Absence.type_id == type_id)
|
||
if status:
|
||
q = q.where(Absence.status == status)
|
||
if year:
|
||
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
|
||
|
||
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
|
||
result = await db.scalars(q.order_by(Absence.start_date.desc()))
|
||
return total, list(result.all())
|
||
|
||
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
|
||
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 and current_user.role == UserRole.EMPLOYEE:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
return absence
|
||
|
||
async def create_absence(
|
||
self,
|
||
data: AbsenceCreate,
|
||
current_user: User,
|
||
db: AsyncSession,
|
||
) -> tuple[Absence, list[str]]:
|
||
# AbsenceType validieren
|
||
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||
|
||
# Arbeitstage berechnen
|
||
holidays = await self._get_holiday_dates(
|
||
current_user.company_id, data.start_date.year, db
|
||
)
|
||
working_days = self._calc_working_days(
|
||
data.start_date, data.end_date, holidays,
|
||
data.half_day_start, data.half_day_end
|
||
)
|
||
|
||
# Krankmeldungen dürfen auch Wochenenden/Feiertage umfassen (0 Arbeitstage erlaubt)
|
||
if working_days <= 0 and absence_type.category != AbsenceCategory.SICK:
|
||
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
|
||
|
||
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
|
||
warnings: list[str] = []
|
||
if absence_type.deducts_vacation:
|
||
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
|
||
company = await db.get(Company, current_user.company_id)
|
||
_, expired = self._carryover_expired(company, data.start_date.year)
|
||
available = self.effective_available(balance, expired)
|
||
if available < working_days:
|
||
warnings.append(
|
||
f"Urlaubskonto reicht möglicherweise nicht aus: "
|
||
f"{available} Tage verfügbar, {working_days} Tage beantragt."
|
||
)
|
||
|
||
# Überschneidung mit eigenen Abwesenheiten prüfen
|
||
overlap = await db.scalar(
|
||
select(Absence).where(
|
||
and_(
|
||
Absence.user_id == current_user.id,
|
||
Absence.status != AbsenceStatus.CANCELLED,
|
||
Absence.status != AbsenceStatus.REJECTED,
|
||
Absence.start_date <= data.end_date,
|
||
Absence.end_date >= data.start_date,
|
||
)
|
||
)
|
||
)
|
||
if overlap:
|
||
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
|
||
|
||
# Vertreter prüfen: gleiche Firma + im Zeitraum selbst nicht abwesend (nur Warnung)
|
||
if data.substitute_id:
|
||
substitute = await db.get(User, data.substitute_id)
|
||
if substitute is None or substitute.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=404, detail="Vertretung nicht gefunden.")
|
||
if substitute.id == current_user.id:
|
||
raise HTTPException(status_code=400, detail="Man kann sich nicht selbst vertreten.")
|
||
sub_overlap = await db.scalar(
|
||
select(Absence).where(
|
||
and_(
|
||
Absence.user_id == data.substitute_id,
|
||
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
|
||
Absence.start_date <= data.end_date,
|
||
Absence.end_date >= data.start_date,
|
||
)
|
||
)
|
||
)
|
||
if sub_overlap:
|
||
warnings.append(
|
||
f"Gewählte Vertretung ({substitute.full_name}) ist im Zeitraum selbst abwesend."
|
||
)
|
||
|
||
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
|
||
approved_by = None if absence_type.requires_approval else current_user.id
|
||
|
||
# Krankmeldung: AU-Pflicht-Datum automatisch berechnen.
|
||
# Reihenfolge: AbsenceType.certificate_after_days (override) → Company default.
|
||
certificate_required_by: date | None = None
|
||
if absence_type.category == AbsenceCategory.SICK and absence_type.requires_certificate:
|
||
company = await db.get(Company, current_user.company_id)
|
||
company_default = company.sick_note_required_after_days if company else 3
|
||
threshold = absence_type.certificate_after_days or company_default
|
||
certificate_required_by = data.start_date + timedelta(days=threshold)
|
||
|
||
absence = Absence(
|
||
user_id=current_user.id,
|
||
type_id=data.type_id,
|
||
start_date=data.start_date,
|
||
end_date=data.end_date,
|
||
half_day_start=data.half_day_start,
|
||
half_day_end=data.half_day_end,
|
||
working_days=working_days,
|
||
fza_hours=data.fza_hours if hasattr(data, "fza_hours") else None,
|
||
status=status,
|
||
approved_by=approved_by,
|
||
substitute_id=data.substitute_id,
|
||
note=data.note,
|
||
certificate_required_by=certificate_required_by,
|
||
)
|
||
db.add(absence)
|
||
await db.flush()
|
||
|
||
# Bei automatischer Genehmigung Konto abziehen + Vertretung benachrichtigen
|
||
if not absence_type.requires_approval:
|
||
if absence_type.deducts_vacation:
|
||
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
|
||
if absence.substitute_id:
|
||
await self._notify_substitute(absence, db)
|
||
|
||
return absence, warnings
|
||
|
||
async def update_absence(
|
||
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
|
||
) -> Absence:
|
||
from app.schemas.absence import AbsenceUpdate
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
# Mitarbeiter: nur eigene; Manager: gleiche Company
|
||
if current_user.role == UserRole.EMPLOYEE:
|
||
if absence.user_id != current_user.id:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
else:
|
||
owner = await db.get(User, absence.user_id)
|
||
if owner is None or owner.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||
is_manager = current_user.role in _manager_roles
|
||
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
|
||
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
|
||
if absence.status == AbsenceStatus.APPROVED and not is_manager:
|
||
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
|
||
if not data.correction_note or not data.correction_note.strip():
|
||
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
|
||
|
||
if data.type_id is not None:
|
||
await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||
absence.type_id = data.type_id
|
||
if data.start_date is not None:
|
||
absence.start_date = data.start_date
|
||
if data.end_date is not None:
|
||
absence.end_date = data.end_date
|
||
if data.half_day_start is not None:
|
||
absence.half_day_start = data.half_day_start
|
||
if data.half_day_end is not None:
|
||
absence.half_day_end = data.half_day_end
|
||
if data.substitute_id is not None:
|
||
absence.substitute_id = data.substitute_id
|
||
if data.note is not None:
|
||
absence.note = data.note
|
||
if data.correction_note is not None:
|
||
absence.correction_note = data.correction_note.strip() or None
|
||
|
||
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
|
||
was_approved = absence.status == AbsenceStatus.APPROVED
|
||
if was_approved and not is_manager:
|
||
absence.status = AbsenceStatus.PENDING
|
||
absence.approved_by = None
|
||
|
||
# Arbeitstage neu berechnen
|
||
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
|
||
absence.working_days = Decimal(str(
|
||
self._calc_working_days(absence.start_date, absence.end_date,
|
||
holiday_dates, absence.half_day_start, absence.half_day_end)
|
||
))
|
||
|
||
# Audit-Log
|
||
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
|
||
db.add(AuditLog(
|
||
user_id=current_user.id,
|
||
action=action,
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "approved" if was_approved else "pending"},
|
||
new_value={
|
||
"status": absence.status.value,
|
||
"start_date": str(absence.start_date),
|
||
"end_date": str(absence.end_date),
|
||
"working_days": float(absence.working_days),
|
||
"correction_note": absence.correction_note,
|
||
},
|
||
))
|
||
return absence
|
||
|
||
async def cancel_absence(
|
||
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||
) -> Absence:
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
|
||
is_admin = current_user.role in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||
original_status = absence.status # vor Änderung merken
|
||
|
||
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.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, fza_hours=absence.fza_hours
|
||
)
|
||
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)
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_cancelled",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": original_status.value},
|
||
new_value={
|
||
"status": "cancelled",
|
||
"cancelled_by": str(current_user.id),
|
||
"cancelled_by_name": current_user.full_name,
|
||
"absence_user_id": str(absence.user_id),
|
||
"start_date": str(absence.start_date),
|
||
"end_date": str(absence.end_date),
|
||
"working_days": float(absence.working_days),
|
||
**({"fza_hours_refunded": True} if original_status == AbsenceStatus.APPROVED else {}),
|
||
},
|
||
))
|
||
|
||
from app.services.caldav_service import caldav_service
|
||
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
|
||
|
||
return absence
|
||
|
||
async def approve_absence(
|
||
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||
) -> Absence:
|
||
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
requester = await db.get(User, absence.user_id)
|
||
if requester is None or requester.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||
if absence.user_id == current_user.id:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
|
||
)
|
||
|
||
# Storno-Anfrage genehmigen → Antrag tatsächlich stornieren + Rückbuchung
|
||
if absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
|
||
await self._apply_cancellation(absence, current_user, db, from_request=True)
|
||
return absence, []
|
||
|
||
if absence.status != AbsenceStatus.PENDING:
|
||
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
|
||
|
||
absence.status = AbsenceStatus.APPROVED
|
||
absence.approved_by = current_user.id
|
||
|
||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||
|
||
# Urlaubskonto abziehen wenn nötig
|
||
if absence_type and absence_type.deducts_vacation:
|
||
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:
|
||
fza_warnings = await self._deduct_overtime(
|
||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||
)
|
||
|
||
# Audit-Log (DSGVO)
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_approved",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "pending"},
|
||
new_value={
|
||
"status": "approved",
|
||
"approved_by": str(current_user.id),
|
||
"approved_by_name": current_user.full_name,
|
||
"absence_user_id": str(absence.user_id),
|
||
"start_date": str(absence.start_date),
|
||
"end_date": str(absence.end_date),
|
||
"working_days": float(absence.working_days),
|
||
},
|
||
))
|
||
|
||
# CalDAV-Sync (fire & forget – Fehler blockieren nicht die Genehmigung)
|
||
from app.services.caldav_service import caldav_service
|
||
asyncio.create_task(caldav_service.sync_approved_bg(absence.id))
|
||
|
||
# Vertretung benachrichtigen
|
||
if absence.substitute_id:
|
||
await self._notify_substitute(absence, db)
|
||
|
||
return absence, fza_warnings
|
||
|
||
async def reject_absence(
|
||
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
|
||
) -> Absence:
|
||
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
requester = await db.get(User, absence.user_id)
|
||
if requester is None or requester.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||
|
||
# Storno-Anfrage ablehnen → Antrag bleibt genehmigt
|
||
if absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
|
||
absence.status = AbsenceStatus.APPROVED
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_cancellation_rejected",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "cancellation_requested"},
|
||
new_value={"status": "approved", "rejection_reason": data.rejection_reason,
|
||
"absence_user_id": str(absence.user_id)},
|
||
))
|
||
await self._add_system_comment(
|
||
absence, current_user.company_id, current_user.id,
|
||
f"Stornierung abgelehnt von {current_user.full_name}: {data.rejection_reason}", db,
|
||
)
|
||
return absence
|
||
|
||
if absence.status != AbsenceStatus.PENDING:
|
||
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
|
||
|
||
absence.status = AbsenceStatus.REJECTED
|
||
absence.approved_by = current_user.id
|
||
absence.rejection_reason = data.rejection_reason
|
||
|
||
# Audit-Log (DSGVO)
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_rejected",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "pending"},
|
||
new_value={
|
||
"status": "rejected",
|
||
"rejection_reason": absence.rejection_reason,
|
||
"rejected_by": str(current_user.id),
|
||
"rejected_by_name": current_user.full_name,
|
||
"absence_user_id": str(absence.user_id),
|
||
"start_date": str(absence.start_date),
|
||
"end_date": str(absence.end_date),
|
||
"working_days": float(absence.working_days),
|
||
},
|
||
))
|
||
|
||
from app.services.caldav_service import caldav_service
|
||
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
|
||
|
||
return absence
|
||
|
||
async def get_calendar(
|
||
self,
|
||
company_id: UUID,
|
||
year: int,
|
||
month: int | None,
|
||
db: AsyncSession,
|
||
) -> list[dict]:
|
||
q = (
|
||
select(Absence, User, AbsenceType)
|
||
.join(User, Absence.user_id == User.id)
|
||
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||
.where(
|
||
User.company_id == company_id,
|
||
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
|
||
)
|
||
)
|
||
if month:
|
||
start = date(year, month, 1)
|
||
end = date(year, month, 28) + timedelta(days=4)
|
||
end = end.replace(day=1) - timedelta(days=1)
|
||
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
|
||
else:
|
||
q = q.where(
|
||
Absence.start_date >= date(year, 1, 1),
|
||
Absence.end_date <= date(year, 12, 31),
|
||
)
|
||
|
||
result = await db.execute(q.order_by(Absence.start_date))
|
||
rows = result.all()
|
||
|
||
calendar = []
|
||
for absence, user, atype in rows:
|
||
calendar.append({
|
||
"user_id": user.id,
|
||
"user_name": user.full_name,
|
||
"absence_id": absence.id,
|
||
"type_name": atype.name,
|
||
"type_color": atype.color,
|
||
"start_date": absence.start_date,
|
||
"end_date": absence.end_date,
|
||
"status": absence.status,
|
||
"working_days": absence.working_days,
|
||
})
|
||
return calendar
|
||
|
||
# ── Urlaubskonto ──────────────────────────────────────────────────────────
|
||
|
||
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
|
||
return await self._get_or_create_balance(user_id, year, db)
|
||
|
||
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
|
||
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
|
||
q = (
|
||
select(func.sum(Absence.working_days))
|
||
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||
.where(
|
||
Absence.user_id == user_id,
|
||
Absence.status == AbsenceStatus.PENDING,
|
||
AbsenceType.deducts_vacation.is_(True),
|
||
func.extract("year", Absence.start_date) == year,
|
||
)
|
||
)
|
||
result = await db.scalar(q)
|
||
return float(result or 0)
|
||
|
||
# ── Feiertage ─────────────────────────────────────────────────────────────
|
||
|
||
async def list_holidays(
|
||
self, year: int, country: str, state: str | None, db: AsyncSession
|
||
) -> list[PublicHoliday]:
|
||
q = select(PublicHoliday).where(
|
||
PublicHoliday.year == year, PublicHoliday.country == country
|
||
)
|
||
if state:
|
||
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||
result = await db.scalars(q.order_by(PublicHoliday.date))
|
||
return list(result.all())
|
||
|
||
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
|
||
holiday = PublicHoliday(
|
||
country=data.country,
|
||
state=data.state,
|
||
date=data.date,
|
||
name=data.name,
|
||
year=data.date.year,
|
||
)
|
||
db.add(holiday)
|
||
await db.flush()
|
||
return holiday
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
async def _get_type_or_404(
|
||
self, type_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> AbsenceType:
|
||
at = await db.get(AbsenceType, type_id)
|
||
if at is None or at.company_id != company_id:
|
||
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
|
||
return at
|
||
|
||
async def _get_or_create_balance(
|
||
self, user_id: UUID, year: int, db: AsyncSession
|
||
) -> VacationBalance:
|
||
balance = await db.scalar(
|
||
select(VacationBalance).where(
|
||
VacationBalance.user_id == user_id, VacationBalance.year == year
|
||
)
|
||
)
|
||
if balance is None:
|
||
# Automatischer Übertrag: Resturlaub aus dem Vorjahr übernehmen
|
||
prev = await db.scalar(
|
||
select(VacationBalance).where(
|
||
VacationBalance.user_id == user_id, VacationBalance.year == year - 1
|
||
)
|
||
)
|
||
carried = max(0, prev.remaining_days) if prev else 0
|
||
# Grundanspruch frisch berechnen (Firmen-Default · Teilzeit · Pro-rata).
|
||
# Fallback auf Vorjahres-Anspruch bzw. 30, falls keine Konfig greift.
|
||
user = await db.get(User, user_id)
|
||
company = await db.get(Company, user.company_id) if user and user.company_id else None
|
||
entitled = await self._compute_entitlement(user, company, year, db)
|
||
if entitled is None:
|
||
entitled = prev.entitled_days if prev else 30
|
||
balance = VacationBalance(
|
||
user_id=user_id,
|
||
year=year,
|
||
entitled_days=entitled,
|
||
carried_over=carried,
|
||
)
|
||
db.add(balance)
|
||
await db.flush()
|
||
return balance
|
||
|
||
@staticmethod
|
||
def _working_days_per_week(schedule: WorkSchedule) -> int:
|
||
return sum(
|
||
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
|
||
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
|
||
if h and h > 0
|
||
)
|
||
|
||
async def _compute_entitlement(
|
||
self, user: "User | None", company: "Company | None", year: int, db: AsyncSession
|
||
) -> int | None:
|
||
"""Grundurlaub für ein neues Konto: Firmen-Default, optional Teilzeit-Skalierung
|
||
(Arbeitstage/Woche aus WorkSchedule) und anteilig im Ein-/Austrittsjahr (Zwölftel).
|
||
Gibt None zurück, wenn keine Berechnung möglich ist (→ Aufrufer nutzt Fallback)."""
|
||
if company is None:
|
||
return None
|
||
days = float(company.vacation_default_days)
|
||
|
||
# Teilzeit: Anspruch proportional zu den Arbeitstagen/Woche (5 = Vollzeit)
|
||
if company.vacation_from_schedule and user and user.work_schedule_id:
|
||
schedule = await db.get(WorkSchedule, user.work_schedule_id)
|
||
if schedule:
|
||
wd = self._working_days_per_week(schedule)
|
||
days = days * wd / 5.0
|
||
|
||
# Anteilig im Ein-/Austrittsjahr (Zwölftel ab/bis Monat)
|
||
if company.vacation_prorate_first_year and user:
|
||
months = 12
|
||
if user.entry_date:
|
||
if user.entry_date.year > year:
|
||
months = 0
|
||
elif user.entry_date.year == year:
|
||
months = min(months, 12 - user.entry_date.month + 1)
|
||
if user.exit_date:
|
||
if user.exit_date.year < year:
|
||
months = 0
|
||
elif user.exit_date.year == year:
|
||
months = min(months, user.exit_date.month)
|
||
days = days * max(0, months) / 12.0
|
||
|
||
# Kaufmännisch runden
|
||
return int(math.floor(days + 0.5))
|
||
|
||
@staticmethod
|
||
def _carryover_expired(company: "Company | None", year: int) -> tuple[date | None, bool]:
|
||
"""Verfallsdatum des Resturlaubs aus company.settings. (expires_at, is_expired)."""
|
||
s = (company.settings or {}) if company else {}
|
||
month, day = s.get("carryover_expires_month"), s.get("carryover_expires_day")
|
||
if not month or not day:
|
||
return None, False
|
||
try:
|
||
expires_at = date(year, int(month), int(day))
|
||
return expires_at, date.today() > expires_at
|
||
except (ValueError, TypeError):
|
||
return None, False
|
||
|
||
@staticmethod
|
||
def effective_available(balance: VacationBalance, expired: bool) -> int:
|
||
"""Effektiv verfügbare Urlaubstage. Bei verfallenem Resturlaub zählt der noch
|
||
nicht verbrauchte Teil des Übertrags nicht mehr mit."""
|
||
if expired:
|
||
return balance.entitled_days + balance.special_days - max(0, balance.used_days - balance.carried_over)
|
||
return balance.remaining_days
|
||
|
||
async def _deduct_vacation(
|
||
self, user_id: UUID, year: int, days: int, db: AsyncSession
|
||
) -> None:
|
||
balance = await self._get_or_create_balance(user_id, year, db)
|
||
balance.used_days += days
|
||
|
||
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:
|
||
schedule = await db.get(WorkSchedule, user.work_schedule_id)
|
||
if schedule:
|
||
working_days_in_week = sum(
|
||
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
|
||
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
|
||
if h > 0
|
||
)
|
||
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,
|
||
fza_hours: "Decimal | None" = None,
|
||
) -> list[str]:
|
||
"""Zieht working_days × tägliche Stunden (oder direkt fza_hours) vom Überstundenkonto ab.
|
||
Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
|
||
user = await db.get(User, user_id)
|
||
if fza_hours is not None:
|
||
hours_to_deduct = Decimal(str(fza_hours))
|
||
else:
|
||
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:
|
||
company_id = user.company_id if user else None
|
||
if not company_id:
|
||
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,
|
||
fza_hours: "Decimal | None" = None,
|
||
) -> None:
|
||
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
|
||
if fza_hours is not None:
|
||
hours_to_refund = Decimal(str(fza_hours))
|
||
else:
|
||
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
|
||
) -> set[date]:
|
||
"""Feiertage für die Company-Country holen."""
|
||
from app.models.company import Company
|
||
from sqlalchemy import or_
|
||
|
||
company = await db.get(Company, company_id)
|
||
country = company.country if company else "DE"
|
||
state = company.state if company else None
|
||
|
||
q = select(PublicHoliday.date).where(
|
||
PublicHoliday.year == year,
|
||
PublicHoliday.country == country,
|
||
)
|
||
if state:
|
||
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||
|
||
result = await db.scalars(q)
|
||
return set(result.all())
|
||
|
||
@staticmethod
|
||
def _calc_working_days(
|
||
start: date,
|
||
end: date,
|
||
holidays: set[date],
|
||
half_day_start: bool,
|
||
half_day_end: bool,
|
||
) -> float:
|
||
count = 0.0
|
||
current = start
|
||
while current <= end:
|
||
if current.weekday() < 5 and current not in holidays:
|
||
count += 1.0
|
||
current += timedelta(days=1)
|
||
# Halbtage abziehen
|
||
if half_day_start and start.weekday() < 5 and start not in holidays:
|
||
count -= 0.5
|
||
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
|
||
count -= 0.5
|
||
return max(0.0, count)
|
||
|
||
# ── Krankmeldung ──────────────────────────────────────────────────────────
|
||
|
||
async def quick_sick(
|
||
self,
|
||
start: date,
|
||
end: date,
|
||
current_user: User,
|
||
db: AsyncSession,
|
||
) -> tuple[Absence, list[str]]:
|
||
"""Sofort-Krankmeldung: nutzt den ersten aktiven SICK-Typ der Firma."""
|
||
sick_type = await db.scalar(
|
||
select(AbsenceType)
|
||
.where(
|
||
AbsenceType.company_id == current_user.company_id,
|
||
AbsenceType.category == AbsenceCategory.SICK,
|
||
AbsenceType.is_active == True,
|
||
)
|
||
.order_by(AbsenceType.name)
|
||
.limit(1)
|
||
)
|
||
if sick_type is None:
|
||
raise HTTPException(status_code=404, detail="Kein aktiver Krankheits-Typ konfiguriert.")
|
||
if end < start:
|
||
raise HTTPException(status_code=400, detail="Enddatum darf nicht vor dem Startdatum liegen.")
|
||
|
||
create_data = AbsenceCreate(
|
||
type_id=sick_type.id,
|
||
start_date=start,
|
||
end_date=end,
|
||
)
|
||
return await self.create_absence(create_data, current_user, db)
|
||
|
||
async def mark_certificate_received(
|
||
self,
|
||
absence_id: UUID,
|
||
received_at: date | None,
|
||
current_user: User,
|
||
db: AsyncSession,
|
||
) -> Absence:
|
||
"""HR/Admin: AU-Bescheinigung als eingegangen markieren."""
|
||
if current_user.role not in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||
raise HTTPException(status_code=403, detail="Nur HR/Admin darf den Attest-Eingang markieren.")
|
||
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
owner = await db.get(User, absence.user_id)
|
||
if owner is None or owner.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||
|
||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||
if absence_type is None or absence_type.category != AbsenceCategory.SICK:
|
||
raise HTTPException(status_code=409, detail="Nur für Krankmeldungen verfügbar.")
|
||
|
||
old_value = str(absence.certificate_received_at) if absence.certificate_received_at else None
|
||
absence.certificate_received_at = received_at or date.today()
|
||
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_certificate_received",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"certificate_received_at": old_value},
|
||
new_value={
|
||
"certificate_received_at": str(absence.certificate_received_at),
|
||
"absence_user_id": str(absence.user_id),
|
||
"marked_by": str(current_user.id),
|
||
"marked_by_name": current_user.full_name,
|
||
},
|
||
))
|
||
return absence
|
||
|
||
async def get_sick_stats(
|
||
self,
|
||
company_id: UUID,
|
||
current_user: User,
|
||
ref_date: date,
|
||
db: AsyncSession,
|
||
user_id: UUID | None = None,
|
||
) -> list[dict]:
|
||
"""Krankheitsstatistik für rolling 12 Monate ab ref_date.
|
||
|
||
Bradford-Faktor: S² × D mit S = Anzahl Episoden, D = Summe Kranktage.
|
||
"""
|
||
window_start = ref_date - timedelta(days=365)
|
||
|
||
q = (
|
||
select(Absence, User)
|
||
.join(User, Absence.user_id == User.id)
|
||
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||
.where(
|
||
User.company_id == company_id,
|
||
AbsenceType.category == AbsenceCategory.SICK,
|
||
Absence.status == AbsenceStatus.APPROVED,
|
||
Absence.start_date <= ref_date,
|
||
Absence.end_date >= window_start,
|
||
)
|
||
.order_by(User.last_name, User.first_name, Absence.start_date)
|
||
)
|
||
if user_id:
|
||
q = q.where(Absence.user_id == user_id)
|
||
# MANAGER sieht nur sein Department
|
||
if current_user.role == UserRole.MANAGER and current_user.department_id:
|
||
q = q.where(User.department_id == current_user.department_id)
|
||
|
||
result = await db.execute(q)
|
||
rows = result.all()
|
||
|
||
by_user: dict[UUID, dict] = {}
|
||
for absence, user in rows:
|
||
entry = by_user.setdefault(user.id, {
|
||
"user_id": user.id,
|
||
"user_name": user.full_name,
|
||
"personnel_number": user.personnel_number,
|
||
"episodes": 0,
|
||
"total_days": 0.0,
|
||
"certificates_overdue": 0,
|
||
})
|
||
entry["episodes"] += 1
|
||
entry["total_days"] += float(absence.working_days or 0)
|
||
if (
|
||
absence.certificate_required_by
|
||
and absence.certificate_required_by < ref_date
|
||
and absence.certificate_received_at is None
|
||
):
|
||
entry["certificates_overdue"] += 1
|
||
|
||
for entry in by_user.values():
|
||
entry["bradford_factor"] = float(entry["episodes"]) ** 2 * entry["total_days"]
|
||
|
||
return list(by_user.values())
|
||
|
||
# ── Stornierung mit Re-Genehmigung ──────────────────────────────────────────
|
||
|
||
async def request_cancellation(
|
||
self, absence_id: UUID, reason: str | None, current_user: User, db: AsyncSession
|
||
) -> Absence:
|
||
"""Mitarbeiter beantragt die Stornierung eines bereits GENEHMIGTEN Antrags.
|
||
|
||
HR/Admin storniert weiterhin direkt (über cancel_absence). Diese Anfrage
|
||
setzt den Status auf CANCELLATION_REQUESTED → Manager muss zustimmen.
|
||
"""
|
||
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:
|
||
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
|
||
if absence.status != AbsenceStatus.APPROVED:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Nur genehmigte Anträge können zur Stornierung eingereicht werden.",
|
||
)
|
||
|
||
absence.status = AbsenceStatus.CANCELLATION_REQUESTED
|
||
|
||
db.add(AuditLog(
|
||
company_id=current_user.company_id,
|
||
user_id=current_user.id,
|
||
action="absence_cancellation_requested",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "approved"},
|
||
new_value={"status": "cancellation_requested", "reason": reason,
|
||
"absence_user_id": str(absence.user_id)},
|
||
))
|
||
body = "Stornierung beantragt" + (f": {reason}" if reason else ".")
|
||
await self._add_system_comment(absence, current_user.company_id, current_user.id, body, db)
|
||
return absence
|
||
|
||
async def _apply_cancellation(
|
||
self, absence: Absence, actor: User, db: AsyncSession, from_request: bool,
|
||
) -> None:
|
||
"""Genehmigten Antrag tatsächlich stornieren inkl. Rückbuchung (Urlaub + FZA)."""
|
||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||
if absence_type and absence_type.deducts_vacation:
|
||
await self._refund_vacation(
|
||
absence.user_id, absence.start_date.year, int(absence.working_days), db
|
||
)
|
||
if absence_type and absence_type.affects_overtime_balance:
|
||
await self._refund_overtime(
|
||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||
)
|
||
|
||
absence.status = AbsenceStatus.CANCELLED
|
||
|
||
db.add(AuditLog(
|
||
company_id=actor.company_id,
|
||
user_id=actor.id,
|
||
action="absence_cancellation_approved" if from_request else "absence_cancelled",
|
||
entity_type="absence",
|
||
entity_id=absence.id,
|
||
old_value={"status": "cancellation_requested" if from_request else "approved"},
|
||
new_value={
|
||
"status": "cancelled",
|
||
"cancelled_by": str(actor.id),
|
||
"cancelled_by_name": actor.full_name,
|
||
"absence_user_id": str(absence.user_id),
|
||
"working_days": float(absence.working_days),
|
||
},
|
||
))
|
||
await self._add_system_comment(
|
||
absence, actor.company_id, actor.id,
|
||
f"Stornierung genehmigt von {actor.full_name}.", db,
|
||
)
|
||
|
||
from app.services.caldav_service import caldav_service
|
||
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
|
||
|
||
async def _refund_vacation(
|
||
self, user_id: UUID, year: int, days: int, db: AsyncSession
|
||
) -> None:
|
||
balance = await db.scalar(
|
||
select(VacationBalance).where(
|
||
VacationBalance.user_id == user_id, VacationBalance.year == year
|
||
)
|
||
)
|
||
if balance is not None:
|
||
balance.used_days = max(0, balance.used_days - days)
|
||
|
||
async def _notify_substitute(self, absence: Absence, db: AsyncSession) -> None:
|
||
"""Eingetragene Vertretung über die genehmigte Abwesenheit informieren."""
|
||
if not absence.substitute_id:
|
||
return
|
||
substitute = await db.get(User, absence.substitute_id)
|
||
requester = await db.get(User, absence.user_id)
|
||
if substitute is None or requester is None or not substitute.email:
|
||
return
|
||
from app.core.notifications import pref_enabled
|
||
if not pref_enabled(substitute, "substitute_assigned"):
|
||
return
|
||
from app.services.email_service import email_service
|
||
try:
|
||
await email_service.send_substitute_notification(substitute, requester, absence, db)
|
||
except Exception as exc: # Mailfehler dürfen die Genehmigung nicht blockieren
|
||
print(f"Vertreter-Benachrichtigung fehlgeschlagen: {exc}")
|
||
|
||
# ── Kommentare ──────────────────────────────────────────────────────────────
|
||
|
||
async def _add_system_comment(
|
||
self, absence: Absence, company_id: UUID, author_id: UUID | None, body: str, db: AsyncSession
|
||
) -> None:
|
||
from app.models.absence_comment import AbsenceComment
|
||
db.add(AbsenceComment(
|
||
absence_id=absence.id, company_id=company_id,
|
||
author_id=author_id, body=body, is_system=True,
|
||
))
|
||
|
||
async def _assert_comment_access(
|
||
self, absence: Absence, current_user: User, db: AsyncSession
|
||
) -> None:
|
||
"""Sichtbar für: Antragsteller, eingetragene Vertretung, Manager-Rollen der Firma."""
|
||
if current_user.role in _manager_roles:
|
||
owner = await db.get(User, absence.user_id)
|
||
if owner is None or owner.company_id != current_user.company_id:
|
||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||
return
|
||
if current_user.id in (absence.user_id, absence.substitute_id):
|
||
return
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
|
||
async def list_comments(
|
||
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||
) -> list:
|
||
from app.models.absence_comment import AbsenceComment
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
await self._assert_comment_access(absence, current_user, db)
|
||
rows = (await db.execute(
|
||
select(AbsenceComment, User)
|
||
.outerjoin(User, AbsenceComment.author_id == User.id)
|
||
.where(AbsenceComment.absence_id == absence_id)
|
||
.order_by(AbsenceComment.created_at)
|
||
)).all()
|
||
result = []
|
||
for comment, author in rows:
|
||
result.append({
|
||
"id": comment.id, "absence_id": comment.absence_id,
|
||
"author_id": comment.author_id,
|
||
"author_name": author.full_name if author else None,
|
||
"body": comment.body, "is_system": comment.is_system,
|
||
"created_at": comment.created_at,
|
||
})
|
||
return result
|
||
|
||
async def add_comment(
|
||
self, absence_id: UUID, body: str, current_user: User, db: AsyncSession
|
||
) -> dict:
|
||
from app.models.absence_comment import AbsenceComment
|
||
absence = await db.get(Absence, absence_id)
|
||
if absence is None:
|
||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||
await self._assert_comment_access(absence, current_user, db)
|
||
comment = AbsenceComment(
|
||
absence_id=absence_id, company_id=current_user.company_id,
|
||
author_id=current_user.id, body=body.strip(), is_system=False,
|
||
)
|
||
db.add(comment)
|
||
await db.flush()
|
||
return {
|
||
"id": comment.id, "absence_id": comment.absence_id,
|
||
"author_id": comment.author_id, "author_name": current_user.full_name,
|
||
"body": comment.body, "is_system": comment.is_system,
|
||
"created_at": comment.created_at,
|
||
}
|
||
|
||
|
||
absence_service = AbsenceService()
|