Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
from datetime import date, datetime, time, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.work_schedule import WorkSchedule
|
||||
from app.schemas.time_entry import (
|
||||
BalanceResponse,
|
||||
ManualEntryCreate,
|
||||
StampInRequest,
|
||||
TimeEntryUpdate,
|
||||
)
|
||||
|
||||
|
||||
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
|
||||
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
|
||||
start_mins = start.hour * 60 + start.minute
|
||||
end_mins = end.hour * 60 + end.minute
|
||||
if end_mins <= start_mins:
|
||||
end_mins += 24 * 60 # Nachtschicht
|
||||
total_mins = end_mins - start_mins
|
||||
worked_mins = total_mins - break_minutes
|
||||
worked_hours = worked_mins / 60
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
if worked_hours > 10:
|
||||
warnings.append(
|
||||
f"Maximale Arbeitszeit von 10 Stunden überschritten "
|
||||
f"({worked_hours:.1f}h gearbeitet) – ArbZG §3"
|
||||
)
|
||||
if total_mins >= 9 * 60 and break_minutes < 45:
|
||||
warnings.append(
|
||||
"Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben – ArbZG §4"
|
||||
)
|
||||
elif total_mins >= 6 * 60 and break_minutes < 30:
|
||||
warnings.append(
|
||||
"Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben – ArbZG §4"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_rest_period(prev_end: time | None, prev_date: date | None,
|
||||
new_start: time, new_date: date) -> list[str]:
|
||||
"""Mindestruhezeit 11h zwischen Schichten – ArbZG §5.
|
||||
Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen
|
||||
Stempelungen am gleichen Tag (z.B. Korrektur oder Pause).
|
||||
"""
|
||||
if prev_end is None or prev_date is None:
|
||||
return []
|
||||
# Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar
|
||||
if prev_date == new_date:
|
||||
return []
|
||||
prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None)
|
||||
new_start_dt = datetime.combine(new_date, new_start, tzinfo=None)
|
||||
rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600
|
||||
# Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen
|
||||
if 0 < rest_hours < 11:
|
||||
return [
|
||||
f"Mindestruhezeit von 11h unterschritten "
|
||||
f"({rest_hours:.1f}h seit letzter Schicht) – ArbZG §5"
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
class TimeService:
|
||||
|
||||
# ── Stempeluhr ────────────────────────────────────────────────────────────
|
||||
|
||||
async def stamp_in(
|
||||
self,
|
||||
user: User,
|
||||
data: StampInRequest,
|
||||
db: AsyncSession,
|
||||
) -> tuple[TimeEntry, list[str]]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||
|
||||
# Offenen Eintrag für heute prüfen
|
||||
open_entry = await self._get_open_entry(user.id, db)
|
||||
if open_entry is not None:
|
||||
raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.")
|
||||
|
||||
# Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen
|
||||
last_entry = await db.scalar(
|
||||
select(TimeEntry)
|
||||
.where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None))
|
||||
.order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
warnings = _check_rest_period(
|
||||
last_entry.end_time if last_entry else None,
|
||||
last_entry.date if last_entry else None,
|
||||
now_time,
|
||||
today,
|
||||
)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
date=today,
|
||||
start_time=now_time,
|
||||
break_minutes=0,
|
||||
source=data.source,
|
||||
project_id=data.project_id,
|
||||
note=data.note,
|
||||
status=EntryStatus.PENDING,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry, warnings
|
||||
|
||||
async def stamp_out(
|
||||
self,
|
||||
user: User,
|
||||
note: str | None,
|
||||
db: AsyncSession,
|
||||
) -> tuple[TimeEntry, list[str]]:
|
||||
entry = await self._get_open_entry(user.id, db)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||
|
||||
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||
|
||||
# Aktive Pause beenden falls vergessen
|
||||
if entry.break_start is not None:
|
||||
extra_break = self._calc_break_minutes(entry.break_start, now_time)
|
||||
entry.break_minutes += extra_break
|
||||
entry.break_start = None
|
||||
|
||||
entry.end_time = now_time
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
if note:
|
||||
entry.note = note
|
||||
|
||||
warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes)
|
||||
return entry, warnings
|
||||
|
||||
async def break_start(self, user: User, db: AsyncSession) -> TimeEntry:
|
||||
entry = await self._get_open_entry(user.id, db)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||
if entry.break_start is not None:
|
||||
raise HTTPException(status_code=409, detail="Pause bereits aktiv.")
|
||||
|
||||
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||
entry.break_start = now_time
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
async def break_end(self, user: User, db: AsyncSession) -> TimeEntry:
|
||||
entry = await self._get_open_entry(user.id, db)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||
if entry.break_start is None:
|
||||
raise HTTPException(status_code=409, detail="Keine aktive Pause.")
|
||||
|
||||
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||
extra = self._calc_break_minutes(entry.break_start, now_time)
|
||||
entry.break_minutes += extra
|
||||
entry.break_start = None
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
# ── Einträge ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
result = await db.scalars(
|
||||
select(TimeEntry)
|
||||
.where(TimeEntry.user_id == user.id, TimeEntry.date == today)
|
||||
.order_by(TimeEntry.start_time)
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
async def list_entries(
|
||||
self,
|
||||
company_id: UUID,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
user_id: UUID | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
status: EntryStatus | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[int, list[TimeEntry]]:
|
||||
# Basis: nur Einträge der eigenen Company
|
||||
# Subquery: JOIN user für company_id Filter
|
||||
q = (
|
||||
select(TimeEntry)
|
||||
.join(User, TimeEntry.user_id == User.id)
|
||||
.where(User.company_id == company_id)
|
||||
)
|
||||
|
||||
# EMPLOYEE sieht nur eigene Einträge
|
||||
if current_user.role == UserRole.EMPLOYEE:
|
||||
q = q.where(TimeEntry.user_id == current_user.id)
|
||||
elif user_id:
|
||||
q = q.where(TimeEntry.user_id == user_id)
|
||||
|
||||
if date_from:
|
||||
q = q.where(TimeEntry.date >= date_from)
|
||||
if date_to:
|
||||
q = q.where(TimeEntry.date <= date_to)
|
||||
if status:
|
||||
q = q.where(TimeEntry.status == status)
|
||||
|
||||
total = await db.scalar(select(func.count()).select_from(q.subquery()))
|
||||
entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit))
|
||||
return total or 0, list(entries.all())
|
||||
|
||||
async def create_manual(
|
||||
self,
|
||||
data: ManualEntryCreate,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple[TimeEntry, list[str]]:
|
||||
target_user_id = current_user.id
|
||||
|
||||
# Employees need explicit permission to create manual entries
|
||||
_elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry:
|
||||
raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.")
|
||||
|
||||
if data.user_id and data.user_id != current_user.id:
|
||||
if current_user.role not in _elevated:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.")
|
||||
target = await db.get(User, data.user_id)
|
||||
if target is None or target.company_id != current_user.company_id:
|
||||
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
||||
target_user_id = data.user_id
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=target_user_id,
|
||||
date=data.date,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
break_minutes=data.break_minutes,
|
||||
project_id=data.project_id,
|
||||
note=data.note,
|
||||
source=data.source,
|
||||
status=EntryStatus.PENDING,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes)
|
||||
return entry, warnings
|
||||
|
||||
async def update_entry(
|
||||
self,
|
||||
entry_id: UUID,
|
||||
data: TimeEntryUpdate,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> TimeEntry:
|
||||
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
entry = await self._get_entry_or_404(entry_id, db)
|
||||
await self._assert_access(entry, current_user, db)
|
||||
|
||||
if entry.status == EntryStatus.APPROVED:
|
||||
if current_user.role not in _manager_roles:
|
||||
raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.")
|
||||
if not data.correction_note:
|
||||
raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.")
|
||||
|
||||
# Vorherigen Zustand für AuditLog sichern
|
||||
old_snapshot = {
|
||||
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
|
||||
"break_minutes": entry.break_minutes,
|
||||
"note": entry.note,
|
||||
"correction_note": entry.correction_note,
|
||||
}
|
||||
|
||||
changes = data.model_dump(exclude_none=True)
|
||||
for field, value in changes.items():
|
||||
setattr(entry, field, value)
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
if entry.status == EntryStatus.APPROVED:
|
||||
new_snapshot = {
|
||||
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
|
||||
"break_minutes": entry.break_minutes,
|
||||
"note": entry.note,
|
||||
"correction_note": entry.correction_note,
|
||||
}
|
||||
user_obj = await db.get(User, entry.user_id)
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id,
|
||||
user_id=current_user.id,
|
||||
action="time_entry_approved_edit",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
old_value=old_snapshot,
|
||||
new_value={**new_snapshot, "changed_by": str(current_user.id),
|
||||
"target_user": str(entry.user_id),
|
||||
"target_user_name": user_obj.full_name if user_obj else None},
|
||||
))
|
||||
|
||||
return entry
|
||||
|
||||
async def approve_entry(
|
||||
self,
|
||||
entry_id: UUID,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> TimeEntry:
|
||||
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.")
|
||||
|
||||
entry = await self._get_entry_or_404(entry_id, db)
|
||||
|
||||
# Cross-Tenant-Schutz
|
||||
entry_user = await db.get(User, entry.user_id)
|
||||
if entry_user is None or entry_user.company_id != current_user.company_id:
|
||||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||
|
||||
# Self-Approval-Schutz (L-03)
|
||||
if entry.user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Eigene Zeiteinträge können nicht selbst genehmigt werden."
|
||||
)
|
||||
|
||||
if entry.status != EntryStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.")
|
||||
|
||||
entry.status = EntryStatus.APPROVED
|
||||
entry.approved_by = current_user.id
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
async def reject_entry(
|
||||
self,
|
||||
entry_id: UUID,
|
||||
current_user: User,
|
||||
correction_note: str | None,
|
||||
db: AsyncSession,
|
||||
) -> TimeEntry:
|
||||
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
|
||||
|
||||
entry = await self._get_entry_or_404(entry_id, db)
|
||||
if entry.status != EntryStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
|
||||
|
||||
entry.status = EntryStatus.REJECTED
|
||||
entry.approved_by = current_user.id
|
||||
if correction_note:
|
||||
entry.correction_note = correction_note
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
async def delete_entry(
|
||||
self,
|
||||
entry_id: UUID,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
entry = await self._get_entry_or_404(entry_id, db)
|
||||
await self._assert_access(entry, current_user, db)
|
||||
|
||||
# Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden
|
||||
if entry.status == EntryStatus.APPROVED:
|
||||
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden."
|
||||
)
|
||||
|
||||
await db.delete(entry)
|
||||
|
||||
async def get_balance(
|
||||
self,
|
||||
user_id: UUID,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
) -> BalanceResponse:
|
||||
# Zugriff prüfen
|
||||
if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
if period_start is None:
|
||||
period_start = today.replace(day=1)
|
||||
if period_end is None:
|
||||
period_end = today
|
||||
|
||||
# Genehmigte Einträge summieren
|
||||
approved_entries = await db.scalars(
|
||||
select(TimeEntry).where(
|
||||
and_(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.date >= period_start,
|
||||
TimeEntry.date <= period_end,
|
||||
TimeEntry.status == EntryStatus.APPROVED,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
approved_list = list(approved_entries.all())
|
||||
|
||||
total_worked = sum(e.worked_hours or 0.0 for e in approved_list)
|
||||
|
||||
# Ausstehende Einträge zählen
|
||||
pending_count = await db.scalar(
|
||||
select(func.count(TimeEntry.id)).where(
|
||||
and_(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.date >= period_start,
|
||||
TimeEntry.date <= period_end,
|
||||
TimeEntry.status == EntryStatus.PENDING,
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
|
||||
# Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan)
|
||||
schedule = await db.scalar(
|
||||
select(WorkSchedule)
|
||||
.join(User, WorkSchedule.company_id == User.company_id)
|
||||
.where(
|
||||
User.id == user_id,
|
||||
WorkSchedule.valid_from <= period_start,
|
||||
)
|
||||
.order_by(WorkSchedule.valid_from.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
expected = self._calc_expected_hours(period_start, period_end, schedule)
|
||||
|
||||
return BalanceResponse(
|
||||
user_id=user_id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
total_hours_worked=round(total_worked, 2),
|
||||
expected_hours=round(expected, 2),
|
||||
overtime_hours=round(total_worked - expected, 2),
|
||||
approved_entries=len(approved_list),
|
||||
pending_entries=pending_count,
|
||||
)
|
||||
|
||||
# ── Arbeitspläne ──────────────────────────────────────────────────────────
|
||||
|
||||
async def create_work_schedule(
|
||||
self,
|
||||
company_id: UUID,
|
||||
data,
|
||||
db: AsyncSession,
|
||||
) -> WorkSchedule:
|
||||
schedule = WorkSchedule(company_id=company_id, **data.model_dump())
|
||||
db.add(schedule)
|
||||
await db.flush()
|
||||
return schedule
|
||||
|
||||
async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]:
|
||||
result = await db.scalars(
|
||||
select(WorkSchedule)
|
||||
.where(WorkSchedule.company_id == company_id)
|
||||
.order_by(WorkSchedule.valid_from.desc())
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None:
|
||||
return await db.scalar(
|
||||
select(TimeEntry).where(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.end_time.is_(None),
|
||||
).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1)
|
||||
)
|
||||
|
||||
async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry:
|
||||
entry = await db.get(TimeEntry, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.")
|
||||
return entry
|
||||
|
||||
async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None:
|
||||
if entry.user_id != user.id and user.role not in (
|
||||
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||
entry_user = await db.get(User, entry.user_id)
|
||||
if entry_user is None or entry_user.company_id != user.company_id:
|
||||
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||
|
||||
@staticmethod
|
||||
def _calc_break_minutes(start: time, end: time) -> int:
|
||||
s = start.hour * 60 + start.minute
|
||||
e = end.hour * 60 + end.minute
|
||||
if e < s:
|
||||
e += 24 * 60
|
||||
return max(0, e - s)
|
||||
|
||||
@staticmethod
|
||||
def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float:
|
||||
"""Soll-Stunden für den Zeitraum berechnen."""
|
||||
from datetime import timedelta
|
||||
total = 0.0
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
wd = current.weekday() # 0=Mon
|
||||
if schedule:
|
||||
total += float(schedule.hours_for_weekday(wd))
|
||||
else:
|
||||
# Fallback: 8h Mo-Fr
|
||||
if wd < 5:
|
||||
total += 8.0
|
||||
current += timedelta(days=1)
|
||||
return total
|
||||
|
||||
|
||||
time_service = TimeService()
|
||||
Reference in New Issue
Block a user