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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 10:40:54 +02:00
parent b683f2d93b
commit 4c17705290
10 changed files with 368 additions and 105 deletions

View File

@@ -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,