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:
@@ -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 0–100 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 0–100).
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user