from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import CurrentUser, get_client_ip, require_role from app.models.absence import AbsenceStatus from app.models.user import User, UserRole from app.models.overtime_balance import OvertimeBalance from app.schemas.absence import ( AbsenceCreate, AbsenceListResponse, AbsenceOut, AbsenceReject, AbsenceUpdate, AbsenceTypeCreate, AbsenceTypeOut, AbsenceTypeUpdate, CalendarEntry, CertificateMarkIn, OvertimeBalanceOut, PublicHolidayCreate, PublicHolidayOut, QuickSickIn, SickStatsOut, VacationBalanceOut, VacationBalanceUpdate, ) from app.services.absence_service import absence_service from app.models.company import Company from sqlalchemy import select from datetime import date router = APIRouter(tags=["Abwesenheiten"]) def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]: """Verfallsdatum für Resturlaub berechnen. Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert.""" s = company.settings or {} month = s.get("carryover_expires_month") day = 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: return None, False _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) _manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) # ── Absence Types ───────────────────────────────────────────────────────────── @router.get("/absence-types/", response_model=list[AbsenceTypeOut]) async def list_absence_types( current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): types = await absence_service.list_types(current_user.company_id, db) return [AbsenceTypeOut.model_validate(t) for t in types] @router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201) async def create_absence_type( data: AbsenceTypeCreate, current_user: User = require_role(*_admin_roles), db: AsyncSession = Depends(get_db), ): at = await absence_service.create_type(current_user.company_id, data, db) await db.commit() return AbsenceTypeOut.model_validate(at) @router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut) async def update_absence_type( type_id: UUID, data: AbsenceTypeUpdate, current_user: User = require_role(*_admin_roles), db: AsyncSession = Depends(get_db), ): at = await absence_service.update_type(type_id, current_user.company_id, data, db) await db.commit() return AbsenceTypeOut.model_validate(at) # ── Public Holidays ─────────────────────────────────────────────────────────── @router.get("/public-holidays/", response_model=list[PublicHolidayOut]) async def list_public_holidays( current_user: CurrentUser, year: int = Query(...), country: str = Query("DE"), state: str | None = Query(None), db: AsyncSession = Depends(get_db), ): holidays = await absence_service.list_holidays(year, country, state, db) return [PublicHolidayOut.model_validate(h) for h in holidays] @router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201) async def create_public_holiday( data: PublicHolidayCreate, current_user: User = require_role(*_admin_roles), db: AsyncSession = Depends(get_db), ): holiday = await absence_service.create_holiday(data, db) await db.commit() return PublicHolidayOut.model_validate(holiday) # ── Absences ────────────────────────────────────────────────────────────────── @router.get("/absences/calendar", response_model=list[CalendarEntry]) async def get_calendar( current_user: CurrentUser, year: int = Query(...), month: int | None = Query(None, ge=1, le=12), db: AsyncSession = Depends(get_db), ): """Team-Kalender: alle Abwesenheiten im Zeitraum.""" entries = await absence_service.get_calendar(current_user.company_id, year, month, db) return [CalendarEntry(**e) for e in entries] @router.get("/absences/balance", response_model=VacationBalanceOut) async def get_own_balance( current_user: CurrentUser, year: int = Query(...), db: AsyncSession = Depends(get_db), ): """Eigenes Urlaubskonto.""" balance = await absence_service.get_balance(current_user.id, year, db) pending = await absence_service.get_pending_days(current_user.id, year, db) company = await db.get(Company, current_user.company_id) expires_at, expired = _carryover_expiry(company, year) if company else (None, False) return VacationBalanceOut.model_validate(balance).model_copy(update={ "pending_days": pending, "carried_over_expires_at": expires_at, "carried_over_expired": expired, }) @router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut) async def get_balance_for_user( user_id: UUID, year: int = Query(...), current_user: User = require_role(*_manager_roles), db: AsyncSession = Depends(get_db), ): """Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN).""" target_user = await db.get(User, user_id) if target_user is None or target_user.company_id != current_user.company_id: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.") balance = await absence_service.get_balance(user_id, year, db) pending = await absence_service.get_pending_days(user_id, year, db) company = await db.get(Company, current_user.company_id) expires_at, expired = _carryover_expiry(company, year) if company else (None, False) return VacationBalanceOut.model_validate(balance).model_copy(update={ "pending_days": pending, "carried_over_expires_at": expires_at, "carried_over_expired": expired, }) @router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201) async def quick_sick( data: QuickSickIn, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma.""" absence, _ = await absence_service.quick_sick( data.start_date, data.end_date, current_user, db ) await db.commit() return AbsenceOut.model_validate(absence) @router.get("/absences/sick-stats", response_model=list[SickStatsOut]) async def get_sick_stats( current_user: User = require_role(*_manager_roles), user_id: UUID | None = Query(None), ref_date: date | None = Query(None, description="Stichtag, default heute"), db: AsyncSession = Depends(get_db), ): """Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor.""" stats = await absence_service.get_sick_stats( company_id=current_user.company_id, current_user=current_user, ref_date=ref_date or date.today(), db=db, user_id=user_id, ) return [SickStatsOut(**s) for s in stats] @router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut) async def get_overtime_balance( current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Eigenes Überstunden-Konto.""" bal = await db.scalar( select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id) ) if bal is None: return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0) # Verfall anwenden wenn nötig from app.services.report_service import apply_overtime_expiry_if_needed from app.models.company import Company as CompanyModel company = await db.get(CompanyModel, current_user.company_id) changed = await apply_overtime_expiry_if_needed(bal, company, db) if changed: await db.commit() return OvertimeBalanceOut( total_hours=float(bal.total_hours), taken_hours=float(bal.taken_hours), available_hours=float(bal.available_hours), ) @router.post("/absences/overtime-balance/apply-expiry") async def apply_overtime_expiry_all( current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN), db: AsyncSession = Depends(get_db), ): """Überstunden-Verfall manuell für alle Mitarbeiter der Firma anwenden.""" from sqlalchemy import select as sa_select from app.models.company import Company as CompanyModel from app.services.report_service import apply_overtime_expiry_if_needed company = await db.get(CompanyModel, current_user.company_id) balances = list(await db.scalars( sa_select(OvertimeBalance).where(OvertimeBalance.company_id == current_user.company_id) )) applied_count = 0 for bal in balances: changed = await apply_overtime_expiry_if_needed(bal, company, db) if changed: applied_count += 1 await db.commit() return {"applied_to": applied_count, "total": len(balances)} @router.get("/absences/", response_model=AbsenceListResponse) async def list_absences( current_user: CurrentUser, user_id: UUID | None = Query(None), type_id: UUID | None = Query(None), status: AbsenceStatus | None = Query(None), year: int | None = Query(None), 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, ) return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences]) @router.post("/absences/", response_model=AbsenceOut, status_code=201) async def create_absence( data: AbsenceCreate, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen.""" acting_user = current_user if data.for_user_id and data.for_user_id != current_user.id: if current_user.role not in _manager_roles: from fastapi import HTTPException raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.") target = await db.get(User, data.for_user_id) if not target or target.company_id != current_user.company_id: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.") acting_user = target absence, warnings = await absence_service.create_absence(data, acting_user, db) await db.commit() return AbsenceOut.model_validate(absence) @router.patch("/absences/{absence_id}", response_model=AbsenceOut) async def update_absence( absence_id: UUID, data: AbsenceUpdate, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company).""" absence = await absence_service.update_absence(absence_id, data, current_user, db) await db.commit() return AbsenceOut.model_validate(absence) @router.get("/absences/{absence_id}", response_model=AbsenceOut) async def get_absence( absence_id: UUID, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): absence = await absence_service.get_by_id(absence_id, current_user, db) return AbsenceOut.model_validate(absence) @router.delete("/absences/{absence_id}", response_model=AbsenceOut) async def cancel_absence( absence_id: UUID, current_user: CurrentUser, db: AsyncSession = Depends(get_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) 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, warnings = await absence_service.approve_absence(absence_id, current_user, db) await db.commit() out = AbsenceApproveOut.model_validate(absence) out.warnings = warnings return out @router.post("/absences/{absence_id}/reject", response_model=AbsenceOut) async def reject_absence( absence_id: UUID, data: AbsenceReject, current_user: User = require_role(*_manager_roles), db: AsyncSession = Depends(get_db), ): absence = await absence_service.reject_absence(absence_id, data, current_user, db) await db.commit() return AbsenceOut.model_validate(absence) @router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut) async def mark_certificate_received( absence_id: UUID, data: CertificateMarkIn, current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN), db: AsyncSession = Depends(get_db), ): """HR/Admin markiert die AU-Bescheinigung als eingegangen.""" absence = await absence_service.mark_certificate_received( absence_id, data.received_at, current_user, db ) await db.commit() return AbsenceOut.model_validate(absence) # ── Urlaubskonto bearbeiten (HR/Admin) ──────────────────────────────────────── @router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut) async def update_balance( user_id: UUID, data: VacationBalanceUpdate, request: Request, current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN), db: AsyncSession = Depends(get_db), year: int = Query(...), ): """Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen.""" from app.models.vacation_balance import VacationBalance from app.models.audit_log import AuditLog target = await db.get(User, user_id) if target is None or target.company_id != current_user.company_id: from fastapi import HTTPException raise HTTPException(404, "Mitarbeiter nicht gefunden") balance = await absence_service.get_balance(user_id, year, db) # Alte Werte für AuditLog sichern old_base = balance.base_days old_special = balance.special_days old_carried = balance.carried_over_days for field, value in data.model_dump(exclude_unset=True).items(): setattr(balance, field, value) # AuditLog schreiben db.add(AuditLog( user_id=current_user.id, action="update_vacation_balance", entity_type="vacation_balance", entity_id=balance.id, old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried}, new_value={ "base_days": balance.base_days, "special_days": balance.special_days, "carried_over_days": balance.carried_over_days, "target_user_id": str(user_id), "year": year, }, ip_address=get_client_ip(request), )) await db.commit() pending = await absence_service.get_pending_days(user_id, year, db) company = await db.get(Company, current_user.company_id) expires_at, expired = _carryover_expiry(company, year) if company else (None, False) return VacationBalanceOut.model_validate(balance).model_copy(update={ "pending_days": pending, "carried_over_expires_at": expires_at, "carried_over_expired": expired, })