feat(verleih): Übergabeliste zum Unterschreiben (ASSET-013 Verleih-Teil)
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:
1 parent
5ce9430816
commit
4a0d0fd5f1
7 files changed
+309
-14
No files matched your search
@@ -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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user