feat(assets): ASSET-013 Aussonderungs-Teil - ObjektStatus.ausgesondert
CI / backend-tests (push) Successful in 3m27s
CI / frontend-build (push) Successful in 23s

Neuer Status per Migration (ALTER TYPE ADD VALUE), gesetzt über den
bestehenden generischen PATCH /objekte/{id}-Endpunkt (Historie-Logging kam
damit automatisch mit). Dashboard blendet ausgesondert wie ausser_dienst
aus. Neuer GET /objekte/{id}/uebergabeliste nutzt denselben PDF-Mechanismus
wie der Verleih-Teil, ohne Verleih-/Externe-Stelle-Bezug. Damit ist
ASSET-013 komplett (Verleih-Teil war bereits 2026-09-12 fertig).

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-13 10:13:06 +02:00
1 parent 69f66c6d1c
commit f4e8a83097
9 files changed
+175 -14

No files matched your search

+22 -13
View File
@@ -558,20 +558,29 @@ 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:** **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`
- **DoD:** ✅ **beide Teile umgesetzt (2026-09-12/13).** Verleih-Teil
(2026-09-12): `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.
„Übergabeliste" in `VerleihSection.tsx`. Aussonderungs-Teil (2026-09-13):
Migration `0056_objekt_status_ausgesondert.py`
(`ALTER TYPE objekt_status ADD VALUE`), neuer `ObjektStatus.ausgesondert`
wird über den bestehenden generischen `PATCH /objekte/{id}`-Endpunkt
gesetzt (kein eigener Status-Endpunkt nötig) - Historie-Logging kam damit
automatisch mit. `objekt_kategorien()` (`app/services/dashboard.py`)
blendet `ausgesondert` jetzt genauso aus wie `ausser_dienst`. Neuer
`GET /objekte/{id}/uebergabeliste`-Endpunkt nutzt denselben
`uebergabeliste`-Service ohne Verleih-/Externe-Stelle-Bezug. Frontend:
neue Status-Option „Ausgesondert" in `objektZeileTypen.ts`, Button
„Übergabeliste" in `ObjektZeile.tsx` sichtbar sobald Status
`ausgesondert` ist, `ladeObjektUebergabeliste()` in `api/client.ts`
(gleiches Blob+neuer-Tab-Muster). Tests: `backend/tests/test_verleih.py`
(PDF-Erzeugung, 404), `backend/tests/test_aussonderung.py` (Status-
Wechsel, Dashboard-Ausblendung, PDF-Erzeugung, 404, Rollen-Gate). Kein
lokales Backend-Setup vorhanden (kein venv/Postgres) — nur Syntax-Check
lokal, echte Verifikation läuft über CI.
---
@@ -0,0 +1,29 @@
"""ASSET-013 (Aussonderungs-Teil): neuer ObjektStatus-Wert "ausgesondert" -
dauerhaft aus dem Bestand entfernt, bewusst getrennt von "ausser_dienst"
(bleibt temporär/Werkstatt-Bedeutung).
Revision ID: 0056_objekt_status_ausgesondert
Revises: 0055_person_einheit_funktion
Create Date: 2026-09-13
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0056_objekt_status_ausgesondert"
down_revision: Union[str, None] = "0055_person_einheit_funktion"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE objekt_status ADD VALUE IF NOT EXISTS 'ausgesondert'")
def downgrade() -> None:
# Postgres kennt kein ALTER TYPE ... DROP VALUE - Downgrade würde eine
# komplette Enum-Neuerstellung + Tabellen-Migration brauchen, gleiches
# Muster wie bei den bisherigen ADD-VALUE-Migrationen (0014/0040) auch
# nicht rückgängig gemacht.
pass
+33
View File
@@ -1,4 +1,5 @@
import uuid
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
@@ -16,6 +17,11 @@ from app.models.stammdaten import Material, Nummernschema, Objekttyp
from app.models.vorlage import Beladungsvorlage
from app.services import historie as historie_service
from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf
from app.services.uebergabeliste import (
generiere_uebergabeliste_pdf,
hole_standort_name,
hole_uebergabepositionen,
)
LabelFormat = Literal["code128", "qr"]
from app.schemas.objekt import (
@@ -620,6 +626,33 @@ async def aendere_zaehlerstand(
return details
@router.get("/objekte/{objekt_id}/uebergabeliste")
async def objekt_uebergabeliste(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> Response:
"""ASSET-013 (Aussonderungs-Teil): reines Druck-PDF zum handschriftlichen
Unterschreiben bei Aussonderung, gleicher Mechanismus wie
GET /verleihe/{id}/uebergabeliste (app/services/uebergabeliste.py) - hier
ohne externe Stelle/Verleih-Datensatz, da Aussonderung kein Verleih-
Vorgang ist."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
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="–",
anlass="Aussonderung",
zeitpunkt_text=datetime.now().strftime("%d.%m.%Y"),
positionen=positionen,
)
return Response(content=pdf, media_type="application/pdf")
@router.put("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
async def setze_fahrzeugdetails(
objekt_id: int,
+3
View File
@@ -11,6 +11,9 @@ class ObjektStatus(str, enum.Enum):
aktiv = "aktiv"
ausser_dienst = "ausser_dienst"
in_wartung = "in_wartung"
# ASSET-013: dauerhaft aus dem Bestand entfernt - bewusst getrennt von
# ausser_dienst (bleibt temporär/Werkstatt-Bedeutung).
ausgesondert = "ausgesondert"
objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False)
+11 -1
View File
@@ -130,8 +130,18 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st
# ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
# aktiver Grund weiter unten erscheint statt das Objekt komplett auszublenden.
# ausgesondert (ASSET-013) genauso ausgeblendet - dauerhaft entfernt ist
# noch eindeutiger "zählt nicht mit" als temporär außer Dienst.
objekte = (
(await db.execute(select(Objekt).where(Objekt.status != ObjektStatus.ausser_dienst))).scalars().all()
(
await db.execute(
select(Objekt).where(
Objekt.status.notin_([ObjektStatus.ausser_dienst, ObjektStatus.ausgesondert])
)
)
)
.scalars()
.all()
)
offene_fehlbestaende = (
+60
View File
@@ -0,0 +1,60 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_objekt_kann_ausgesondert_werden(client, objekt_mit_position, admin_user):
"""ASSET-013 (Aussonderungs-Teil): neuer ObjektStatus-Wert, gesetzt über
den bestehenden generischen PATCH /objekte/{id}-Endpunkt (kein neuer
Status-Endpunkt nötig)."""
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
response = await client.patch(
f"/api/v1/objekte/{objekt.id}", json={"status": "ausgesondert"}, headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["status"] == "ausgesondert"
@pytest.mark.asyncio
async def test_ausgesondertes_objekt_zaehlt_nicht_in_einsatzbereitschaft(
client, objekt_mit_position, admin_user, materialverantwortlicher_user
):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "ausgesondert"}, headers=auth_header(token))
verantwortlicher_token = await login(client, "materialverantwortlicher1")
response = await client.get(
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
)
assert response.json()["gesamt"] == 0
@pytest.mark.asyncio
async def test_aussonderung_uebergabeliste_pdf(client, objekt_mit_position, admin_user):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "ausgesondert"}, headers=auth_header(token))
pdf_response = await client.get(f"/api/v1/objekte/{objekt.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_uebergabeliste_404_bei_unbekanntem_objekt(client, admin_user):
token = await login(client, "admin1")
response = await client.get("/api/v1/objekte/999999/uebergabeliste", headers=auth_header(token))
assert response.status_code == 404
@pytest.mark.asyncio
async def test_uebergabeliste_nur_materialverantwortliche(client, objekt_mit_position, mitarbeiter_user):
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
response = await client.get(f"/api/v1/objekte/{objekt.id}/uebergabeliste", headers=auth_header(token))
assert response.status_code == 403
+5
View File
@@ -219,6 +219,11 @@ export function ladeVerleihUebergabeliste(verleihId: string): Promise<void> {
return ladeUndOeffneInNeuemTab(`/verleihe/${verleihId}/uebergabeliste`);
}
// ASSET-013 (Aussonderungs-Teil): gleiche Übergabeliste, ohne Verleih-Bezug.
export function ladeObjektUebergabeliste(objektId: number): Promise<void> {
return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/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`, {
+11
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { ladeObjektUebergabeliste } from "../../api/client";
import type { Objekt } from "../../api/types";
import { ObjektAktionenFormulare } from "./ObjektAktionenFormulare";
import { STATUS_OPTIONEN, type ObjektZeilenAktionen } from "./objektZeileTypen";
@@ -8,6 +9,7 @@ const STATUS_BADGE: Record<string, string> = {
aktiv: "badge-success",
in_wartung: "badge-warning",
ausser_dienst: "badge-danger",
ausgesondert: "badge-danger",
};
export function ObjektZeile({ o, a }: { o: Objekt; a: ObjektZeilenAktionen }) {
@@ -54,6 +56,15 @@ export function ObjektZeile({ o, a }: { o: Objekt; a: ObjektZeilenAktionen }) {
{a.duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
)}
{o.status === "ausgesondert" && (
<button
className="btn btn-secondary"
onClick={() => ladeObjektUebergabeliste(o.id).catch(() => {})}
title="Übergabeliste zum Unterschreiben drucken"
>
Übergabeliste
</button>
)}
</div>
</td>
</tr>
@@ -4,6 +4,7 @@ export const STATUS_OPTIONEN = [
{ wert: "aktiv", label: "Aktiv" },
{ wert: "in_wartung", label: "In Wartung" },
{ wert: "ausser_dienst", label: "Außer Dienst" },
{ wert: "ausgesondert", label: "Ausgesondert" },
];
/** Gebündelte Aktionen/State für eine Objekt-Zeile - identisch für Tabellen-