feat(reports): DATEV-Monatsblatt-Export (Blanko-Vorlage) für Steuerberater

Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro
Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage
(Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen +
Summe + Unterschriftsfelder). Führt time_entries, Absences und
Feiertage pro Kalendertag zusammen.

Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/
Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom
hours_payouts-Modul als aktuellstem sauberen Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
This commit is contained in:
2026-08-06 22:19:54 +02:00
co-authored by Claude Sonnet 5
parent f3ed234e56
commit 1dcc04ae07
10 changed files with 901 additions and 2 deletions
+168
View File
@@ -0,0 +1,168 @@
"""TEMPLATE Mustervorlage für einen neuen Router (Modul mit Workflow-Status).
Abgeleitet vom aktuellsten sauberen Muster im Projekt: app/routers/hours_payouts.py.
Kern-Regeln (aus CLAUDE.md + gelernte Fallstricke):
- Company-Isolation: JEDE Query filtert auf company_id == current_user.company_id
(zusätzlich zur DB-seitigen RLS Python-Check ist die zweite Verteidigungslinie)
- **RLS-Bypass + mid-request commit Falle**: Response-Objekt (_build_out) IMMER
VOR db.commit() bauen. db.refresh() nur nach db.flush() (noch in Transaktion).
Nach commit() ist app.bypass_rls/app.company_id verfallen -> Post-Commit-Reads
sehen ggf. keine Zeilen mehr.
- AuditLog bei JEDER schreibenden Aktion (create/approve/reject/cancel/delete)
- HTTPException mit sprechendem detail, kein Hard-Delete
- require_role() aus core.dependencies für Rollenprüfung
Anleitung:
1. Nach app/routers/<name>.py kopieren, XxxThing/xxx_things ersetzen
2. In app/main.py registrieren: app.include_router(xxx_things.router, prefix="/api/v1")
3. Model + Schema aus model_template.py / schema_template.py zuerst anlegen
4. Migration schreiben (Alembic) inkl. RLS-Policy + conftest.py nachziehen
5. Tests in backend/tests/test_xxx_things.py (siehe test_hours_payouts.py als Muster)
"""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_client_ip, require_role
from app.models.audit_log import AuditLog
from app.models.user import User, UserRole
from app.models.xxx_thing import XxxThing, XxxThingStatus # TODO: Model anlegen
from app.schemas.xxx_thing import ( # TODO: Schemas anlegen
XxxThingCreate,
XxxThingListResponse,
XxxThingOut,
XxxThingReject,
)
router = APIRouter(tags=["Xxx-Thing"])
_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
async def _build_out(item: XxxThing, db: AsyncSession) -> XxxThingOut:
"""Response-Objekt VOR commit() bauen siehe RLS-Bypass-Falle oben."""
out = XxxThingOut.model_validate(item)
user = await db.get(User, item.user_id)
out.user_name = f"{user.first_name} {user.last_name}" if user else str(item.user_id)
return out
# ── GET /xxx-things ────────────────────────────────────────────────────────
@router.get("/xxx-things", response_model=XxxThingListResponse)
async def list_items(
status: str | None = Query(None),
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Liste der eigenen Firma. EMPLOYEE/MANAGER sehen nur eigene Einträge."""
filters = [XxxThing.company_id == current_user.company_id]
if current_user.role not in _hr_roles:
filters.append(XxxThing.user_id == current_user.id)
if status is not None:
filters.append(XxxThing.status == status)
total_count = await db.scalar(select(func.count()).select_from(XxxThing).where(*filters))
rows = list(await db.scalars(
select(XxxThing).where(*filters).order_by(XxxThing.created_at.desc())
))
result = [await _build_out(row, db) for row in rows]
return XxxThingListResponse(items=result, total_count=total_count or 0)
# ── POST /xxx-things ───────────────────────────────────────────────────────
@router.post("/xxx-things", response_model=XxxThingOut, status_code=201)
async def create_item(
request: Request,
data: XxxThingCreate,
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
item = XxxThing(
company_id=current_user.company_id,
user_id=data.user_id,
note=data.note,
status=XxxThingStatus.REQUESTED.value,
created_by=current_user.id,
)
db.add(item)
await db.flush()
await db.refresh(item) # server_default created_at noch in-Transaktion
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="xxx_thing_created", entity_type="xxx_thing", entity_id=item.id,
new_value={"note": data.note},
ip=get_client_ip(request),
))
out = await _build_out(item, db)
await db.commit()
return out
# ── POST /xxx-things/{id}/approve ──────────────────────────────────────────
@router.post("/xxx-things/{item_id}/approve", response_model=XxxThingOut)
async def approve_item(
item_id: UUID,
request: Request,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
item = await db.get(XxxThing, item_id)
if item is None or item.company_id != current_user.company_id:
raise HTTPException(404, "Eintrag nicht gefunden")
if item.status != XxxThingStatus.REQUESTED.value:
raise HTTPException(409, "Nur offene Anträge können genehmigt werden.")
item.status = XxxThingStatus.APPROVED.value
item.decided_by = current_user.id
item.decided_at = datetime.utcnow()
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="xxx_thing_approved", entity_type="xxx_thing", entity_id=item.id,
ip=get_client_ip(request),
))
out = await _build_out(item, db)
await db.commit()
return out
# ── POST /xxx-things/{id}/reject ───────────────────────────────────────────
@router.post("/xxx-things/{item_id}/reject", response_model=XxxThingOut)
async def reject_item(
item_id: UUID,
request: Request,
data: XxxThingReject,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
item = await db.get(XxxThing, item_id)
if item is None or item.company_id != current_user.company_id:
raise HTTPException(404, "Eintrag nicht gefunden")
if item.status != XxxThingStatus.REQUESTED.value:
raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.")
item.status = XxxThingStatus.REJECTED.value
item.rejection_reason = data.rejection_reason
item.decided_by = current_user.id
item.decided_at = datetime.utcnow()
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="xxx_thing_rejected", entity_type="xxx_thing", entity_id=item.id,
new_value={"rejection_reason": data.rejection_reason},
ip=get_client_ip(request),
))
out = await _build_out(item, db)
await db.commit()
return out