feat(assets): ASSET-013 Aussonderungs-Teil - ObjektStatus.ausgesondert
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:
1 parent
69f66c6d1c
commit
f4e8a83097
9 files changed
+175
-14
No files matched your search
+22
-13
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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-
|
||||
|
||||
Reference in new issue
Block a user