feat(objekt): letzte abgeschlossene Kontrolle je Objekt anzeigen
CI / backend-tests (push) Failing after 1m28s
CI / frontend-build (push) Successful in 17s

Bisher nirgends vermerkt/abrufbar, wann ein Objekt zuletzt komplett
kontrolliert wurde (nur roh in kontrolle.beendet_am ableitbar). Neues Feld
ObjektRead.letzte_kontrolle_am (jüngste Kontrolle mit status=abgeschlossen),
angezeigt in Objektliste und Admin-Objektsektion ("noch nie kontrolliert"
falls keine).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 11:00:49 +02:00
co-authored by Claude Sonnet 5
parent 69d6956d23
commit c3f141f7b0
10 changed files with 6599 additions and 14 deletions
+202
View File
@@ -3696,3 +3696,205 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjektSection.tsx | 94 ++++++++++++++++++++++++++++++++++++----------------------------------
---
## 2026-09-05 10:32 10:33 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
- 54fc296 feat(objekt): Zugfahrzeug-Flag am Objekttyp + Leitungs-Dashboard-Kacheln
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:33 10:33 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:33 10:35 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:35 10:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:36 10:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:37 10:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 21 ++++
- backend/DEVLOG.md | 18 ++++
- backend/alembic/versions/0011_objekttyp_zugfahrzeug.py | 31 ++++++
- backend/app/api/v1/endpoints/dashboard.py | 33 ++++++-
- backend/app/api/v1/endpoints/objekte.py | 25 +++--
- backend/app/models/stammdaten.py | 4 +
- backend/app/schemas/dashboard.py | 31 ++++++
- backend/app/schemas/stammdaten.py | 2 +
- backend/app/services/dashboard.py | 138 ++++++++++++++++++++++++++
- backend/tests/conftest.py | 4 +-
- backend/tests/test_dashboard.py | 130 +++++++++++++++++++++++++
- backend/tests/test_objekte.py | 66 ++++++++++++-
- frontend/src/App.tsx | 18 ++++
- frontend/src/api/types.ts | 18 ++++
- frontend/src/components/AppShell.tsx | 10 ++
- frontend/src/pages/AdminPage.tsx | 1 +
- frontend/src/pages/DashboardPage.tsx | 161 +++++++++++++++++++++++++++++++
- frontend/src/pages/FehlbestandListePage.tsx | 152 +++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 16 ++-
- frontend/src/pages/admin/ObjekttypSection.tsx | 32 +++++-
---
## 2026-09-05 10:38 10:39 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 69d6956 feat(login): Verantwortliche landen nach Login auf /dashboard
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 ++++++++++-
---
## 2026-09-05 10:40 10:40 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 ++++++++++-
---
+34 -12
View File
@@ -24,12 +24,27 @@ from app.schemas.objekt import (
)
from app.services.objekte import (
dupliziere_objekt,
letzte_kontrollen_map,
naechster_freier_code,
objekt_aus_vorlage,
sollmenge_effektiv,
sollmenge_map,
)
def _objekt_read(objekt: Objekt, letzte_kontrolle_am=None) -> ObjektRead:
return ObjektRead(
id=objekt.id,
code=objekt.code,
name=objekt.name,
objekttyp_id=objekt.objekttyp_id,
vorlage_id=objekt.vorlage_id,
standort_id=objekt.standort_id,
status=objekt.status,
fahrzeug_id=objekt.fahrzeug_id,
letzte_kontrolle_am=letzte_kontrolle_am,
)
router = APIRouter()
_admin_only = require_roles(RolleTyp.administration)
@@ -57,25 +72,28 @@ async def liste_objekte(
standort_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Objekt]:
) -> list[ObjektRead]:
stmt = select(Objekt)
if standort_id is not None:
stmt = stmt.where(Objekt.standort_id == standort_id)
result = await db.execute(stmt)
return list(result.scalars().all())
objekte = list(result.scalars().all())
letzte_map = await letzte_kontrollen_map(db, [o.id for o in objekte])
return [_objekt_read(o, letzte_map.get(o.id)) for o in objekte]
@router.get("/objekte/code/{code}", response_model=ObjektRead)
async def hole_objekt_per_code(
code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Objekt:
) -> ObjektRead:
"""Lookup per QR/Barcode-Code (Karte 10). Muss VOR /objekte/{id} registriert
sein, sonst greift FastAPI/Starlette-Routing zuerst die generische ID-Route."""
result = await db.execute(select(Objekt).where(Objekt.code == code))
objekt = result.scalar_one_or_none()
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.get("/objekte/naechster-code")
@@ -129,11 +147,12 @@ async def hole_objektposition_per_code(
@router.get("/objekte/{objekt_id}", response_model=ObjektRead)
async def hole_objekt(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Objekt:
) -> ObjektRead:
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.get("/objekte/{objekt_id}/label.pdf")
@@ -205,7 +224,7 @@ async def liste_objektpositionen(
@router.post("/objekte", response_model=ObjektRead, status_code=status.HTTP_201_CREATED)
async def erstelle_objekt(
payload: ObjektCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Objekt:
) -> ObjektRead:
"""Prompt 09: neues Objekt aus einer Beladungsvorlage, startet "nicht kontrolliert"
(Ist-Menge 0 in allen übernommenen Positionen)."""
vorlage = await db.get(Beladungsvorlage, payload.vorlage_id)
@@ -218,7 +237,7 @@ async def erstelle_objekt(
zustaendiger_server_id = hauptserver.scalar_one()
try:
return await objekt_aus_vorlage(
objekt = await objekt_aus_vorlage(
db,
code=payload.code,
name=payload.name,
@@ -229,12 +248,13 @@ async def erstelle_objekt(
)
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc
return _objekt_read(objekt)
@router.patch("/objekte/{objekt_id}", response_model=ObjektRead)
async def aendere_objekt(
objekt_id: int, payload: ObjektUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Objekt:
) -> ObjektRead:
"""Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu
jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und
direkter Zyklus (A↔B) werden geprüft, mehrstufige Ketten sind fachlich
@@ -259,7 +279,8 @@ async def aendere_objekt(
await db.flush()
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.post(
@@ -270,7 +291,7 @@ async def dupliziere(
payload: ObjektDuplizierenRequest,
db: AsyncSession = Depends(get_db),
_=Depends(_admin_only),
) -> Objekt:
) -> ObjektRead:
"""Prompt 09: Struktur/Sollmengen kopiert, Ist-Menge/SN/Ablauf/Charge NICHT,
neuer eindeutiger Code Pflicht."""
quelle = await db.get(Objekt, objekt_id)
@@ -278,7 +299,7 @@ async def dupliziere(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quell-Objekt nicht gefunden")
try:
return await dupliziere_objekt(
neues_objekt = await dupliziere_objekt(
db,
quelle=quelle,
neuer_code=payload.neuer_code,
@@ -288,6 +309,7 @@ async def dupliziere(
)
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc
return _objekt_read(neues_objekt)
@router.patch("/objekte/{objekt_id}/positionen/{position_id}", response_model=ObjektpositionRead)
+2 -1
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import date
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
@@ -27,6 +27,7 @@ class ObjektRead(BaseModel):
standort_id: int
status: ObjektStatus
fahrzeug_id: int | None
letzte_kontrolle_am: datetime | None = None
class ObjektUpdate(BaseModel):
+19 -1
View File
@@ -1,9 +1,11 @@
import re
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.kontrolle import Kontrolle, KontrollStatus
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
@@ -29,6 +31,22 @@ async def naechster_freier_code(db: AsyncSession, spalte, praefix: str) -> str:
return f"{praefix}-{hoechste + 1:0{breite}d}"
async def letzte_kontrollen_map(db: AsyncSession, objekt_ids: list[int]) -> dict[int, datetime]:
"""Nutzer-Frage: "wann war die letzte komplette Kontrolle vom Objekt" - bisher
nirgends vermerkt (kein Cache-Feld am Objekt, nur ableitbar aus kontrolle.
beendet_am). Liefert je Objekt den jüngsten Abschluss-Zeitpunkt einer
Kontrolle mit status=abgeschlossen; Objekte ohne abgeschlossene Kontrolle
fehlen im Ergebnis (Aufrufer behandelt das als "noch nie kontrolliert")."""
if not objekt_ids:
return {}
result = await db.execute(
select(Kontrolle.objekt_id, func.max(Kontrolle.beendet_am))
.where(Kontrolle.objekt_id.in_(objekt_ids), Kontrolle.status == KontrollStatus.abgeschlossen)
.group_by(Kontrolle.objekt_id)
)
return {objekt_id: beendet_am for objekt_id, beendet_am in result.all()}
async def sollmenge_map(db: AsyncSession, vorlage_id: int | None) -> dict[int, Decimal]:
"""material_id -> Sollmenge laut Vorlage (Prompt 10: Basis für die dynamische
Auflösung, wenn eine Objektposition keinen Override hat)."""
+32
View File
@@ -5,6 +5,38 @@ from app.schemas.vorlage import VorlagenpositionCreate
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_letzte_kontrolle_am_wird_erst_nach_abschluss_gesetzt(
client, objekt_mit_position, mitarbeiter_user
):
"""Nutzer-Frage: wann war die letzte KOMPLETTE Kontrolle - vorher (None/
"noch nie"), nach starte_kontrolle allein noch nicht (erst status=abgeschlossen
zählt), erst nach /abschliessen gesetzt."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
vor_kontrolle = await client.get(f"/api/v1/objekte/{objekt.id}", headers=auth_header(token))
assert vor_kontrolle.json()["letzte_kontrolle_am"] is None
start = await client.post(
f"/api/v1/objekte/{objekt.id}/kontrollen", json={"uebernehmen": False}, headers=auth_header(token)
)
kontrolle_id = start.json()["id"]
await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "10"},
headers=auth_header(token),
)
waehrend = await client.get(f"/api/v1/objekte/{objekt.id}", headers=auth_header(token))
assert waehrend.json()["letzte_kontrolle_am"] is None # noch nicht abgeschlossen
await client.post(f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token))
nach_abschluss = await client.get(f"/api/v1/objekte/{objekt.id}", headers=auth_header(token))
assert nach_abschluss.json()["letzte_kontrolle_am"] is not None
@pytest.mark.asyncio
async def test_objekt_aus_vorlage_startet_unkontrolliert(
client, db_session, objekttyp_factory, material_factory, standort_factory, admin_user
+33
View File
@@ -191,3 +191,36 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjektSection.tsx | 29 ++++++++++++++++++++++++-----
---
## 2026-09-05 10:40 10:47 (6m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 ++++++++++-
---
## 2026-09-05 10:50 10:50 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 ++++++++++-
---
## 2026-09-05 10:51 10:51 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 ++++++++++-
---
+6264
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@ export interface Objekt {
standort_id: number;
status: string;
fahrzeug_id: number | null;
letzte_kontrolle_am: string | null;
}
export interface Objektposition {
+5
View File
@@ -76,6 +76,11 @@ export function ObjektListPage() {
<span className="badge badge-neutral">{objekt.status}</span>
</div>
<div className="card-subtitle">{objekt.code}</div>
<div className="text-muted" style={{ fontSize: "0.85rem" }}>
{objekt.letzte_kontrolle_am
? `Letzte Kontrolle: ${new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")}`
: "Noch nie kontrolliert"}
</div>
</Link>
</li>
))}
@@ -277,6 +277,13 @@ export function ObjektSection({
{o.fahrzeug_id !== null && (
<span className="text-muted"> Fahrzeug: {objektName(o.fahrzeug_id)}</span>
)}
<span className="text-muted">
{" "}
{" "}
{o.letzte_kontrolle_am
? `zuletzt kontrolliert ${new Date(o.letzte_kontrolle_am).toLocaleDateString("de-DE")}`
: "noch nie kontrolliert"}
</span>
</span>
<div className="row">
<button