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:
2026-06-23 11:47:47 +02:00
co-authored by Claude Opus 4.8
parent be22a51805
commit 6fa66b8c13
18 changed files with 793 additions and 27 deletions
+43
View File
@@ -12,12 +12,15 @@ from app.schemas.absence import (
AbsenceCreate,
AbsenceListResponse,
AbsenceOut,
AbsenceCommentCreate,
AbsenceCommentOut,
AbsenceReject,
AbsenceUpdate,
AbsenceTypeCreate,
AbsenceTypeOut,
AbsenceTypeUpdate,
CalendarEntry,
CancellationRequest,
CertificateMarkIn,
OvertimeBalanceOut,
PublicHolidayCreate,
@@ -256,11 +259,13 @@ async def list_absences(
type_id: UUID | None = Query(None),
status: AbsenceStatus | None = Query(None),
year: int | None = Query(None),
as_substitute: bool = Query(False),
db: AsyncSession = Depends(get_db),
):
total, absences = await absence_service.list_absences(
current_user.company_id, current_user, db,
user_id=user_id, type_id=type_id, status=status, year=year,
as_substitute=as_substitute,
)
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
@@ -325,6 +330,44 @@ async def cancel_absence(
return AbsenceOut.model_validate(absence)
@router.post("/absences/{absence_id}/request-cancellation", response_model=AbsenceOut)
async def request_cancellation(
absence_id: UUID,
data: CancellationRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter beantragt Stornierung eines genehmigten Antrags (Manager genehmigt/lehnt ab).
HR/Admin storniert weiterhin direkt über DELETE."""
absence = await absence_service.request_cancellation(absence_id, data.reason, current_user, db)
await db.commit()
return AbsenceOut.model_validate(absence)
# ── Kommentare ────────────────────────────────────────────────────────────────
@router.get("/absences/{absence_id}/comments", response_model=list[AbsenceCommentOut])
async def list_absence_comments(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
return await absence_service.list_comments(absence_id, current_user, db)
@router.post("/absences/{absence_id}/comments", response_model=AbsenceCommentOut, status_code=201)
async def add_absence_comment(
absence_id: UUID,
data: AbsenceCommentCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
comment = await absence_service.add_comment(absence_id, data.body, current_user, db)
await db.commit()
return comment
class AbsenceApproveOut(AbsenceOut):
warnings: list[str] = []
+21
View File
@@ -53,6 +53,27 @@ async def invite_user(
return UserOut.model_validate(user)
@router.get("/colleagues")
async def list_colleagues(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Schlanke Kollegenliste (id + Name) für alle Mitarbeiter z.B. zur
Vertreter-Auswahl. Nur aktive User der eigenen Firma (RLS-gefenced),
ohne sensible Felder."""
from sqlalchemy import select
rows = await db.scalars(
select(User)
.where(
User.company_id == current_user.company_id,
User.is_active.is_(True),
User.id != current_user.id,
)
.order_by(User.last_name, User.first_name)
)
return [{"id": str(u.id), "full_name": u.full_name} for u in rows.all()]
@router.get("/me", response_model=UserOut)
async def get_me(current_user: CurrentUser):
return UserOut.model_validate(current_user)