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,

View File

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

View File

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