Ablauf/Charge/SN gehören zur Kontroll-Erfassung, nicht nur zur Admin-Pflege
CI / backend-tests (push) Successful in 54s
CI / frontend-build (push) Successful in 18s

Nutzer-Vorgabe: "Ablauf/Charge/SN gehört mit zu den Kontrollen wie auch die
Menge". PUT .../positionen/{material_id} akzeptiert jetzt optional
ablaufdatum/chargennummer/seriennummer und schreibt sie auf die
Objektposition (Vier-Kernbegriffe bleiben unberührt: nur diese drei Felder,
niemals istmenge - das bleibt exklusiv Nachfüllung vorbehalten). Offline-
Queue und PositionCard erweitert: Felder erscheinen nur passend zum
Materialtyp (ablauf_charge -> Datum+Charge, geraet_sn -> SN). Admin-Panel
(ObjektPositionenPanel) bleibt zusätzlich für nachträgliche Korrekturen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-04 01:11:17 +02:00
co-authored by Claude Sonnet 5
parent a6e68b0a94
commit 0988f8d026
8 changed files with 138 additions and 18 deletions
@@ -134,6 +134,9 @@ async def erfasse_kontrollposition(
material_id=material_id, material_id=material_id,
istmenge=payload.istmenge, istmenge=payload.istmenge,
zustaendiger_server_id=zustaendiger_server_id, zustaendiger_server_id=zustaendiger_server_id,
ablaufdatum=payload.ablaufdatum,
chargennummer=payload.chargennummer,
seriennummer=payload.seriennummer,
) )
return KontrollpositionRead( return KontrollpositionRead(
id=kontrollposition.id, id=kontrollposition.id,
+9 -1
View File
@@ -1,5 +1,5 @@
import uuid import uuid
from datetime import datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -24,6 +24,14 @@ class KontrolleRead(BaseModel):
class KontrollpositionInput(BaseModel): class KontrollpositionInput(BaseModel):
istmenge: Decimal 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): class KontrollpositionRead(BaseModel):
+18 -2
View File
@@ -1,5 +1,5 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
@@ -22,12 +22,20 @@ async def erfasse_position(
material_id: int, material_id: int,
istmenge: Decimal, istmenge: Decimal,
zustaendiger_server_id: int, zustaendiger_server_id: int,
ablaufdatum: date | None = None,
chargennummer: str | None = None,
seriennummer: str | None = None,
) -> tuple[Kontrollposition, uuid.UUID | None]: ) -> tuple[Kontrollposition, uuid.UUID | None]:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die """Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die
Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine 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) vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute( result = await db.execute(
select(Objektposition).where( select(Objektposition).where(
@@ -37,6 +45,14 @@ async def erfasse_position(
objektposition = result.scalar_one_or_none() objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0) 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 abweichung = istmenge != sollmenge
result = await db.execute( result = await db.execute(
+30
View File
@@ -3,6 +3,7 @@ from sqlalchemy import select
from app.models.fehlbestand import Fehlbestand from app.models.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objektposition import Objektposition
from tests.conftest import auth_header, login 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" 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 @pytest.mark.asyncio
async def test_ueberbestand_erzeugt_keinen_fehlbestand(client, db_session, objekt_mit_position, mitarbeiter_user): async def test_ueberbestand_erzeugt_keinen_fehlbestand(client, db_session, objekt_mit_position, mitarbeiter_user):
objekt, material = objekt_mit_position objekt, material = objekt_mit_position
+23 -3
View File
@@ -11,7 +11,13 @@ const store = createStore("mabea-offline-queue", "kontrollpositionen");
export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler"; 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}` key: string; // `${kontrolleId}:${materialId}`
kontrolleId: string; kontrolleId: string;
materialId: number; materialId: number;
@@ -25,13 +31,19 @@ function keyFor(kontrolleId: string, materialId: number): string {
return `${kontrolleId}:${materialId}`; return `${kontrolleId}:${materialId}`;
} }
export async function enqueue(kontrolleId: string, materialId: number, istmenge: string): Promise<void> { export async function enqueue(
kontrolleId: string,
materialId: number,
istmenge: string,
zusatz: ZusatzErfassung = {}
): Promise<void> {
const key = keyFor(kontrolleId, materialId); const key = keyFor(kontrolleId, materialId);
const item: QueueItem = { const item: QueueItem = {
key, key,
kontrolleId, kontrolleId,
materialId, materialId,
istmenge, istmenge,
...zusatz,
status: "wartet", status: "wartet",
fehlerText: null, fehlerText: null,
fehlbestandId: null, fehlbestandId: null,
@@ -63,7 +75,15 @@ async function sendeEintrag(item: QueueItem): Promise<void> {
try { try {
const ergebnis = await apiRequest<Kontrollposition>( const ergebnis = await apiRequest<Kontrollposition>(
`/kontrollen/${item.kontrolleId}/positionen/${item.materialId}`, `/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 // Erfolgreich übertragen (idempotent - ein erneutes Senden desselben Werts
// durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand, // durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand,
+39 -6
View File
@@ -3,12 +3,19 @@ import type { PositionZustand } from "./types";
interface Props { interface Props {
zustand: PositionZustand; zustand: PositionZustand;
onEingabeAendern: (materialId: number, wert: string) => void; onEingabeAendern: (materialId: number, feld: keyof PositionZustand, wert: string) => void;
onSenden: (materialId: number, istmenge: string) => void; onSenden: (materialId: number, zustand: PositionZustand) => void;
onErneutVersuchen: (materialId: number, istmenge: string) => 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) { export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVersuchen }: Props) {
const materialtyp = zustand.material?.materialtyp;
return ( return (
<li className="card"> <li className="card">
<div className="card-title-row"> <div className="card-title-row">
@@ -23,17 +30,43 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
type="number" type="number"
className="input" className="input"
value={zustand.eingabe} 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" }} style={{ width: "6rem" }}
/> />
<button <button
className="btn btn-primary" className="btn btn-primary"
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => onSenden(zustand.position.material_id, zustand.eingabe)} onClick={() => onSenden(zustand.position.material_id, zustand)}
> >
Bestätigen Bestätigen
</button> </button>
</div> </div>
{materialtyp === "ablauf_charge" && (
<div className="row" style={{ marginTop: "0.5rem" }}>
<input
type="date"
className="input"
value={zustand.ablaufdatum}
onChange={(e) => onEingabeAendern(zustand.position.material_id, "ablaufdatum", e.target.value)}
/>
<input
className="input"
placeholder="Chargennummer"
value={zustand.chargennummer}
onChange={(e) => onEingabeAendern(zustand.position.material_id, "chargennummer", e.target.value)}
/>
</div>
)}
{materialtyp === "geraet_sn" && (
<div className="row" style={{ marginTop: "0.5rem" }}>
<input
className="input"
placeholder="Seriennummer"
value={zustand.seriennummer}
onChange={(e) => onEingabeAendern(zustand.position.material_id, "seriennummer", e.target.value)}
/>
</div>
)}
{zustand.status === "fehler" && ( {zustand.status === "fehler" && (
<div style={{ marginTop: "0.6rem" }}> <div style={{ marginTop: "0.6rem" }}>
<p className="text-muted" style={{ color: "var(--color-danger)" }}> <p className="text-muted" style={{ color: "var(--color-danger)" }}>
@@ -41,7 +74,7 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
</p> </p>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => onErneutVersuchen(zustand.position.material_id, zustand.eingabe)} onClick={() => onErneutVersuchen(zustand.position.material_id, zustand)}
> >
Erneut versuchen Erneut versuchen
</button> </button>
+3
View File
@@ -8,6 +8,9 @@ export interface PositionZustand {
material: Material | undefined; material: Material | undefined;
status: PositionStatus; status: PositionStatus;
eingabe: string; eingabe: string;
ablaufdatum: string;
chargennummer: string;
seriennummer: string;
fehlerText: string | null; fehlerText: string | null;
} }
+13 -6
View File
@@ -68,6 +68,9 @@ export function useKontrolle(objektId: string | undefined) {
material: materialById.get(p.material_id), material: materialById.get(p.material_id),
status: "unbestaetigt", status: "unbestaetigt",
eingabe: p.sollmenge_effektiv, eingabe: p.sollmenge_effektiv,
ablaufdatum: p.ablaufdatum ?? "",
chargennummer: p.chargennummer ?? "",
seriennummer: p.seriennummer ?? "",
fehlerText: null, fehlerText: null,
})) }))
); );
@@ -102,25 +105,29 @@ export function useKontrolle(objektId: string | undefined) {
if (online) void synchronisiereQueue(); if (online) void synchronisiereQueue();
}, [online]); }, [online]);
function eingabeAendern(materialId: number, wert: string) { function eingabeAendern(materialId: number, feld: keyof PositionZustand, wert: string) {
setPositionen((bisherige) => 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; 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) => setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z)) bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z))
); );
void synchronisiereQueue(); void synchronisiereQueue();
} }
async function fehlerErneutVersuchen(materialId: number, istmenge: string) { async function fehlerErneutVersuchen(materialId: number, zustand: PositionZustand) {
if (!kontrolle) return; if (!kontrolle) return;
await entferneFehlerEintrag(kontrolle.id, materialId); await entferneFehlerEintrag(kontrolle.id, materialId);
await positionSenden(materialId, istmenge); await positionSenden(materialId, zustand);
} }
const alleUebertragen = useMemo( const alleUebertragen = useMemo(