feat(objekt): letzte abgeschlossene Kontrolle je Objekt anzeigen
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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user