From 8766e35e91d0c7111558a625a3f500f75e824855 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 14:00:30 +0200 Subject: [PATCH] Artikeldaten von Open Food Facts vergleichen und einzeln uebernehmen Neuer Knopf unter "Nachschlagen" auf der Artikelseite. Er fragt OFF erneut und stellt die Antwort den eigenen Daten gegenueber: Name, Marke, Packungsgroesse, Kategorie und Bild, jeweils mit eigenem Uebernehmen-Knopf und einem "Alle uebernehmen". Uebernommen wird nur ins Formular - gespeichert erst mit "Speichern", damit man bis zuletzt bei den eigenen Daten bleiben kann. Dafuer noetig: GET /products/{id}/off. /lookup taugt hier nicht, weil es bei einem bekannten Barcode den eigenen Artikel meldet und OFF gar nicht erst fragt. Der neue Endpunkt probiert auch die zusaetzlichen EAN-Codes durch - oft ist nur einer davon bei OFF hinterlegt. Damit ist das Bild unabhaengig davon nachholbar, ob ein Backup eine Bildadresse enthielt. Nebenbei: Der Nachschlagen-Knopf uebergab die Gruppenliste, wo die Einheitenliste erwartet wird - die Einheit eines Vorschlags wurde deshalb nie richtig zugeordnet. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/products.py | 44 ++++++++++++ web/src/api.js | 2 + web/src/components/Icon.jsx | 6 ++ web/src/components/OffVergleich.jsx | 86 +++++++++++++++++++++++ web/src/components/ProduktBild.jsx | 12 ++-- web/src/pages/ProductForm.jsx | 105 ++++++++++++++++++++++++++-- web/src/styles.css | 21 ++++++ 7 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 web/src/components/OffVergleich.jsx diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 7ca3dd8..481b80b 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -92,6 +92,50 @@ def get_product( return product_to_out(db, product) +@router.get("/{product_id}/off", response_model=LookupResult) +def off_vergleich( + product_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> LookupResult: + """Open Food Facts erneut befragen – auch für einen bereits angelegten Artikel. + + ``/lookup`` kann das nicht: Kennt es den Barcode schon, meldet es den eigenen + Artikel und fragt OFF gar nicht erst. Zum Vergleichen braucht es aber genau + die fremden Daten. + + Neben dem Haupt-Barcode werden die zusätzlichen EAN-Codes durchprobiert – + oft ist nur einer davon bei OFF hinterlegt. + """ + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + + codes = [product.barcode] if product.barcode else [] + codes += [ + b.code + for b in db.query(Barcode).filter(Barcode.product_id == product_id).all() + if b.code not in codes + ] + if not codes: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.", + ) + + for code in codes: + suggestion = lookup_barcode(code) + if suggestion: + category = suggest_category(db, suggestion) + return LookupResult( + found=True, + suggestion=suggestion, + category_id=category.id if category else None, + category_name=category.name if category else None, + ) + return LookupResult(found=False) + + @router.get("/{product_id}/image") def get_product_image( product_id: int, diff --git a/web/src/api.js b/web/src/api.js index 91ef106..d8b30c5 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -139,6 +139,8 @@ export const api = { }, getProduct: (id) => request(`/products/${id}`), lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`), + // Fragt OFF auch fuer einen bereits angelegten Artikel - zum Vergleichen. + offCompare: (id) => request(`/products/${id}/off`), createProduct: (body) => request("/products", { method: "POST", body }), updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }), deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }), diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index a89bac3..b33a82b 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -115,6 +115,12 @@ const PATHS = { check: , close: , chevronRight: , + download: ( + <> + + + + ), box: ( <> diff --git a/web/src/components/OffVergleich.jsx b/web/src/components/OffVergleich.jsx new file mode 100644 index 0000000..f4f223c --- /dev/null +++ b/web/src/components/OffVergleich.jsx @@ -0,0 +1,86 @@ +import Icon from "./Icon"; +import ProduktBild from "./ProduktBild"; + +/** + * Gegenüberstellung: eigene Daten links, Open Food Facts rechts. + * + * Nichts wird von allein übernommen. Jede Zeile hat ihren eigenen Knopf, und + * auch der schreibt nur ins Formular – gespeichert wird erst mit „Speichern“. + * So bleibt bis zuletzt die Möglichkeit, bei den eigenen Daten zu bleiben. + * + * felder: [{ schluessel, titel, eigen, fremd, anzeigeEigen?, anzeigeFremd? }] + */ +export default function OffVergleich({ + felder, bildEigenId, bildFremdUrl, onUebernehmen, onAlle, onSchliessen, +}) { + const leer = (wert) => wert === null || wert === undefined || wert === ""; + const abweichend = felder.filter((f) => !leer(f.fremd) && String(f.fremd) !== String(f.eigen)); + const bildAbweichend = Boolean(bildFremdUrl); + + return ( +
+
+ +

Daten von Open Food Facts

+ + +
+ + {abweichend.length === 0 && !bildAbweichend ? ( +

Keine abweichenden Angaben – deine Daten sind aktuell.

+ ) : ( + + + + + + + + {felder.map((f) => { + const gleich = leer(f.fremd) || String(f.fremd) === String(f.eigen); + return ( + + + + + + + ); + })} + + + + + + + + +
FeldBisherOpen Food Facts
{f.titel}{f.anzeigeEigen ?? (leer(f.eigen) ? "–" : String(f.eigen))} + {f.anzeigeFremd ?? (leer(f.fremd) ? "–" : String(f.fremd))} + + +
Bild + {/* Vorschau kommt hier direkt von OFF – lokal gibt es das Bild + ja gerade noch nicht. Übernommen und gespeichert, holt der + Server es einmal und liefert es danach selbst aus. */} + {bildFremdUrl + ? + : } + + +
+ )} +
+ ); +} diff --git a/web/src/components/ProduktBild.jsx b/web/src/components/ProduktBild.jsx index 001b935..7104832 100644 --- a/web/src/components/ProduktBild.jsx +++ b/web/src/components/ProduktBild.jsx @@ -8,7 +8,7 @@ import Icon from "./Icon"; * Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser * jedes angesehene Bild bis zum Neuladen der Seite im Speicher. */ -function useBildUrl(productId) { +function useBildUrl(productId, version = 0) { const [url, setUrl] = useState(null); useEffect(() => { @@ -16,7 +16,9 @@ function useBildUrl(productId) { let abgebrochen = false; let erzeugt = null; - authorizedObjectUrl(`/products/${productId}/image`) + // ``version`` haengt einen Wert an die Adresse, damit der Browser nach dem + // Speichern nicht seine zwischengespeicherte Fassung ausliefert. + authorizedObjectUrl(`/products/${productId}/image${version ? `?v=${version}` : ""}`) .then((neu) => { if (!neu) return; if (abgebrochen) { @@ -33,7 +35,7 @@ function useBildUrl(productId) { if (erzeugt) URL.revokeObjectURL(erzeugt); setUrl(null); }; - }, [productId]); + }, [productId, version]); return url; } @@ -44,8 +46,8 @@ function useBildUrl(productId) { * Rendert nichts, solange kein Bild vorliegt – ein leerer Rahmen neben einem * Artikel, der nie ein Bild bekommen wird, wäre nur Lärm. */ -export default function ProduktBild({ productId, alt, className = "" }) { - const url = useBildUrl(productId); +export default function ProduktBild({ productId, alt, className = "", version = 0 }) { + const url = useBildUrl(productId, version); if (!url) return null; return {alt; } diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 28947ed..c79dbc7 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -5,6 +5,7 @@ import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; +import OffVergleich from "../components/OffVergleich"; import ProduktBild from "../components/ProduktBild"; import { useToast } from "../toast"; import { useSettings } from "../settings"; @@ -50,6 +51,10 @@ export default function ProductForm() { // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. const [minUnit, setMinUnit] = useState(""); const [warnDays, setWarnDays] = useState(7); + // Offene Gegenüberstellung mit Open Food Facts (null = zu). + const [offDaten, setOffDaten] = useState(null); + const [offBusy, setOffBusy] = useState(false); + const [bildVersion, setBildVersion] = useState(0); const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null; const unitFactor = selectedUnit ? selectedUnit.factor : 1; @@ -125,6 +130,74 @@ export default function ProductForm() { } } + /** + * Open Food Facts erneut befragen, obwohl der Artikel schon existiert. + * Übernommen wird nichts von allein – das entscheidet die Gegenüberstellung. + */ + async function holeOffDaten() { + setError(null); + setOffBusy(true); + try { + const res = await api.offCompare(id); + if (res.found && res.suggestion) { + setOffDaten({ ...res.suggestion, category_id: res.category_id, category_name: res.category_name }); + } else { + setOffDaten(null); + toast("Open Food Facts kennt keinen der hinterlegten EAN-Codes.", "warn"); + } + } catch (err) { + setError(err.message); + } finally { + setOffBusy(false); + } + } + + /** Welche Felder die Gegenüberstellung zeigt – und woher ihr Wert kommt. */ + function offFelder() { + if (!offDaten) return []; + const kategorie = categories.find((c) => String(c.id) === String(form.category_id)); + return [ + { schluessel: "name", titel: "Name", eigen: form.name, fremd: offDaten.name || "" }, + { schluessel: "brand", titel: "Marke", eigen: form.brand, fremd: offDaten.brand || "" }, + { + schluessel: "package_size", titel: "Packungsgröße", + eigen: form.package_size, fremd: offDaten.package_size ?? "", + }, + { + schluessel: "category_id", titel: "Kategorie", + eigen: form.category_id, fremd: offDaten.category_id ?? "", + anzeigeEigen: kategorie ? kategorie.name : "–", + anzeigeFremd: offDaten.category_name || "–", + }, + ]; + } + + function uebernehmen(schluessel) { + if (!offDaten) return; + if (schluessel === "image_url") { + set("image_url", offDaten.image_url || ""); + return; + } + if (schluessel === "package_size") { + set("package_size", offDaten.package_size != null ? String(offDaten.package_size) : ""); + return; + } + if (schluessel === "category_id") { + set("category_id", offDaten.category_id ? String(offDaten.category_id) : ""); + return; + } + set(schluessel, offDaten[schluessel] || ""); + } + + function alleUebernehmen() { + offFelder().forEach((f) => { + if (f.fremd !== "" && f.fremd !== null && String(f.fremd) !== String(f.eigen)) { + uebernehmen(f.schluessel); + } + }); + if (offDaten?.image_url) uebernehmen("image_url"); + } + useEffect(() => { async function load() { try { @@ -215,6 +288,10 @@ export default function ProductForm() { toast("Gespeichert."); setProduct(await api.getProduct(id)); setLots(await api.listLots(id)); + // Der Server holt bei geaenderter Bildadresse ein neues Bild. Ohne + // diesen Zaehler zeigte die Seite weiter das alte aus dem Zwischenspeicher. + setBildVersion((v) => v + 1); + setOffDaten(null); } } catch (err) { setError(err.message); @@ -276,9 +353,18 @@ export default function ProductForm() { set("barcode", e.target.value)} disabled={readOnly} /> {isAdmin && ( - +
+ + {!isNew && ( + + )} +
)} - {!isNew && } + {!isNew && } + + {offDaten && ( + setOffDaten(null)} + /> + )}