From 4c177052903401b9ee71f4a55300670b44ff5416 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 10:40:54 +0200 Subject: [PATCH] Einlagern-UX: 3-Wege-Barcode, Inline-Anlage aus OFF, Mehr-Chargen mit MHD Einlagern: - Barcode-Erkennung dreiteilig: bekannt -> direkt Menge; unbekannt aber in OFF -> "Anlegen & einlagern" inline; gar nicht gefunden -> manuell anlegen. - Mehrere Chargen pro Vorgang: je Zeile eigene Menge + eigenes MHD (z.B. 5 Glaeser mit unterschiedlichen Daten). Neuer Endpoint /stock/checkin/batch. Produkte/OFF: - OFF-Fuellmenge (quantity) wird in Basiseinheit + Packungsgroesse geparst (kg->g, l->ml, cl/dl); parse_quantity + Tests. - Produktformular uebernimmt Barcode aus ?barcode= und schlaegt automatisch nach, fuellt Packungsgroesse; gemeinsame offUtils (guessGroup/suggestionToProduct). Lagerorte: - Baum jetzt rekursiv ueber beliebig viele Ebenen (Schrank->Fach->Kiste->...). Co-Authored-By: Claude Opus 4.8 --- backend/app/off.py | 42 ++++++--- backend/app/routers/stock.py | 41 +++++++++ backend/app/schemas.py | 20 +++++ backend/tests/test_off.py | 24 +++++ web/src/api.js | 1 + web/src/offUtils.js | 27 ++++++ web/src/pages/CheckIn.jsx | 164 +++++++++++++++++++++++++++------- web/src/pages/Locations.jsx | 19 ++-- web/src/pages/ProductForm.jsx | 119 ++++++++++++------------ web/src/styles.css | 16 ++++ 10 files changed, 368 insertions(+), 105 deletions(-) create mode 100644 backend/tests/test_off.py create mode 100644 web/src/offUtils.js diff --git a/backend/app/off.py b/backend/app/off.py index ddcdfbe..2518290 100644 --- a/backend/app/off.py +++ b/backend/app/off.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import httpx @@ -10,17 +11,35 @@ from .config import get_settings settings = get_settings() +# Faktoren, um eine Einheit in die Basiseinheit (Gramm bzw. Milliliter) umzurechnen. +_UNIT_TO_BASE: dict[str, tuple[str, float]] = { + "kg": ("gram", 1000.0), + "g": ("gram", 1.0), + "mg": ("gram", 0.001), + "l": ("milliliter", 1000.0), + "dl": ("milliliter", 100.0), + "cl": ("milliliter", 10.0), + "ml": ("milliliter", 1.0), +} -def _guess_base_unit(quantity: str | None) -> str: - """Rät die Basiseinheit aus dem OFF-Feld 'quantity' (z.B. '500 g', '1 l').""" +_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE) + + +def parse_quantity(quantity: str | None) -> tuple[str, float | None]: + """Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'. + + Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000), + '1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None). + """ if not quantity: - return "piece" - q = quantity.lower() - if "ml" in q or "cl" in q or "l" in q or "liter" in q: - return "milliliter" - if "kg" in q or " g" in q or "gramm" in q or q.strip().endswith("g"): - return "gram" - return "piece" + return "piece", None + match = _QUANTITY_RE.search(quantity) + if not match: + return "piece", None + amount = float(match.group(1).replace(",", ".")) + base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()] + package_size = round(amount * factor, 3) + return base_unit, (package_size if package_size > 0 else None) def lookup_barcode(barcode: str) -> dict | None: @@ -65,12 +84,15 @@ def lookup_barcode(barcode: str) -> dict | None: categories = product.get("categories") or "" category_tags = product.get("categories_tags") or [] + base_unit, package_size = parse_quantity(product.get("quantity")) + return { "barcode": barcode, "name": name, "brand": (product.get("brands") or "").strip() or None, "image_url": product.get("image_front_url") or product.get("image_url") or None, - "base_unit": _guess_base_unit(product.get("quantity")), + "base_unit": base_unit, + "package_size": package_size, "quantity_text": product.get("quantity"), "category_suggestion": categories.split(",")[0].strip() if categories else None, "category_tags": category_tags, diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 40a8f0a..8dd2609 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -6,6 +6,8 @@ from ..database import get_db from ..deps import get_current_user from ..models import Lot, User from ..schemas import ( + BatchCheckInRequest, + BatchCheckInResponse, CheckInRequest, CheckInResponse, CheckOutRequest, @@ -45,6 +47,45 @@ def stock_checkin( ) +@router.post("/stock/checkin/batch", response_model=BatchCheckInResponse) +def stock_checkin_batch( + payload: BatchCheckInRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> BatchCheckInResponse: + """Mehrere Chargen desselben Produkts in einem Vorgang einlagern. + + Jede Zeile erzeugt eine eigene Charge mit eigener Menge und eigenem MHD – + z.B. 5 Gläser mit unterschiedlichen Mindesthaltbarkeitsdaten. + """ + product = resolve_product(db, payload.product_id, payload.barcode) + lots: list[Lot] = [] + try: + for line in payload.lines: + lots.append( + check_in( + db, + product=product, + quantity=line.quantity, + unit=payload.unit, + best_before=line.best_before, + location_id=line.location_id, + user=user, + note=payload.note, + ) + ) + except UnitError as exc: + db.rollback() + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + db.commit() + for lot in lots: + db.refresh(lot) + return BatchCheckInResponse( + lots=[LotOut.model_validate(lot) for lot in lots], + product_stock=current_stock(db, product.id), + ) + + @router.post("/stock/checkout", response_model=CheckOutResponse) def stock_checkout( payload: CheckOutRequest, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c981c30..af6af1c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -152,6 +152,26 @@ class CheckInResponse(BaseModel): product_stock: float +class CheckInLine(BaseModel): + """Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD).""" + quantity: float = Field(gt=0) + best_before: date | None = None + location_id: int | None = None + + +class BatchCheckInRequest(BaseModel): + product_id: int | None = None + barcode: str | None = None + unit: str + lines: list[CheckInLine] = Field(min_length=1) + note: str | None = None + + +class BatchCheckInResponse(BaseModel): + lots: list[LotOut] + product_stock: float + + class CheckOutResponse(BaseModel): affected_lots: list[dict] product_stock: float diff --git a/backend/tests/test_off.py b/backend/tests/test_off.py new file mode 100644 index 0000000..a831af0 --- /dev/null +++ b/backend/tests/test_off.py @@ -0,0 +1,24 @@ +from app.off import parse_quantity + + +def test_grams(): + assert parse_quantity("500 g") == ("gram", 500) + assert parse_quantity("500g") == ("gram", 500) + + +def test_kilograms_to_grams(): + assert parse_quantity("1 kg") == ("gram", 1000) + assert parse_quantity("1,5 kg") == ("gram", 1500) + + +def test_liters_to_milliliters(): + assert parse_quantity("1 l") == ("milliliter", 1000) + assert parse_quantity("0,5 l") == ("milliliter", 500) + assert parse_quantity("33 cl") == ("milliliter", 330) + assert parse_quantity("330 ml") == ("milliliter", 330) + + +def test_unparsable_is_piece(): + assert parse_quantity(None) == ("piece", None) + assert parse_quantity("6 Stück") == ("piece", None) + assert parse_quantity("") == ("piece", None) diff --git a/web/src/api.js b/web/src/api.js index 36b60c7..bfb9d5f 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -84,6 +84,7 @@ export const api = { // Bestand checkIn: (body) => request("/stock/checkin", { method: "POST", body }), + checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }), checkOut: (body) => request("/stock/checkout", { method: "POST", body }), listLots: (productId) => request(`/lots${productId ? `?product_id=${productId}` : ""}`), diff --git a/web/src/offUtils.js b/web/src/offUtils.js new file mode 100644 index 0000000..d065073 --- /dev/null +++ b/web/src/offUtils.js @@ -0,0 +1,27 @@ +// Gemeinsame Helfer rund um Open-Food-Facts-Vorschläge. + +// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten. +export function guessGroup(groups, suggestion) { + if (!suggestion) return ""; + const haystack = [ + suggestion.category_suggestion || "", + ...(suggestion.category_tags || []), + suggestion.name || "", + ].join(" ").toLowerCase(); + const hit = groups.find((g) => haystack.includes(g.name.toLowerCase())); + return hit ? String(hit.id) : ""; +} + +// Baut aus einem OFF-Vorschlag ein Produkt-Anlage-Payload. +export function suggestionToProduct(s, groupId = "") { + return { + barcode: s.barcode || null, + name: s.name, + brand: s.brand || null, + image_url: s.image_url || null, + base_unit: s.base_unit || "piece", + package_size: s.package_size ?? null, + min_stock: null, + group_id: groupId === "" || groupId == null ? null : Number(groupId), + }; +} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 10bac1a..7f43293 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -3,45 +3,59 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; +import { guessGroup, suggestionToProduct } from "../offUtils"; import { fmt, unitOptions, unitShort } from "../units"; +const emptyLine = () => ({ quantity: "", best_before: "" }); + export default function CheckIn() { const { isAdmin } = useAuth(); const [barcode, setBarcode] = useState(""); const [product, setProduct] = useState(null); + const [suggestion, setSuggestion] = useState(null); + const [unknownBarcode, setUnknownBarcode] = useState(null); const [results, setResults] = useState([]); const [search, setSearch] = useState(""); const [locations, setLocations] = useState([]); + const [groups, setGroups] = useState([]); - const [quantity, setQuantity] = useState(""); const [unit, setUnit] = useState(""); - const [bestBefore, setBestBefore] = useState(""); const [locationId, setLocationId] = useState(""); + const [lines, setLines] = useState([emptyLine()]); const [error, setError] = useState(null); const [info, setInfo] = useState(null); - const [unknownBarcode, setUnknownBarcode] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { api.listLocations().then(setLocations).catch(() => {}); + api.listGroups().then(setGroups).catch(() => {}); }, []); + function resetLookup() { + setSuggestion(null); + setUnknownBarcode(null); + } + function selectProduct(p) { setProduct(p); setResults([]); - setUnknownBarcode(null); + resetLookup(); setUnit(unitOptions(p)[0].value); + setLines([emptyLine()]); + setLocationId(""); } async function doLookup() { - setError(null); setInfo(null); setUnknownBarcode(null); + setError(null); setInfo(null); resetLookup(); if (!barcode) return; try { const res = await api.lookup(barcode.trim()); if (res.found && res.existing_product) { selectProduct(res.existing_product); setInfo(`Produkt erkannt: ${res.existing_product.name}`); + } else if (res.found && res.suggestion) { + setSuggestion(res.suggestion); } else { setUnknownBarcode(barcode.trim()); } @@ -50,6 +64,20 @@ export default function CheckIn() { } } + async function createFromSuggestion() { + setError(null); setBusy(true); + try { + const payload = suggestionToProduct(suggestion, guessGroup(groups, suggestion)); + const created = await api.createProduct(payload); + selectProduct(created); + setInfo(`Produkt "${created.name}" angelegt.`); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + async function doSearch(e) { e.preventDefault(); try { @@ -59,20 +87,40 @@ export default function CheckIn() { } } + function setLine(i, key, value) { + setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, [key]: value } : l))); + } + function addLine() { + setLines((ls) => [...ls, emptyLine()]); + } + function removeLine(i) { + setLines((ls) => (ls.length > 1 ? ls.filter((_, idx) => idx !== i) : ls)); + } + + const totalQty = lines.reduce((s, l) => s + (Number(l.quantity) || 0), 0); + async function submit(e) { e.preventDefault(); - setError(null); setInfo(null); setBusy(true); - try { - const res = await api.checkIn({ - product_id: product.id, - quantity: Number(quantity), - unit, - best_before: bestBefore || null, + setError(null); setInfo(null); + const payloadLines = lines + .filter((l) => Number(l.quantity) > 0) + .map((l) => ({ + quantity: Number(l.quantity), + best_before: l.best_before || null, location_id: locationId === "" ? null : Number(locationId), - }); - setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`); - setQuantity(""); - setBestBefore(""); + })); + if (payloadLines.length === 0) { + setError("Bitte mindestens eine Menge angeben."); + return; + } + setBusy(true); + try { + const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines }); + setInfo( + `${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` + + `${fmt(res.product_stock)} ${unitShort(product.base_unit)}` + ); + setLines([emptyLine()]); setProduct(await api.getProduct(product.id)); } catch (err) { setError(err.message); @@ -99,18 +147,44 @@ export default function CheckIn() { + + {suggestion && ( +
+ {suggestion.image_url + ? + : } +
+
{suggestion.name}
+
+ {suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden + {suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""} +
+
Noch nicht im Katalog.
+
+ {isAdmin ? ( + + ) : ( + Ein Administrator muss es anlegen. + )} +
+ )} + {unknownBarcode && (
- Barcode {unknownBarcode} ist unbekannt.{" "} + Barcode {unknownBarcode} ist weder im Katalog noch bei + Open Food Facts.{" "} {isAdmin - ? Produkt anlegen - : "Bitte einen Administrator bitten, das Produkt anzulegen."} + ? Manuell anlegen + : "Bitte einen Administrator bitten, es anzulegen."}
)} +

