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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
24
backend/tests/test_off.py
Normal file
24
backend/tests/test_off.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user