0dd736c220
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.2 KiB
Python
247 lines
9.2 KiB
Python
"""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)
|