Backend: Kassenzettel-Abgleich (POST /products/match) + Schwellwert-Einstellung

Neuer Endpunkt ordnet OCR-Zeilen den aehnlichsten LEBENSMITTELN zu (Score 0-100,
difflib + Token-/Praefix-Abgleich fuer abgekuerzte Kassennamen). Gegenstaende
inkl. Verbrauchsgegenstaende bleiben aussen vor. Je Zeile die besten Treffer ueber
dem Schwellwert (Request oder Einstellung receipt_match_threshold, Default 45).
3 Tests, gesamt 182 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 18:24:19 +02:00
parent d05727779b
commit 91adee5333
4 changed files with 173 additions and 0 deletions

View File

@@ -1,3 +1,6 @@
import difflib
import re
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
@@ -9,6 +12,7 @@ from ..models import (
Barcode, Barcode,
BaseUnit, BaseUnit,
Category, Category,
CategoryTracking,
Group, Group,
Location, Location,
Movement, Movement,
@@ -25,6 +29,9 @@ from ..schemas import (
BarcodeCreate, BarcodeCreate,
LocationMinStockIn, LocationMinStockIn,
LookupResult, LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate, ProductCreate,
ProductOut, ProductOut,
ProductUpdate, ProductUpdate,
@@ -35,6 +42,7 @@ from ..schemas import (
from ..services import images from ..services import images
from ..services.categories import suggest_category from ..services.categories import suggest_category
from .categories import descendant_ids from .categories import descendant_ids
from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values 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.group_codes import detach as detach_group_code, sync as sync_group_code
@@ -42,6 +50,76 @@ from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"]) 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)
for p, s in treffer[:5]
],
))
return ergebnis
@router.get("", response_model=list[ProductOut]) @router.get("", response_model=list[ProductOut])
def list_products( def list_products(

View File

@@ -10,6 +10,10 @@ from ..schemas import SettingOut
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
EXPIRY_WARNING_KEY = "expiry_warning_days" EXPIRY_WARNING_KEY = "expiry_warning_days"
# Ab wie viel Prozent Übereinstimmung ein Artikel beim Kassenzettel-Scan als
# Treffer vorgeschlagen wird.
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
RECEIPT_THRESHOLD_DEFAULT = 45
def get_expiry_warning_days(db: Session) -> int: def get_expiry_warning_days(db: Session) -> int:
@@ -22,6 +26,17 @@ def get_expiry_warning_days(db: Session) -> int:
return get_settings().expiry_warning_days_default return get_settings().expiry_warning_days_default
def get_receipt_match_threshold(db: Session) -> int:
"""Schwellwert (0100) für Kassenzettel-Treffer; auf sinnvollen Bereich geklemmt."""
row = db.get(Setting, RECEIPT_THRESHOLD_KEY)
if row is None:
return RECEIPT_THRESHOLD_DEFAULT
try:
return max(0, min(100, int(row.value)))
except ValueError:
return RECEIPT_THRESHOLD_DEFAULT
@router.get("", response_model=list[SettingOut]) @router.get("", response_model=list[SettingOut])
def list_settings( def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -29,6 +44,7 @@ def list_settings(
rows = db.query(Setting).all() rows = db.query(Setting).all()
known = {r.key: r.value for r in rows} known = {r.key: r.value for r in rows}
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db))) known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db)))
return [SettingOut(key=k, value=v) for k, v in known.items()] return [SettingOut(key=k, value=v) for k, v in known.items()]

View File

@@ -487,6 +487,25 @@ class LotSplit(BaseModel):
location_id: str | None = None location_id: str | None = None
# ---- Kassenzettel-Abgleich ----
class MatchRequest(BaseModel):
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
lines: list[str]
threshold: int | None = None # 0100; ohne Angabe gilt die Einstellung
class MatchCandidate(BaseModel):
product_id: int
name: str
brand: str | None = None
score: int # 0100, Ähnlichkeit zur Kassenzeile
class MatchLine(BaseModel):
text: str
candidates: list[MatchCandidate] = []
class CheckInResponse(BaseModel): class CheckInResponse(BaseModel):
lot: LotOut lot: LotOut
product_stock: float product_stock: float

View File

@@ -0,0 +1,60 @@
"""Kassenzettel-Abgleich: OCR-Zeilen den ähnlichsten Lebensmitteln zuordnen."""
import pytest
from app.models import BaseUnit, Product, Role, User
from app.routers.products import match_receipt
from app.schemas import MatchRequest
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def test_abgekuerzte_kassenzeile_trifft_lebensmittel(db, user):
p = Product(
name="Vegane Mühlen-Schnitzel auf Basis von Soja",
base_unit=BaseUnit.gram, package_size=180,
)
db.add(p)
db.commit()
db.refresh(p)
res = match_receipt(
MatchRequest(lines=["MÜHLEN SCHNITZ", "voelliger unsinn xyz"]),
db=db, _=user,
)
# Abgekürzte Kassenzeile findet den vollen Namen mit hohem Score.
assert res[0].text == "MÜHLEN SCHNITZ"
assert res[0].candidates
assert res[0].candidates[0].product_id == p.id
assert res[0].candidates[0].score >= 70
# Unsinnszeile bleibt ohne Treffer über dem Schwellwert.
assert res[1].candidates == []
def test_nur_lebensmittel_keine_gegenstaende(db, user):
# Einzelstück-Flag -> tracking "object" -> darf nicht vorgeschlagen werden.
obj = Product(name="Powerbank Anker", base_unit=BaseUnit.piece, individual=True)
db.add(obj)
db.commit()
res = match_receipt(MatchRequest(lines=["POWERBANK ANKER"], threshold=10), db=db, _=user)
assert res[0].candidates == []
def test_schwellwert_filtert(db, user):
p = Product(name="Basmati Reis", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.commit()
# Nur teilweise passende Zeile: bei sehr hohem Schwellwert kein Treffer.
hart = match_receipt(MatchRequest(lines=["reis lose"], threshold=99), db=db, _=user)
weich = match_receipt(MatchRequest(lines=["reis lose"], threshold=20), db=db, _=user)
assert hart[0].candidates == []
assert weich[0].candidates and weich[0].candidates[0].product_id == p.id