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 && ( +