diff --git a/arbeitskacheln/04_assets.md b/arbeitskacheln/04_assets.md index 7f88dbd..1bcfeee 100644 --- a/arbeitskacheln/04_assets.md +++ b/arbeitskacheln/04_assets.md @@ -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. --- diff --git a/backend/alembic/versions/0056_objekt_status_ausgesondert.py b/backend/alembic/versions/0056_objekt_status_ausgesondert.py new file mode 100644 index 0000000..7aca771 --- /dev/null +++ b/backend/alembic/versions/0056_objekt_status_ausgesondert.py @@ -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 diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index e340ea7..f6e2332 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -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, diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index 477dfb7..2395955 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -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) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 5a4928d..d91b743 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -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 = ( diff --git a/backend/tests/test_aussonderung.py b/backend/tests/test_aussonderung.py new file mode 100644 index 0000000..ef1ee8c --- /dev/null +++ b/backend/tests/test_aussonderung.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0f03e89..df48907 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -219,6 +219,11 @@ export function ladeVerleihUebergabeliste(verleihId: string): Promise { return ladeUndOeffneInNeuemTab(`/verleihe/${verleihId}/uebergabeliste`); } +// ASSET-013 (Aussonderungs-Teil): gleiche Übergabeliste, ohne Verleih-Bezug. +export function ladeObjektUebergabeliste(objektId: number): Promise { + return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/uebergabeliste`); +} + export async function login(username: string, password: string): Promise { const body = new URLSearchParams({ username, password }); const response = await fetch(`${BASE_URL}/auth/login`, { diff --git a/frontend/src/pages/admin/ObjektZeile.tsx b/frontend/src/pages/admin/ObjektZeile.tsx index 10cf886..4451c70 100644 --- a/frontend/src/pages/admin/ObjektZeile.tsx +++ b/frontend/src/pages/admin/ObjektZeile.tsx @@ -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 = { 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"} )} + {o.status === "ausgesondert" && ( + + )} diff --git a/frontend/src/pages/admin/objektZeileTypen.ts b/frontend/src/pages/admin/objektZeileTypen.ts index 14466d4..3a6a9fc 100644 --- a/frontend/src/pages/admin/objektZeileTypen.ts +++ b/frontend/src/pages/admin/objektZeileTypen.ts @@ -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-