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 && ( + + )}