feat(verleih): Übergabeliste zum Unterschreiben (ASSET-013 Verleih-Teil)
CI / backend-tests (push) Successful in 3m18s
CI / frontend-build (push) Successful in 22s

Reines Druck-PDF (reportlab, kein digitaler Signatur-Workflow) mit
Objektpositionen (Soll/Ist), nach Fach gruppiert wo vorhanden, plus
Unterschriftsfelder für Übergeber/Übernehmer. Aussonderungs-Teil von
ASSET-013 bleibt offen (braucht erst neuen ObjektStatus).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 22:47:49 +02:00
1 parent 5ce9430816
commit 4a0d0fd5f1
7 files changed
+309 -14

No files matched your search

+12
View File
@@ -15097,3 +15097,15 @@ Keine Commits in dieser Session.
- frontend/src/auth/AuthContext.tsx | 16 +++++++++++++++-
---
## 2026-09-12 22:43 – 22:43 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 5ce9430 docs(backlog): ASSET-013 Übergabeliste bei Verleih/Aussonderung geplant
### Geänderte Dateien
- DEVLOG.md | 28 ++++++++++++++++++++++++++++
- arbeitskacheln/04_assets.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+21 -8
View File
@@ -521,10 +521,14 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
optional `verleih.uebergabeliste_dokument_id` bzw. generische
Dokument-Verknüpfung über bestehendes `entitaet_typ="verleih"`-Muster
(DOC-001 unterstützt das bereits ohne Schemaänderung).
- **Backend:** `GET /verleih/{id}/uebergabeliste` (PDF) bzw.
- **Backend:** `GET /verleihe/{id}/uebergabeliste` (PDF) bzw.
`GET /objekte/{id}/uebergabeliste` (für Aussonderung) - liefert
serverseitig mit `weasyprint` (HTML/CSS zu PDF, entschieden 2026-09-12)
gerendertes PDF aus Objektpositionen + letzter Kontrolle.
serverseitig mit `reportlab` gerendertes PDF (Entscheidung 2026-09-12
REVIDIERT: reportlab statt weasyprint - reportlab ist bereits
Projekt-Abhängigkeit für `ausgabeprotokoll.py`/`label.py`/`qr_png.py`,
weasyprint hätte zusätzliche Systembibliotheken (Pango/Cairo) auf dem
Zielserver gebraucht, ohne fachlichen Mehrwert für dieses einfache
Tabellen-Layout).
- **Frontend:** Button „Übergabeliste drucken" im Verleih-Tab und (nach
Klärung Aussonderungs-Workflow) im Objekt-Status-Wechsel-Dialog; danach
optional direkt Upload-Dialog für den unterschriebenen Scan (DOC-001).
@@ -540,11 +544,20 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
DECISION-REQUIRED-Klärung akzeptanzfähig.
- **Tests:** PDF-Erzeugung liefert erwartete Positionen/Mengen, 404 bei
nicht existierendem Verleih/Objekt.
- **DoD:** offen — noch nicht begonnen. Entscheidungen 2026-09-12 getroffen:
`weasyprint`, neuer `ObjektStatus.ausgesondert`, Verleih+Aussonderung
zusammen in dieser Kachel. Prüfen ob `weasyprint` bereits Systemabhängig-
keiten braucht (Pango/Cairo) — auf dem Zielserver (Debian 13 LXC) vor
Deploy verifizieren, da das eine reine Python-Paketinstallation übersteigt.
- **DoD:** **Verleih-Teil umgesetzt (2026-09-12)**, Aussonderungs-Teil
weiterhin offen (braucht erst den neuen `ObjektStatus.ausgesondert` inkl.
Migration + Admin-UI-Workflow dafür). Umgesetzt: `app/services/
uebergabeliste.py` (reine reportlab-Zeichenoperationen wie
`ausgabeprotokoll.py`, kein HTML/Jinja2 - kein Injection-Risiko über
Materialnamen), `GET /verleihe/{id}/uebergabeliste` in
`endpoints/verleih.py`, Fach-Gruppierung über `Vorlagenposition.fach`
(nur wenn Objekt eine Vorlage hat, sonst flache Liste), Frontend-Button
„Übergabeliste" in `VerleihSection.tsx` über neues
`ladeVerleihUebergabeliste()` (gleiches Blob+neuer-Tab-Muster wie
`ladeObjektEtikett`). Tests: `backend/tests/test_verleih.py`
(PDF-Erzeugung, 404 bei unbekanntem Verleih). Kein lokales Backend-Setup
vorhanden (kein venv/Postgres) — nur Syntax-Check lokal, echte
Verifikation läuft über CI.
---
+41 -1
View File
@@ -1,4 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
import uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
@@ -7,6 +10,11 @@ from app.models.auth import Benutzer, RolleTyp
from app.models.objekt import Objekt
from app.models.verleih import ExterneStelle, Verleih
from app.schemas.verleih import ExterneStelleCreate, ExterneStelleRead, VerleihCreate, VerleihRead
from app.services.uebergabeliste import (
generiere_uebergabeliste_pdf,
hole_standort_name,
hole_uebergabepositionen,
)
from app.services.verleih import (
KeinOffenerVerleihError,
ObjektBereitsVerliehenError,
@@ -99,3 +107,35 @@ async def verleih_rueckgabe(
status_code=status.HTTP_409_CONFLICT, detail="Objekt ist aktuell nicht verliehen"
) from exc
return _verleih_read(verleih)
@router.get("/verleihe/{verleih_id}/uebergabeliste")
async def verleih_uebergabeliste(
verleih_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> Response:
"""ASSET-013 (Verleih-Teil): reines Druck-PDF zum handschriftlichen
Unterschreiben, siehe app/services/uebergabeliste.py."""
verleih = await db.get(Verleih, verleih_id)
if verleih is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Verleih nicht gefunden")
objekt = await db.get(Objekt, verleih.objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
externe_stelle = await db.get(ExterneStelle, verleih.externe_stelle_id)
standort_name = await hole_standort_name(db, objekt.standort_id)
positionen = await hole_uebergabepositionen(db, objekt)
pdf = generiere_uebergabeliste_pdf(
objekt_name=objekt.name,
objekt_code=objekt.code,
standort_name=standort_name,
empfaenger_bezeichnung=externe_stelle.name if externe_stelle else "unbekannt",
anlass="Verleih",
zeitpunkt_text=_formatiere_datum(verleih.verliehen_am),
positionen=positionen,
)
return Response(content=pdf, media_type="application/pdf")
def _formatiere_datum(wert: date | datetime) -> str:
return wert.strftime("%d.%m.%Y")
+153
View File
@@ -0,0 +1,153 @@
import io
from decimal import Decimal
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.models.stammdaten import Material, Standort
from app.models.vorlage import Vorlagenposition
from app.services.objekte import sollmenge_effektiv
# ASSET-013 (Verleih-Teil): reines Druck-PDF zum handschriftlichen
# Unterschreiben, KEIN digitaler Signatur-Workflow - anders als
# ausgabeprotokoll.py (ASSET-010, bettet ein bereits eingesammeltes
# Signatur-Bild ein). Bewusst reportlab statt weasyprint (Entscheidung
# 2026-09-12 revidiert): reportlab ist bereits Projekt-Abhängigkeit
# (ausgabeprotokoll.py/label.py/qr_png.py), weasyprint hätte zusätzliche
# System-Bibliotheken (Pango/Cairo) auf dem Zielserver gebraucht.
_ZEILENHOEHE = 6 * mm
class Position:
def __init__(self, *, material_name: str, einheit: str, soll: Decimal, ist: Decimal, fach: str | None):
self.material_name = material_name
self.einheit = einheit
self.soll = soll
self.ist = ist
self.fach = fach
async def hole_uebergabepositionen(db: AsyncSession, objekt: Objekt) -> list[Position]:
"""Fach kommt - falls vorhanden - aus der Vorlagenposition (Objektposition
selbst hat kein eigenes Fach-Feld, siehe Datenmodell-Klärung ASSET-013);
Objekte ohne Vorlage (vorlage_id NULL) liefern einfach kein Fach, die
PDF-Ausgabe bleibt dann eine flache Liste."""
fach_je_material: dict[int, str | None] = {}
if objekt.vorlage_id is not None:
result = await db.execute(
select(Vorlagenposition.material_id, Vorlagenposition.fach).where(
Vorlagenposition.vorlage_id == objekt.vorlage_id
)
)
fach_je_material = dict(result.all())
result = await db.execute(
select(Objektposition, Material)
.join(Material, Material.id == Objektposition.material_id)
.where(Objektposition.objekt_id == objekt.id, Objektposition.ist_status == ObjektpositionStatus.aktiv)
.order_by(Material.name)
)
positionen = [
Position(
material_name=material.name,
einheit=material.einheit,
soll=sollmenge_effektiv(position),
ist=position.istmenge,
fach=fach_je_material.get(position.material_id),
)
for position, material in result.all()
]
# Nach Fach gruppiert (physische Reihenfolge im Rucksack/Fahrzeug wichtiger
# als Alphabet), positionslose (kein Fach) ans Ende.
return sorted(positionen, key=lambda p: (p.fach is None, p.fach or "", p.material_name))
def generiere_uebergabeliste_pdf(
*,
objekt_name: str,
objekt_code: str,
standort_name: str,
empfaenger_bezeichnung: str,
anlass: str,
zeitpunkt_text: str,
positionen: list[Position],
) -> bytes:
puffer = io.BytesIO()
c = canvas.Canvas(puffer, pagesize=A4)
breite, hoehe = A4
rand = 20 * mm
c.setFont("Helvetica-Bold", 16)
c.drawString(rand, hoehe - 25 * mm, "Material-Übergabeliste")
c.setFont("Helvetica", 11)
kopfzeilen = [
f"Objekt: {objekt_name} ({objekt_code})",
f"Standort: {standort_name}",
f"Anlass: {anlass}",
f"Empfänger/Stelle: {empfaenger_bezeichnung}",
f"Zeitpunkt: {zeitpunkt_text}",
]
y = hoehe - 38 * mm
for zeile in kopfzeilen:
c.drawString(rand, y, zeile)
y -= 6 * mm
y -= 4 * mm
c.setFont("Helvetica-Bold", 10)
spalten_x = [rand, rand + 55 * mm, rand + 100 * mm, rand + 130 * mm]
c.drawString(spalten_x[0], y, "Material")
c.drawString(spalten_x[1], y, "Fach")
c.drawString(spalten_x[2], y, "Soll")
c.drawString(spalten_x[3], y, "Ist")
y -= 4 * mm
c.line(rand, y, breite - rand, y)
y -= _ZEILENHOEHE
c.setFont("Helvetica", 9)
letztes_fach: str | None = "__unset__"
for position in positionen:
if y < 25 * mm:
c.showPage()
c.setFont("Helvetica", 9)
y = hoehe - rand
if position.fach != letztes_fach and position.fach is not None:
c.setFont("Helvetica-BoldOblique", 9)
c.drawString(spalten_x[0], y, position.fach)
y -= _ZEILENHOEHE
c.setFont("Helvetica", 9)
letztes_fach = position.fach
c.drawString(spalten_x[0], y, position.material_name)
c.drawRightString(spalten_x[2] + 15 * mm, y, f"{position.soll} {position.einheit}")
c.drawRightString(spalten_x[3] + 15 * mm, y, f"{position.ist} {position.einheit}")
y -= _ZEILENHOEHE
y -= 15 * mm
if y < 40 * mm:
c.showPage()
y = hoehe - rand - 15 * mm
c.setFont("Helvetica", 10)
unterschrift_breite = (breite - 2 * rand - 10 * mm) / 2
c.line(rand, y, rand + unterschrift_breite, y)
c.line(rand + unterschrift_breite + 10 * mm, y, breite - rand, y)
y -= 5 * mm
c.drawString(rand, y, "Übergeber (Unterschrift, Datum)")
c.drawString(rand + unterschrift_breite + 10 * mm, y, "Übernehmer (Unterschrift, Datum)")
c.showPage()
c.save()
return puffer.getvalue()
async def hole_standort_name(db: AsyncSession, standort_id: int) -> str:
standort = await db.get(Standort, standort_id)
return standort.name if standort else f"Standort #{standort_id}"
+57
View File
@@ -1,7 +1,9 @@
from datetime import date, timedelta
from decimal import Decimal
import pytest
from app.models.objektposition import Objektposition
from tests.conftest import auth_header, login
@@ -80,3 +82,58 @@ async def test_verleih_nur_materialverantwortliche(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.get("/api/v1/verleihe", headers=auth_header(token))
assert response.status_code == 403
@pytest.mark.asyncio
async def test_verleih_uebergabeliste_pdf(
client, db_session, admin_user, standort_factory, objekt_factory, material_factory
):
"""ASSET-013 (Verleih-Teil): Übergabeliste enthält den aktuellen
Ist-Bestand der Objektpositionen als PDF."""
standort = await standort_factory("Wache-Uebergabe")
objekt = await objekt_factory(name="Notstromaggregat", code="VERLEIH-PDF", standort=standort)
material = await material_factory("Verlängerungskabel")
db_session.add(
Objektposition(
objekt_id=objekt.id,
material_id=material.id,
sollmenge_vorlage=Decimal(2),
istmenge=Decimal(1),
)
)
await db_session.flush()
await db_session.commit()
token = await login(client, "admin1")
stelle = await client.post(
"/api/v1/externe-stellen", json={"name": "Nachbarwehr"}, headers=auth_header(token)
)
stelle_id = stelle.json()["id"]
verliehen = await client.post(
"/api/v1/verleihe",
json={
"objekt_id": objekt.id,
"externe_stelle_id": stelle_id,
"verliehen_am": date.today().isoformat(),
},
headers=auth_header(token),
)
verleih_id = verliehen.json()["id"]
pdf_response = await client.get(
f"/api/v1/verleihe/{verleih_id}/uebergabeliste", headers=auth_header(token)
)
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
assert pdf_response.content.startswith(b"%PDF")
@pytest.mark.asyncio
async def test_verleih_uebergabeliste_404_bei_unbekanntem_verleih(client, admin_user):
token = await login(client, "admin1")
response = await client.get(
"/api/v1/verleihe/00000000-0000-0000-0000-000000000000/uebergabeliste",
headers=auth_header(token),
)
assert response.status_code == 404
+5
View File
@@ -214,6 +214,11 @@ export function ladeObjektEtikett(objektId: number, format: "code128" | "qr"): P
return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/label.pdf?format=${format}`);
}
// ASSET-013 (Verleih-Teil): Material-Übergabeliste zum Ausdrucken/Unterschreiben.
export function ladeVerleihUebergabeliste(verleihId: string): Promise<void> {
return ladeUndOeffneInNeuemTab(`/verleihe/${verleihId}/uebergabeliste`);
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, {
+20 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { apiRequest, ApiError } from "../../api/client";
import { apiRequest, ApiError, ladeVerleihUebergabeliste } from "../../api/client";
import type { ExterneStelle, Objekt, VerleihEintrag } from "../../api/types";
import { TableWrapper } from "../../components/ui/table-wrapper";
@@ -94,6 +94,12 @@ export function VerleihSection({ objekte, onFehler }: Props) {
}
}
function uebergabelisteDrucken(verleihId: string) {
// Muss synchron im Klick-Handler starten (window.open-Popup-Blocker-
// Heuristik, siehe ladeUndOeffneInNeuemTab in api/client.ts).
ladeVerleihUebergabeliste(verleihId).catch(() => onFehler("Übergabeliste konnte nicht erzeugt werden."));
}
function objektName(id: number): string {
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `#${id}`;
@@ -204,11 +210,20 @@ export function VerleihSection({ objekte, onFehler }: Props) {
)}
</td>
<td>
{!v.tatsaechliche_rueckgabe_am && (
<button className="btn btn-secondary" onClick={() => rueckgabe(v.objekt_id)}>
Rückgabe
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
<button
className="btn btn-secondary"
onClick={() => uebergabelisteDrucken(v.id)}
title="Übergabeliste zum Unterschreiben drucken"
>
Übergabeliste
</button>
)}
{!v.tatsaechliche_rueckgabe_am && (
<button className="btn btn-secondary" onClick={() => rueckgabe(v.objekt_id)}>
Rückgabe
</button>
)}
</div>
</td>
</tr>
))}