Aus Produktliste

@@ -146,23 +220,12 @@ export default function CheckIn() {
- -
-
-
- + +
+
+ Chargen + je Charge ein eigenes MHD +
+ {lines.map((line, i) => ( +
+ + + +
+ ))} + +
+ +
+ + {totalQty > 0 && ( + Summe: {fmt(totalQty)} { + unitOptions(product).find((o) => o.value === unit)?.label || "" + } + )} +
)} diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index c347741..2604aa8 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -42,13 +42,19 @@ export default function Locations() { } const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name])); - const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]); - const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid); + const ids = new Set(locations.map((l) => l.id)); + const isRoot = (l) => !l.parent_id || !ids.has(l.parent_id); + + // Rekursiv über beliebig viele Ebenen (Schrank -> Fach -> Kiste -> …). const ordered = []; - for (const r of roots) { - ordered.push({ ...r, depth: 0 }); - for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 }); + const seen = new Set(); + function walk(node, depth) { + if (seen.has(node.id)) return; // Schutz vor Zyklen + seen.add(node.id); + ordered.push({ ...node, depth }); + for (const c of locations.filter((l) => l.parent_id === node.id)) walk(c, depth + 1); } + for (const r of locations.filter(isRoot)) walk(r, 0); return (
@@ -86,7 +92,8 @@ export default function Locations() {
    {ordered.map((l) => (
  • - + + {l.depth > 0 && } {l.name} {l.depth > 0 && l.parent_id && nameById[l.parent_id] && ( diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 69cdf4d..3631832 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; +import { guessGroup } from "../offUtils"; import { BASE_UNITS, fmt, unitShort } from "../units"; const EMPTY = { @@ -10,25 +11,12 @@ const EMPTY = { base_unit: "piece", package_size: "", min_stock: "", group_id: "", }; -// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten. -function guessGroup(groups, suggestion) { - if (!suggestion) return ""; - const haystack = [ - suggestion.category_suggestion || "", - ...(suggestion.category_tags || []), - suggestion.name || "", - ] - .join(" ") - .toLowerCase(); - const hit = groups.find((g) => haystack.includes(g.name.toLowerCase())); - return hit ? String(hit.id) : ""; -} - export default function ProductForm() { const { id } = useParams(); const isNew = !id; const { isAdmin } = useAuth(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [form, setForm] = useState(EMPTY); const [product, setProduct] = useState(null); @@ -38,10 +26,53 @@ export default function ProductForm() { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); + function set(k, v) { + setForm((f) => ({ ...f, [k]: v })); + } + + function applySuggestion(s, groupsList) { + const groupGuess = guessGroup(groupsList, s); + setForm((f) => ({ + ...f, + barcode: s.barcode || f.barcode, + name: s.name || f.name, + brand: s.brand || f.brand, + image_url: s.image_url || f.image_url, + base_unit: s.base_unit || f.base_unit, + package_size: s.package_size != null ? String(s.package_size) : f.package_size, + group_id: f.group_id || groupGuess, + })); + setInfo( + "Daten von Open Food Facts übernommen." + + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") + + (groupGuess ? " Passende Gruppe vorgeschlagen." : "") + ); + } + + async function runLookup(code, groupsList) { + if (!code) return; + setError(null); + setInfo(null); + try { + const res = await api.lookup(code.trim()); + if (res.found && res.existing_product) { + setInfo("Dieses Produkt existiert bereits."); + navigate(`/products/${res.existing_product.id}`); + } else if (res.found && res.suggestion) { + applySuggestion(res.suggestion, groupsList); + } else { + setInfo("Barcode unbekannt – bitte Daten selbst eingeben."); + } + } catch (err) { + setError(err.message); + } + } + useEffect(() => { async function load() { try { - setGroups(await api.listGroups()); + const gs = await api.listGroups(); + setGroups(gs); if (!isNew) { const p = await api.getProduct(id); setProduct(p); @@ -52,6 +83,12 @@ export default function ProductForm() { group_id: p.group_id ?? "", }); setLots(await api.listLots(id)); + } else { + const bc = searchParams.get("barcode"); + if (bc) { + set("barcode", bc); + await runLookup(bc, gs); + } } } catch (err) { setError(err.message); @@ -61,42 +98,6 @@ export default function ProductForm() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); - function set(k, v) { - setForm((f) => ({ ...f, [k]: v })); - } - - async function lookup() { - if (!form.barcode) return; - setError(null); - setInfo(null); - try { - const res = await api.lookup(form.barcode); - if (res.found && res.existing_product) { - setInfo("Dieses Produkt existiert bereits."); - navigate(`/products/${res.existing_product.id}`); - } else if (res.found && res.suggestion) { - const s = res.suggestion; - const groupGuess = guessGroup(groups, s); - setForm((f) => ({ - ...f, - name: s.name || f.name, - brand: s.brand || f.brand, - image_url: s.image_url || f.image_url, - base_unit: s.base_unit || f.base_unit, - group_id: f.group_id || groupGuess, - })); - setInfo( - "Daten von Open Food Facts übernommen." + - (groupGuess ? " Passende Gruppe vorgeschlagen." : "") - ); - } else { - setInfo("Barcode unbekannt – bitte Daten selbst eingeben."); - } - } catch (err) { - setError(err.message); - } - } - function buildPayload() { return { barcode: form.barcode || null, @@ -141,15 +142,16 @@ export default function ProductForm() { } const readOnly = !isAdmin; + const unitLabel = unitShort(form.base_unit); return (

    {isNew ? "Neues Produkt" : form.name || "Produkt"}

    - {!isNew &&
    Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)}
    } + {!isNew &&
    Bestand: {fmt(product?.stock)} {unitLabel}
    }
    - +
    {error &&
    {error}
    } @@ -163,7 +165,7 @@ export default function ProductForm() { set("barcode", e.target.value)} disabled={readOnly} /> {isAdmin && ( - )} @@ -184,9 +186,10 @@ export default function ProductForm() {
    @@ -227,7 +230,7 @@ export default function ProductForm() { {lots.map((l) => ( - {fmt(l.quantity)} {unitShort(form.base_unit)} + {fmt(l.quantity)} {unitLabel} {l.best_before || "–"} {new Date(l.created_at).toLocaleDateString("de-DE")} diff --git a/web/src/styles.css b/web/src/styles.css index edcbc4d..bb0584e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -245,6 +245,22 @@ input::placeholder { color: var(--muted); opacity: 0.7; } .selected-product > .info { flex: 1; min-width: 0; } .selected-product .title { font-weight: 620; } +/* ---------- OFF suggestion ---------- */ +.suggestion { + display: flex; align-items: center; gap: var(--sp-3); + margin-top: var(--sp-3); padding: var(--sp-3); + background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); +} +.suggestion > .info { flex: 1; min-width: 0; } +.suggestion .title { font-weight: 620; } + +/* ---------- Check-in lines ---------- */ +.lines { margin-top: var(--sp-4); border-top: 1px solid var(--border); padding-top: var(--sp-3); } +.lines-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: var(--sp-2); font-weight: 600; font-size: 0.85rem; } +.line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); } +.line .btn-icon { margin-bottom: 1px; } +.line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; } + /* ---------- Stat tiles ---------- */ .stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); } .stat {