Files

247 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CRUD für Sondervertretungs-Zeiträume (Special Assignments)."""
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.special_assignment import AssignmentMode, SpecialAssignment
from app.models.time_entry import EntryStatus, TimeEntry
from app.models.user import User, UserRole
from app.schemas.special_assignment import (
PayrollAssignmentEntry,
PayrollAssignmentReport,
PayrollAssignmentRow,
SpecialAssignmentCreate,
SpecialAssignmentOut,
SpecialAssignmentUpdate,
)
router = APIRouter(tags=["Sondervertretungen"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
async def _get_assignment_or_404(
assignment_id: UUID, company_id: UUID, db: AsyncSession
) -> SpecialAssignment:
sa = await db.get(SpecialAssignment, assignment_id)
if sa is None or sa.company_id != company_id:
raise HTTPException(404, "Zuweisung nicht gefunden")
return sa
async def _check_overlap(
user_id: UUID,
date_from: date,
date_to: date,
db: AsyncSession,
exclude_id: UUID | None = None,
) -> None:
"""Prüft ob sich der Zeitraum mit einer vorhandenen Zuweisung überschneidet."""
q = select(SpecialAssignment).where(
SpecialAssignment.user_id == user_id,
SpecialAssignment.date_from <= date_to,
SpecialAssignment.date_to >= date_from,
)
if exclude_id:
q = q.where(SpecialAssignment.id != exclude_id)
existing = await db.scalar(q)
if existing:
raise HTTPException(
409,
f"Zeitraum überschneidet sich mit vorhandener Zuweisung "
f"({existing.date_from}{existing.date_to})",
)
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/users/{user_id}/special-assignments", response_model=list[SpecialAssignmentOut])
async def list_assignments(
user_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
target = await db.get(User, user_id)
if not target or target.company_id != current_user.company_id:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
rows = list(await db.scalars(
select(SpecialAssignment)
.where(SpecialAssignment.user_id == user_id)
.order_by(SpecialAssignment.date_from)
))
return [SpecialAssignmentOut.model_validate(r) for r in rows]
@router.post("/users/{user_id}/special-assignments", response_model=SpecialAssignmentOut, status_code=201)
async def create_assignment(
user_id: UUID,
data: SpecialAssignmentCreate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
target = await db.get(User, user_id)
if not target or target.company_id != current_user.company_id:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
await _check_overlap(user_id, data.date_from, data.date_to, db)
sa = SpecialAssignment(
user_id=user_id,
company_id=current_user.company_id,
**data.model_dump(),
)
db.add(sa)
await db.commit()
await db.refresh(sa)
return SpecialAssignmentOut.model_validate(sa)
@router.patch("/users/{user_id}/special-assignments/{assignment_id}", response_model=SpecialAssignmentOut)
async def update_assignment(
user_id: UUID,
assignment_id: UUID,
data: SpecialAssignmentUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
sa = await _get_assignment_or_404(assignment_id, current_user.company_id, db)
if sa.user_id != user_id:
raise HTTPException(404, "Zuweisung nicht gefunden")
updates = data.model_dump(exclude_unset=True)
new_from = updates.get("date_from", sa.date_from)
new_to = updates.get("date_to", sa.date_to)
if new_from > new_to:
raise HTTPException(422, "date_from darf nicht nach date_to liegen")
await _check_overlap(user_id, new_from, new_to, db, exclude_id=assignment_id)
for field, value in updates.items():
setattr(sa, field, value)
await db.commit()
await db.refresh(sa)
return SpecialAssignmentOut.model_validate(sa)
@router.delete("/users/{user_id}/special-assignments/{assignment_id}", status_code=204)
async def delete_assignment(
user_id: UUID,
assignment_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
sa = await _get_assignment_or_404(assignment_id, current_user.company_id, db)
if sa.user_id != user_id:
raise HTTPException(404, "Zuweisung nicht gefunden")
await db.delete(sa)
await db.commit()
# ── Payroll-Report ────────────────────────────────────────────────────────────
@router.get("/reports/special-assignments/payroll", response_model=PayrollAssignmentReport)
async def payroll_report(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Payroll-Report: Für jeden Mitarbeiter die Sondervertretungs-Stunden im Monat."""
from calendar import monthrange
first_day = date(year, month, 1)
last_day = date(year, month, monthrange(year, month)[1])
# Alle Zuweisungen der Firma, die den Monat überschneiden
assignments = list(await db.scalars(
select(SpecialAssignment).where(
SpecialAssignment.company_id == current_user.company_id,
SpecialAssignment.mode.in_([AssignmentMode.payroll, AssignmentMode.both]),
SpecialAssignment.date_from <= last_day,
SpecialAssignment.date_to >= first_day,
).order_by(SpecialAssignment.user_id, SpecialAssignment.date_from)
))
if not assignments:
return PayrollAssignmentReport(year=year, month=month, rows=[])
# User-IDs ermitteln
user_ids = list({a.user_id for a in assignments})
users_map: dict[UUID, User] = {}
for uid in user_ids:
u = await db.get(User, uid)
if u:
users_map[uid] = u
# Genehmigte Zeit-Einträge für betroffene User im Monat laden
entries = list(await db.scalars(
select(TimeEntry).where(
TimeEntry.user_id.in_(user_ids),
TimeEntry.date >= first_day,
TimeEntry.date <= last_day,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.isnot(None),
)
))
# Gruppierung: user_id → list[TimeEntry]
entries_by_user: dict[UUID, list[TimeEntry]] = {}
for e in entries:
entries_by_user.setdefault(e.user_id, []).append(e)
rows: list[PayrollAssignmentRow] = []
# Groupby user_id
from itertools import groupby
sorted_assignments = sorted(assignments, key=lambda a: a.user_id)
for uid, user_assignments_iter in groupby(sorted_assignments, key=lambda a: a.user_id):
user_assignments = list(user_assignments_iter)
user = users_map.get(uid)
if not user:
continue
user_entries = entries_by_user.get(uid, [])
# Index: date → worked_hours
hours_by_date: dict[date, float] = {}
for e in user_entries:
hours_by_date[e.date] = hours_by_date.get(e.date, 0.0) + (e.worked_hours or 0.0)
assignment_entries: list[PayrollAssignmentEntry] = []
for sa in user_assignments:
# Effektiver Zeitraum innerhalb des Monats
eff_from = max(sa.date_from, first_day)
eff_to = min(sa.date_to, last_day)
# Stunden im Zeitraum summieren
normal_hours = sum(
hours for d, hours in hours_by_date.items()
if eff_from <= d <= eff_to
)
factor_hours = round(normal_hours * float(sa.factor), 2)
extra_hours = round(factor_hours - normal_hours, 2)
assignment_entries.append(PayrollAssignmentEntry(
assignment_id=sa.id,
label=sa.label,
date_from=eff_from,
date_to=eff_to,
factor=sa.factor,
normal_hours=round(normal_hours, 2),
factor_hours=factor_hours,
extra_hours=extra_hours,
))
total_normal = sum(e.normal_hours for e in assignment_entries)
total_factor = sum(e.factor_hours for e in assignment_entries)
rows.append(PayrollAssignmentRow(
user_id=uid,
user_name=f"{user.first_name} {user.last_name}",
personnel_number=getattr(user, "personnel_number", None),
assignments=assignment_entries,
total_normal_hours=round(total_normal, 2),
total_factor_hours=round(total_factor, 2),
total_extra_hours=round(total_factor - total_normal, 2),
))
return PayrollAssignmentReport(year=year, month=month, rows=rows)