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.responses import Response
from sqlalchemy.orm import Session, joinedload, selectinload
@@ -9,6 +12,7 @@ from ..models import (
Barcode,
BaseUnit,
Category,
CategoryTracking,
Group,
Location,
Movement,
@@ -25,6 +29,9 @@ from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate,
ProductOut,
ProductUpdate,
@@ -35,6 +42,7 @@ from ..schemas import (
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
@@ -42,6 +50,76 @@ 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)
for p, s in treffer[:5]
],
))
return ergebnis
@router.get("", response_model=list[ProductOut])
def list_products(

View File

@@ -10,6 +10,10 @@ from ..schemas import SettingOut
router = APIRouter(prefix="/settings", tags=["settings"])
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:
@@ -22,6 +26,17 @@ def get_expiry_warning_days(db: Session) -> int:
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])
def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -29,6 +44,7 @@ def list_settings(
rows = db.query(Setting).all()
known = {r.key: r.value for r in rows}
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()]