diff --git a/backend/app/api/v1/endpoints/kontrollen.py b/backend/app/api/v1/endpoints/kontrollen.py index 2af5563..40964fe 100644 --- a/backend/app/api/v1/endpoints/kontrollen.py +++ b/backend/app/api/v1/endpoints/kontrollen.py @@ -134,6 +134,9 @@ async def erfasse_kontrollposition( material_id=material_id, istmenge=payload.istmenge, zustaendiger_server_id=zustaendiger_server_id, + ablaufdatum=payload.ablaufdatum, + chargennummer=payload.chargennummer, + seriennummer=payload.seriennummer, ) return KontrollpositionRead( id=kontrollposition.id, diff --git a/backend/app/schemas/kontrolle.py b/backend/app/schemas/kontrolle.py index 3374263..fb236f9 100644 --- a/backend/app/schemas/kontrolle.py +++ b/backend/app/schemas/kontrolle.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from pydantic import BaseModel, ConfigDict @@ -24,6 +24,14 @@ class KontrolleRead(BaseModel): class KontrollpositionInput(BaseModel): istmenge: Decimal + # Nutzer-Vorgabe: Ablauf/Charge/SN gehören zur Erfassung während der + # Kontrolle dazu, wie die Menge - werden hier optional mitgegeben und auf + # der Objektposition gespeichert (Vier-Kernbegriffe bleiben unberührt: + # nur Objektposition.istmenge darf ausschließlich Nachfüllung ändern, + # diese drei Felder sind unabhängig davon). + ablaufdatum: date | None = None + chargennummer: str | None = None + seriennummer: str | None = None class KontrollpositionRead(BaseModel): diff --git a/backend/app/services/kontrolle/erfassung.py b/backend/app/services/kontrolle/erfassung.py index b4b6e0d..002596c 100644 --- a/backend/app/services/kontrolle/erfassung.py +++ b/backend/app/services/kontrolle/erfassung.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timezone +from datetime import date, datetime, timezone from decimal import Decimal from sqlalchemy import select @@ -22,12 +22,20 @@ async def erfasse_position( material_id: int, istmenge: Decimal, zustaendiger_server_id: int, + ablaufdatum: date | None = None, + chargennummer: str | None = None, + seriennummer: str | None = None, ) -> tuple[Kontrollposition, uuid.UUID | None]: """Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine - Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen.""" + Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen. + + Ablaufdatum/Chargennummer/Seriennummer sind KEINE Kernbegriffe-Verletzung: + sie beschreiben das physische Exemplar, nicht die Ist-Menge, und werden + daher hier - auf Wunsch als Teil der normalen Kontroll-Erfassung, nicht nur + als separate Admin-Pflege - direkt mitgepflegt.""" vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id) result = await db.execute( select(Objektposition).where( @@ -37,6 +45,14 @@ async def erfasse_position( objektposition = result.scalar_one_or_none() sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0) + if objektposition is not None: + if ablaufdatum is not None: + objektposition.ablaufdatum = ablaufdatum + if chargennummer is not None: + objektposition.chargennummer = chargennummer + if seriennummer is not None: + objektposition.seriennummer = seriennummer + abweichung = istmenge != sollmenge result = await db.execute( diff --git a/backend/tests/test_kontrolle.py b/backend/tests/test_kontrolle.py index 93fda86..c9df84c 100644 --- a/backend/tests/test_kontrolle.py +++ b/backend/tests/test_kontrolle.py @@ -3,6 +3,7 @@ from sqlalchemy import select from app.models.fehlbestand import Fehlbestand from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition +from app.models.objektposition import Objektposition from tests.conftest import auth_header, login @@ -37,6 +38,35 @@ async def test_fehlbestand_entsteht_bei_abweichung(client, db_session, objekt_mi assert fehlbestand.status.value == "offen" +@pytest.mark.asyncio +async def test_ablauf_charge_werden_bei_erfassung_mitgepflegt(client, db_session, objekt_mit_position, mitarbeiter_user): + """Nutzer-Vorgabe: Ablauf/Charge/SN werden während der Kontroll-Erfassung + mitgepflegt, wie die Menge - nicht nur separat über die Admin-Pflege.""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + kontrolle_id = start.json()["id"] + + put_response = await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "10", "ablaufdatum": "2027-06-01", "chargennummer": "LOT-1"}, + headers=auth_header(token), + ) + assert put_response.status_code == 200 + + result = await db_session.execute( + select(Objektposition).where( + Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id + ) + ) + position = result.scalar_one() + assert str(position.ablaufdatum) == "2027-06-01" + assert position.chargennummer == "LOT-1" + # Vier-Kernbegriffe bleiben unberührt: istmenge auf Objektposition wird von + # der Kontrolle weiterhin NICHT gesetzt (nur von Nachfüllung). + assert position.istmenge == 0 + + @pytest.mark.asyncio async def test_ueberbestand_erzeugt_keinen_fehlbestand(client, db_session, objekt_mit_position, mitarbeiter_user): objekt, material = objekt_mit_position diff --git a/frontend/src/offline/queue.ts b/frontend/src/offline/queue.ts index 7a2e9aa..4fc9289 100644 --- a/frontend/src/offline/queue.ts +++ b/frontend/src/offline/queue.ts @@ -11,7 +11,13 @@ const store = createStore("mabea-offline-queue", "kontrollpositionen"); export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler"; -export interface QueueItem { +export interface ZusatzErfassung { + ablaufdatum?: string | null; + chargennummer?: string | null; + seriennummer?: string | null; +} + +export interface QueueItem extends ZusatzErfassung { key: string; // `${kontrolleId}:${materialId}` kontrolleId: string; materialId: number; @@ -25,13 +31,19 @@ function keyFor(kontrolleId: string, materialId: number): string { return `${kontrolleId}:${materialId}`; } -export async function enqueue(kontrolleId: string, materialId: number, istmenge: string): Promise { +export async function enqueue( + kontrolleId: string, + materialId: number, + istmenge: string, + zusatz: ZusatzErfassung = {} +): Promise { const key = keyFor(kontrolleId, materialId); const item: QueueItem = { key, kontrolleId, materialId, istmenge, + ...zusatz, status: "wartet", fehlerText: null, fehlbestandId: null, @@ -63,7 +75,15 @@ async function sendeEintrag(item: QueueItem): Promise { try { const ergebnis = await apiRequest( `/kontrollen/${item.kontrolleId}/positionen/${item.materialId}`, - { method: "PUT", body: { istmenge: item.istmenge } } + { + method: "PUT", + body: { + istmenge: item.istmenge, + ablaufdatum: item.ablaufdatum ?? null, + chargennummer: item.chargennummer ?? null, + seriennummer: item.seriennummer ?? null, + }, + } ); // Erfolgreich übertragen (idempotent - ein erneutes Senden desselben Werts // durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand, diff --git a/frontend/src/pages/kontrolle/PositionCard.tsx b/frontend/src/pages/kontrolle/PositionCard.tsx index 9fb4d25..f283268 100644 --- a/frontend/src/pages/kontrolle/PositionCard.tsx +++ b/frontend/src/pages/kontrolle/PositionCard.tsx @@ -3,12 +3,19 @@ import type { PositionZustand } from "./types"; interface Props { zustand: PositionZustand; - onEingabeAendern: (materialId: number, wert: string) => void; - onSenden: (materialId: number, istmenge: string) => void; - onErneutVersuchen: (materialId: number, istmenge: string) => void; + onEingabeAendern: (materialId: number, feld: keyof PositionZustand, wert: string) => void; + onSenden: (materialId: number, zustand: PositionZustand) => void; + onErneutVersuchen: (materialId: number, zustand: PositionZustand) => void; } +/** + * Nutzer-Vorgabe: Ablauf/Charge/SN gehören zur Kontroll-Erfassung dazu, wie + * die Menge - kein separater Admin-Schritt. Felder erscheinen nur, wenn der + * Materialtyp sie verlangt (ablauf_charge -> Ablauf+Charge, geraet_sn -> SN). + */ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVersuchen }: Props) { + const materialtyp = zustand.material?.materialtyp; + return (
  • @@ -23,17 +30,43 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers type="number" className="input" value={zustand.eingabe} - onChange={(e) => onEingabeAendern(zustand.position.material_id, e.target.value)} + onChange={(e) => onEingabeAendern(zustand.position.material_id, "eingabe", e.target.value)} style={{ width: "6rem" }} />
    + {materialtyp === "ablauf_charge" && ( +
    + onEingabeAendern(zustand.position.material_id, "ablaufdatum", e.target.value)} + /> + onEingabeAendern(zustand.position.material_id, "chargennummer", e.target.value)} + /> +
    + )} + {materialtyp === "geraet_sn" && ( +
    + onEingabeAendern(zustand.position.material_id, "seriennummer", e.target.value)} + /> +
    + )} {zustand.status === "fehler" && (

    @@ -41,7 +74,7 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers

    diff --git a/frontend/src/pages/kontrolle/types.ts b/frontend/src/pages/kontrolle/types.ts index 177182b..10b282a 100644 --- a/frontend/src/pages/kontrolle/types.ts +++ b/frontend/src/pages/kontrolle/types.ts @@ -8,6 +8,9 @@ export interface PositionZustand { material: Material | undefined; status: PositionStatus; eingabe: string; + ablaufdatum: string; + chargennummer: string; + seriennummer: string; fehlerText: string | null; } diff --git a/frontend/src/pages/kontrolle/useKontrolle.ts b/frontend/src/pages/kontrolle/useKontrolle.ts index f83fc3c..d0450bb 100644 --- a/frontend/src/pages/kontrolle/useKontrolle.ts +++ b/frontend/src/pages/kontrolle/useKontrolle.ts @@ -68,6 +68,9 @@ export function useKontrolle(objektId: string | undefined) { material: materialById.get(p.material_id), status: "unbestaetigt", eingabe: p.sollmenge_effektiv, + ablaufdatum: p.ablaufdatum ?? "", + chargennummer: p.chargennummer ?? "", + seriennummer: p.seriennummer ?? "", fehlerText: null, })) ); @@ -102,25 +105,29 @@ export function useKontrolle(objektId: string | undefined) { if (online) void synchronisiereQueue(); }, [online]); - function eingabeAendern(materialId: number, wert: string) { + function eingabeAendern(materialId: number, feld: keyof PositionZustand, wert: string) { setPositionen((bisherige) => - bisherige.map((z) => (z.position.material_id === materialId ? { ...z, eingabe: wert } : z)) + bisherige.map((z) => (z.position.material_id === materialId ? { ...z, [feld]: wert } : z)) ); } - async function positionSenden(materialId: number, istmenge: string) { + async function positionSenden(materialId: number, zustand: PositionZustand) { if (!kontrolle) return; - await enqueue(kontrolle.id, materialId, istmenge); + await enqueue(kontrolle.id, materialId, zustand.eingabe, { + ablaufdatum: zustand.ablaufdatum || null, + chargennummer: zustand.chargennummer || null, + seriennummer: zustand.seriennummer || null, + }); setPositionen((bisherige) => bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z)) ); void synchronisiereQueue(); } - async function fehlerErneutVersuchen(materialId: number, istmenge: string) { + async function fehlerErneutVersuchen(materialId: number, zustand: PositionZustand) { if (!kontrolle) return; await entferneFehlerEintrag(kontrolle.id, materialId); - await positionSenden(materialId, istmenge); + await positionSenden(materialId, zustand); } const alleUebertragen = useMemo(