Ablauf/Charge/SN gehören zur Kontroll-Erfassung, nicht nur zur Admin-Pflege
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:
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user