From c8c168639553e602523e51321ea68d443fc9155f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 13:24:08 +0200 Subject: [PATCH] Amazon-Kaufdatum erkennen; Preis-Vorschlaege beim Anlegen; Lagerort-Dropdowns mit Pfad 1) Beleganalyse erkennt Datumsangaben mit Monatsnamen (DE+EN, z.B. Amazon 'Bestelldatum 29 April 2026'), nicht nur 29.04.2026 - so wird das Kaufdatum solcher Rechnungen gefunden. 2) Beim direkten Anlegen eines Einzelstuecks mit Beleg werden jetzt alle erkannten Preise als Vorschlag angeboten (Dropdown), statt stillschweigend nur den wahrscheinlichsten zu nehmen. 3) Alle Lagerort-Dropdowns zeigen den vollen Pfad (Hedingen -> Keller -> Schublade 1) statt nur den Namen, damit gleichnamige Orte unterscheidbar sind. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/warranty.py | 46 ++++++++++++++++++++----- backend/tests/test_warranty.py | 10 ++++++ web/src/components/Einzelstuecke.jsx | 23 +++++++++++-- web/src/components/LocationMinStock.jsx | 3 +- web/src/components/ObjektBestand.jsx | 12 +++---- web/src/locationPath.js | 36 +++++++++++++++++++ web/src/pages/CheckIn.jsx | 3 +- 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 web/src/locationPath.js diff --git a/backend/app/services/warranty.py b/backend/app/services/warranty.py index 3d59946..2b5d25c 100644 --- a/backend/app/services/warranty.py +++ b/backend/app/services/warranty.py @@ -25,9 +25,33 @@ _PURCHASE_KW = re.compile( r"datum|invoice|order date|purchase|receipt", re.IGNORECASE, ) -# 2024-03-31 oder 31.03.2024 / 31/03/24 +# Monatsnamen (Deutsch + Englisch, mit gängigen Abkürzungen), damit z.B. +# Amazon-Rechnungen mit „29 April 2026" erkannt werden – nicht nur 29.04.2026. +_MONTHS = { + "januar": 1, "jan": 1, "january": 1, + "februar": 2, "feb": 2, "february": 2, + "märz": 3, "maerz": 3, "mrz": 3, "mär": 3, "mar": 3, "march": 3, + "april": 4, "apr": 4, + "mai": 5, "may": 5, + "juni": 6, "jun": 6, "june": 6, + "juli": 7, "jul": 7, "july": 7, + "august": 8, "aug": 8, + "september": 9, "sept": 9, "sep": 9, + "oktober": 10, "okt": 10, "october": 10, "oct": 10, + "november": 11, "nov": 11, + "dezember": 12, "dez": 12, "december": 12, "dec": 12, +} +# Längere Namen zuerst, damit „januar" vor „jan" greift. +_MONTH_ALT = "|".join(sorted((re.escape(k) for k in _MONTHS), key=len, reverse=True)) + +# ISO 2024-03-31, numerisch 31.03.2024 / 31/03/24, oder mit Monatsname +# ("29 April 2026" bzw. "April 29, 2026"). _DATE_RE = re.compile( - r"(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})" + r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" + r"|(?P\d{1,2})[.\/](?P\d{1,2})[.\/](?P\d{2,4})" + r"|(?P\d{1,2})\.?\s+(?P" + _MONTH_ALT + r")\.?\s+(?P\d{4})" + r"|(?P" + _MONTH_ALT + r")\s+(?P\d{1,2})(?:th|st|nd|rd)?,?\s+(?P\d{4})", + re.IGNORECASE, ) _WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten @@ -45,16 +69,22 @@ def extract_pdf_text(data: bytes) -> str: def _match_to_date(m: re.Match) -> date | None: - g = m.groups() + g = m.groupdict() try: - if g[0] is not None: # ISO yyyy-mm-dd - y, mo, d = int(g[0]), int(g[1]), int(g[2]) - else: # dd.mm.yyyy / dd/mm/yy - d, mo, y = int(g[3]), int(g[4]), int(g[5]) + if g.get("iy"): # ISO yyyy-mm-dd + y, mo, d = int(g["iy"]), int(g["im"]), int(g["idd"]) + elif g.get("d"): # dd.mm.yyyy / dd/mm/yy + d, mo, y = int(g["d"]), int(g["m"]), int(g["y"]) if y < 100: y += 2000 + elif g.get("nd"): # 29 April 2026 + d, mo, y = int(g["nd"]), _MONTHS[g["nmon"].lower()], int(g["ny"]) + elif g.get("emon"): # April 29, 2026 + d, mo, y = int(g["ed"]), _MONTHS[g["emon"].lower()], int(g["ey"]) + else: + return None return date(y, mo, d) - except (TypeError, ValueError): + except (TypeError, ValueError, KeyError): return None diff --git a/backend/tests/test_warranty.py b/backend/tests/test_warranty.py index 26dd117..be710f7 100644 --- a/backend/tests/test_warranty.py +++ b/backend/tests/test_warranty.py @@ -97,6 +97,16 @@ def test_kaufdatum_aus_beleg(): assert guess_acquired_on("nichts hier") is None +def test_kaufdatum_amazon_monatsname(): + # Amazon nennt das Kaufdatum als "29 April 2026" (Monatsname statt 29.04.). + text = "Bestelldatum 29 April 2026\nBestellnummer 028-6796274-2062709" + assert guess_acquired_on(text) == date(2026, 4, 29) + + +def test_kaufdatum_englischer_monatsname(): + assert guess_acquired_on("Invoice date: April 29, 2026") == date(2026, 4, 29) + + def test_shop_erkennt_bekannten_namen(): shops = [(1, "Digitec"), (2, "Galaxus")] assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec") diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index 2fd8069..90f6399 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -5,6 +5,7 @@ import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "./Icon"; import { REASONS } from "./ObjektBestand"; +import { locationOptions, locationPathById } from "../locationPath"; const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v; @@ -43,6 +44,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt const [newShopName, setNewShopName] = useState(null); // erkannter, noch nicht hinterlegter Shop const [addInfo, setAddInfo] = useState(null); // Hinweis nach Beleg-Analyse + const [priceCandidates, setPriceCandidates] = useState([]); // Preis-Vorschläge aus dem Beleg const origin = window.location.origin; async function load() { @@ -77,6 +79,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh if (s.suggested_warranty_until) patch.warranty_until = s.suggested_warranty_until; if (s.suggested_price_cents != null) { patch.price = (s.suggested_price_cents / 100).toFixed(2); patch.currency = "CHF"; } if (s.suggested_shop_id != null) patch.shop_id = String(s.suggested_shop_id); + // Alle erkannten Preise anbieten (nicht nur den einen „wahrscheinlichsten") – + // so kann man beim Anlegen den richtigen Zeilenpreis wählen. + setPriceCandidates(s.suggested_price_candidates || []); setAddForm((f) => ({ ...f, ...patch })); if (s.suggested_shop_id == null && s.suggested_shop_name) setNewShopName(s.suggested_shop_name); const teile = []; @@ -116,6 +121,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh setDocFile(null); setNewShopName(null); setAddInfo(null); + setPriceCandidates([]); await nachAktion(); toast("Einzelstück(e) angelegt."); } catch (err) { onError(err.message); } @@ -237,7 +243,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh w.document.close(); } - const ortName = (id) => (id == null ? "" : locations.find((l) => l.id === id)?.name || ""); + const ortName = (id) => locationPathById(id, locations); return (
@@ -271,7 +277,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh ) : {ortName(it.location_id) || "–"}} @@ -431,7 +437,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh + {priceCandidates.length > 1 && ( + + )}