Files
Vorrania/backend/app/routers/products.py
Scarriffle 9f3225113e Zweiteinheit am Artikel: Bruecke zwischen Stueck, Gramm und Milliliter
Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet
count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor
dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten
beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte
Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der
Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht.

Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert
wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim
naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund:
250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und
liefe damit gegen die Bestandspruefung beim Auslagern.

Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine
Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit
(GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet -
weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit
geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck,
und Runden wuerde stumm etwas anderes buchen als angegeben.

WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in
_gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein
Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren.
Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer
Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in
Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit
verrechnet.

Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den
ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND
Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen
schmalen Vertrag.

18 neue Tests. Dass test_wrong_kind_rejected und
test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist
selbst der Beleg: ohne Bruecke aendert sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:38:13 +02:00

670 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import difflib
import re
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload, selectinload
from ..crud import product_to_out, product_tracking, products_to_out_bulk
from ..database import SessionLocal, get_db
from ..deps import get_current_user, require_admin
from ..models import (
Barcode,
BaseUnit,
Category,
CategoryTracking,
Group,
Location,
Movement,
MovementType,
Product,
ProductImage,
ProductLocationMinStock,
RemovalReason,
Shop,
User,
)
from ..off import lookup_barcode
from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate,
ProductOut,
ProductUpdate,
RemovalHistoryItem,
RemovalStat,
RemovalSummary,
)
from ..services import images
from ..services.categories import suggest_category
from .categories import descendant_ids
from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
from ..services.min_stock import schreibe_ueberall
from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"])
# ---------------------------------------------------------------------------
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen.
# ---------------------------------------------------------------------------
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def _normalisieren(text: str) -> str:
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern Preise/Sonderzeichen weg."""
text = (text or "").lower().translate(_UMLAUTE)
return re.sub(r"[^a-z0-9]+", " ", text).strip()
def _match_score(zeile: str, name: str) -> int:
"""Ähnlichkeit 0100 zwischen Kassenzeile und Produktname.
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen.
"""
a, b = _normalisieren(zeile), _normalisieren(name)
if not a or not b:
return 0
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
a_tok, b_tok = a.split(), b.split()
bester = []
for t in a_tok:
m = 0.0
for u in b_tok:
if u.startswith(t) or t.startswith(u):
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
else:
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
bester.append(m)
token = sum(bester) / len(bester) if bester else 0.0
return round(100 * max(gesamt, token))
@router.post("/match", response_model=list[MatchLine])
def match_receipt(
payload: MatchRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MatchLine]:
"""Kassenzettel-Zeilen den ähnlichsten LEBENSMITTELN zuordnen (Score 0100).
Gegenstände (auch Verbrauchsgegenstände) bleiben außen vor; je Zeile die besten
Treffer über dem Schwellwert (aus Request oder Einstellung)."""
schwelle = payload.threshold if payload.threshold is not None else get_receipt_match_threshold(db)
schwelle = max(0, min(100, schwelle))
lebensmittel = [
p
for p in db.query(Product).options(joinedload(Product.category)).all()
if product_tracking(db, p) != CategoryTracking.object.value
]
ergebnis: list[MatchLine] = []
for zeile in payload.lines:
text = (zeile or "").strip()
treffer = [(p, _match_score(text, p.name)) for p in lebensmittel]
treffer = sorted(
(ps for ps in treffer if ps[1] >= schwelle),
key=lambda ps: ps[1],
reverse=True,
)
ergebnis.append(MatchLine(
text=text,
candidates=[
MatchCandidate(
product_id=p.id, name=p.name, brand=p.brand, score=s,
package_size=p.package_size, base_unit=p.base_unit.value,
)
for p, s in treffer[:5]
],
))
return ergebnis
@router.get("", response_model=list[ProductOut])
def list_products(
q: str | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ProductOut]:
"""Artikel auflisten, optional nach Name und Kategorie eingegrenzt.
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
auch "Schokolade" sehen.
"""
query = db.query(Product).options(
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
joinedload(Product.category),
joinedload(Product.shop),
joinedload(Product.display_unit),
joinedload(Product.min_stock_unit),
selectinload(Product.field_values),
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
)
if q:
like = f"%{q}%"
query = query.filter(Product.name.ilike(like))
if category_id == 0:
query = query.filter(Product.category_id.is_(None))
elif category_id is not None:
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
products = query.order_by(Product.name).all()
return products_to_out_bulk(db, products)
@router.get("/lookup", response_model=LookupResult)
def lookup(
barcode: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> LookupResult:
"""Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF."""
code = barcode.strip()
existing = db.query(Product).filter(Product.barcode == code).first()
if existing is None:
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.product_id:
existing = db.get(Product, alias.product_id)
if existing:
return LookupResult(found=True, existing_product=product_to_out(db, existing))
# Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl").
group_id = group_name = None
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.group_id:
group = db.get(Group, alias.group_id)
if group is not None:
group_id, group_name = group.id, group.name
suggestion = lookup_barcode(code)
# Die OFF-Einordnung steuert die KATEGORIE (Ueberblick), nicht die Gruppe.
category = suggest_category(db, suggestion)
category_id = category.id if category else None
category_name = category.name if category else None
if suggestion:
return LookupResult(
found=True, suggestion=suggestion, group_id=group_id, group_name=group_name,
category_id=category_id, category_name=category_name,
)
return LookupResult(found=False, group_id=group_id, group_name=group_name)
@router.get("/{product_id}", response_model=ProductOut)
def get_product(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ProductOut:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product_to_out(db, product)
@router.put("/{product_id}/location-min-stock", response_model=ProductOut)
def set_product_location_min_stock(
product_id: int,
payload: list[LocationMinStockIn],
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt)."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
db.query(ProductLocationMinStock).filter(
ProductLocationMinStock.product_id == product_id
).delete()
gesehen: set[int] = set()
for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue
if db.get(Location, eintrag.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
gesehen.add(eintrag.location_id)
db.add(ProductLocationMinStock(
product_id=product_id,
location_id=eintrag.location_id,
min_stock=eintrag.min_stock,
))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> RemovalSummary:
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
stats = [
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
for grund, v in removal_stats(db, product_id).items()
]
stats.sort(key=lambda s: s.quantity, reverse=True)
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.order_by(Movement.created_at.desc())
.limit(50)
.all()
)
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
user_ids = {m.user_id for m in rows if m.user_id}
users = (
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
if user_ids
else {}
)
history = [
RemovalHistoryItem(
reason=RemovalReason(m.reason),
quantity=m.quantity,
location_id=m.location_id,
location_name=loc_names.get(m.location_id),
note=m.note,
username=users.get(m.user_id),
created_at=m.created_at,
)
for m in rows
]
return RemovalSummary(stats=stats, history=history)
@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.",
)
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
prefer = "object" if product_tracking(db, product) == "object" else None
for code in codes:
suggestion = lookup_barcode(code, prefer=prefer)
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,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Artikelbild aus der eigenen Datenbank nie von Open Food Facts.
Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern
liesse sich sonst ohne Konto ablesen, was im Vorrat liegt.
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
bild = images.ensure(db, product)
if bild is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden")
return Response(
content=bild.data,
media_type=bild.content_type,
headers={
"Cache-Control": "private, max-age=300",
"ETag": f'"bild-{product_id}-{int(bild.updated_at.timestamp())}"',
},
)
@router.put("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(
product_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen.
``source_url = None`` markiert das Bild als selbst hochgeladen so ersetzt
es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse
(solange keine Bildadresse gesetzt ist).
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
if file.content_type not in images.ALLOWED_TYPES:
erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES))
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.",
)
data = await file.read()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
if len(data) > images.MAX_BYTES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Das Bild ist zu groß ({len(data) // 1024} KB). "
f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.",
)
bild = db.get(ProductImage, product.id)
if bild is None:
bild = ProductImage(
product_id=product.id, content_type=file.content_type, data=data, source_url=None
)
db.add(bild)
else:
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_image(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen."""
bild = db.get(ProductImage, product_id)
if bild is not None:
db.delete(bild)
db.commit()
def _store_product_image_bg(product_id: int, url: str) -> None:
"""Bild nach dem Anlegen im Hintergrund holen (eigene Session), damit das
Anlegen nicht auf den (langsamen) Netzwerk-Abruf wartet."""
db = SessionLocal()
try:
product = db.get(Product, product_id)
if product is not None:
images.store(db, product, url)
db.commit()
finally:
db.close()
def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None:
"""Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen.
Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig
aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None).
"""
gesetzt = [x for x in (basis, anzahl, menge) if x is not None]
if not gesetzt:
return
if len(gesetzt) != 3:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.",
)
if basis == base_unit:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des "
"Artikels sonst gibt es nichts umzurechnen.",
)
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ProductCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
if payload.barcode:
exists = db.query(Product).filter(Product.barcode == payload.barcode).first()
if exists:
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits"
)
base_unit = payload.base_unit
display_unit_id = None
if payload.unit_id is not None:
try:
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
_pruefe_zweiteinheit(
base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount
)
product = Product(
barcode=payload.barcode or None,
name=payload.name,
brand=payload.brand,
image_url=payload.image_url,
base_unit=base_unit,
display_unit_id=display_unit_id,
package_size=payload.package_size,
package_label=payload.package_label or None,
secondary_base=payload.secondary_base.value if payload.secondary_base else None,
secondary_count=payload.secondary_count,
secondary_amount=payload.secondary_amount,
date_precision=payload.date_precision.value,
group_id=payload.group_id,
category_id=payload.category_id,
min_stock_unit_id=payload.min_stock_unit_id,
min_stock_in_packages=bool(payload.min_stock_in_packages),
shop_id=payload.shop_id,
product_url=payload.product_url or None,
individual=bool(payload.individual),
bulk=bool(payload.bulk),
source="manual",
)
db.add(product)
db.flush() # product.id fuer den Gruppen-Code
# Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
schreibe_ueberall(db, product, payload.min_stock)
sync_group_code(db, product)
try:
apply_field_values(db, product, payload.field_values)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
db.refresh(product)
if product.image_url:
# Bild erst NACH dem Anlegen im Hintergrund holen der Netzwerk-Abruf
# soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl,
# bleibt der Artikel trotzdem angelegt (nur ohne Bild).
background_tasks.add_task(_store_product_image_bg, product.id, product.image_url)
return product_to_out(db, product)
@router.patch("/{product_id}", response_model=ProductOut)
def update_product(
product_id: int,
payload: ProductUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
data = payload.model_dump(exclude_unset=True)
# Feldwerte sind keine Spalte, sondern eigene Zeilen getrennt behandeln.
field_values = data.pop("field_values", None)
if "barcode" in data and data["barcode"]:
clash = (
db.query(Product)
.filter(Product.barcode == data["barcode"], Product.id != product_id)
.first()
)
if clash:
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
)
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
if "unit_id" in data:
unit_id = data.pop("unit_id")
data.pop("base_unit", None) # unit_id hat Vorrang
if unit_id is None:
product.display_unit_id = None
else:
try:
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
# ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
# Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit
# aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen.
ueberall_gesetzt = "min_stock" in data
ueberall_wert = data.pop("min_stock", None)
# Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht.
if "secondary_base" in data:
_pruefe_zweiteinheit(
data.get("base_unit") or product.base_unit,
data["secondary_base"],
data.get("secondary_count", product.secondary_count),
data.get("secondary_amount", product.secondary_amount),
)
data["secondary_base"] = (
data["secondary_base"].value if data["secondary_base"] else None
)
# Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine
# halbe Angabe stehen, die nirgends greift.
if data["secondary_base"] is None:
data["secondary_count"] = None
data["secondary_amount"] = None
if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None:
data.pop("date_precision", None) # Spalte ist NOT NULL
else:
data["date_precision"] = data["date_precision"].value
for field, value in data.items():
setattr(product, field, value)
if ueberall_gesetzt:
schreibe_ueberall(db, product, ueberall_wert)
if field_values is not None:
try:
apply_field_values(db, product, field_values)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
sync_group_code(db, product)
if "image_url" in data:
# Massgeblich ist, woher die vorhandene Kopie stammt - nicht, ob sich
# die Adresse am Artikel geaendert hat. Sonst passierte genau dann
# nichts, wenn die Adresse schon stimmte, der Abruf damals aber
# fehlschlug: Der Artikel bliebe dauerhaft ohne Bild.
alt = db.get(ProductImage, product.id)
if alt is not None and alt.source_url != product.image_url:
db.delete(alt)
db.flush()
alt = None
if alt is None and product.image_url:
images.store(db, product, product.image_url)
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def add_product_barcode(
product_id: int,
payload: BarcodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Weiteren EAN-Code zu einem Produkt hinzufügen."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
code = payload.code.strip()
if db.query(Barcode).filter(Barcode.code == code).first() or (
db.query(Product).filter(Product.barcode == code).first()
):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben")
db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_barcode(
product_id: int,
code: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
entry = (
db.query(Barcode)
.filter(Barcode.product_id == product_id, Barcode.code == code)
.first()
)
if entry is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
db.delete(entry)
db.commit()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
detach_group_code(db, product)
db.delete(product)
db.commit()