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: Keine abweichenden Angaben – deine Daten sind aktuell.Daten von Open Food Facts
+
+
+
+
+
+ )}
+
+
+
+
+ {felder.map((f) => {
+ const gleich = leer(f.fremd) || String(f.fremd) === String(f.eigen);
+ return (
+ Feld Bisher Open 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/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 && (
-
+