feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):
- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
Session + RLS-Bypass.
Migration 0035. 178/178 Tests grün. Deployed auf 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -108,13 +108,17 @@ class AbsenceService:
|
||||
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 current_user.role == UserRole.EMPLOYEE:
|
||||
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)
|
||||
@@ -185,6 +189,28 @@ class AbsenceService:
|
||||
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
|
||||
|
||||
@@ -215,9 +241,12 @@ class AbsenceService:
|
||||
db.add(absence)
|
||||
await db.flush()
|
||||
|
||||
# Bei automatischer Genehmigung Konto abziehen
|
||||
if not absence_type.requires_approval and absence_type.deducts_vacation:
|
||||
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
|
||||
# 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
|
||||
|
||||
@@ -347,7 +376,7 @@ class AbsenceService:
|
||||
))
|
||||
|
||||
from app.services.caldav_service import caldav_service
|
||||
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
|
||||
|
||||
return absence
|
||||
|
||||
@@ -368,6 +397,12 @@ class AbsenceService:
|
||||
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.")
|
||||
|
||||
@@ -408,7 +443,11 @@ class AbsenceService:
|
||||
|
||||
# CalDAV-Sync (fire & forget – Fehler blockieren nicht die Genehmigung)
|
||||
from app.services.caldav_service import caldav_service
|
||||
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
||||
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
|
||||
|
||||
@@ -424,6 +463,26 @@ class AbsenceService:
|
||||
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.")
|
||||
|
||||
@@ -452,7 +511,7 @@ class AbsenceService:
|
||||
))
|
||||
|
||||
from app.services.caldav_service import caldav_service
|
||||
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
|
||||
|
||||
return absence
|
||||
|
||||
@@ -845,5 +904,176 @@ class AbsenceService:
|
||||
|
||||
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.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()
|
||||
|
||||
Reference in New Issue
Block a user