diff --git a/.htaccess b/.htaccess index 53c1111..42445c1 100644 --- a/.htaccess +++ b/.htaccess @@ -1,3 +1,10 @@ +# === DEV-Server: HTTP Basic Auth (kein Kunde, kein Bot) === +AuthType Basic +AuthName "Dev-Server intelectra" +AuthUserFile /etc/apache2/htpasswd_dev_intelectra +Require valid-user +# === ENDE Auth-Block === + RewriteEngine On diff --git a/core/config.class.php b/core/config.class.php index 764e73b..e4f8371 100644 --- a/core/config.class.php +++ b/core/config.class.php @@ -95,6 +95,38 @@ class Config extends Main { } } + // === Mobile-Theme-Switch (Test-Phase) === + // Priorität: + // 1. URL-Parameter ?theme=X (setzt Cookie für 30 Tage) + // 2. Cookie THEME_OVERRIDE + // 3. User-Agent: iOS/Android Mobile → automatisch Mobile-Theme + // Whitelist verhindert beliebige Theme-Werte (Sicherheit). + $theme_whitelist = ['theme2', 'mobile']; + + $is_https = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'); + + if (isset($_GET['theme']) && in_array($_GET['theme'], $theme_whitelist, true)) { + // iOS Safari/Chrome braucht modernes Cookie-Format mit SameSite + Secure + setcookie('THEME_OVERRIDE', $_GET['theme'], [ + 'expires' => time() + 30 * 86400, + 'path' => '/', + 'secure' => $is_https, + 'httponly' => false, + 'samesite' => 'Lax', + ]); + $this->shopConfiguration['THEME'] = $_GET['theme']; + } elseif (isset($_COOKIE['THEME_OVERRIDE']) && in_array($_COOKIE['THEME_OVERRIDE'], $theme_whitelist, true)) { + $this->shopConfiguration['THEME'] = $_COOKIE['THEME_OVERRIDE']; + } else { + // Automatik: iOS oder Android-Mobile? → Mobile-Theme + $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; + if (preg_match('/iPhone|iPod|iPad|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i', $ua)) { + $this->shopConfiguration['THEME'] = 'mobile'; + } + } + // === Ende Theme-Switch === + foreach ($text_values as $obj) { $this->shopConfiguration[$obj->name] = str_replace('{$THEME_DIR}', STANDARD_DIR.'web/'.SHOP_SYSTEM.'/themes/'.$this->shopConfiguration['THEME'], $obj->setting); } diff --git a/core/cs_ticket.class.php b/core/cs_ticket.class.php index e06577f..564fb7e 100644 --- a/core/cs_ticket.class.php +++ b/core/cs_ticket.class.php @@ -404,7 +404,8 @@ class Cs_ticket extends Main { // get agb's ... $site_content_object = new SiteContent($this->base_object); - $var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(55); + // Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet. + $var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(10737); $var_data['[#agb]'] = $site_content_object->get_textbox_data_by_id(56); $var_data['[#agb_b2b]'] = $site_content_object->get_textbox_data_by_id(10632); $var_data['[#widerruf]'] = $site_content_object->get_textbox_data_by_id(60); diff --git a/core/item.class.php b/core/item.class.php index f4531fc..56fde1c 100644 --- a/core/item.class.php +++ b/core/item.class.php @@ -4670,7 +4670,7 @@ private function generateImageFileName($warning) { $obj->tax_rate = $this->tax_rates[$obj->tax_id]->steuersatz; $obj->manufacturer_name = ''; if (isset($this->manufacturers[$obj->manufacturer_id])) { - $obj->manufacturer_name = $this->manufacturers[$obj->manufacturer_id]->name; + $obj->manufacturer_name = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : ''; } $obj->structure_name = ''; if (isset($this->structure[$obj->structure_id])) { @@ -4837,7 +4837,7 @@ private function generateImageFileName($warning) { $obj->tax = $this->all_tax[$obj->tax_id]->steuersatz; $obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.'); - $obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name; + $obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : ''; $obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null; if ($show_taxes) { @@ -5401,7 +5401,7 @@ private function generateImageFileName($warning) { $obj->tax = $this->all_tax[$obj->tax_id]->steuersatz; $obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.'); - $obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name; + $obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : ''; $obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null; if ($show_taxes) { diff --git a/core/layout.class.php b/core/layout.class.php index f33b348..4c85e66 100644 --- a/core/layout.class.php +++ b/core/layout.class.php @@ -58,6 +58,15 @@ class layout extends Smarty { header('content-type: text/html; charset=utf-8'); $this->__construct(); + + // Smarty 4.5+ verlangt explizite Plugin-Registrierung für PHP-Funktionen als Modifier. + // Legacy-Templates nutzen u.a. |stristr — hier zentral registrieren statt überall ersetzen. + foreach (['stristr', 'strstr', 'str_replace', 'preg_replace', 'urlencode', 'urldecode', 'trim'] as $fn) { + if (function_exists($fn)) { + $this->registerPlugin('modifier', $fn, $fn); + } + } + $this->compile_id = SHOP_SYSTEM.'_'.$base_object->config->shopConfiguration['THEME']; $this->compile_dir = ROOT_DIR.'/tmp/smarty_compile'; $this->cache_dir = ROOT_DIR.'/tmp/smarty_cache'; @@ -65,6 +74,12 @@ class layout extends Smarty { $tpl_paths = array(); $tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/'.$base_object->config->shopConfiguration['THEME'].'/templates'; + // Mobile-Theme: theme2 als Fallback vor dem Parent — Mobile überschreibt nur + // die Schlüssel-Templates, alles andere kommt aus theme2 bzw. easyshop_advanced. + if ($base_object->config->shopConfiguration['THEME'] === 'mobile') { + $tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/theme2/templates'; + } + if ($theme->parent_theme) { $tpl_paths[] = ROOT_DIR.'/themes/'.$theme->parent_theme.'/templates'; } diff --git a/core/manufacturer.class.php b/core/manufacturer.class.php index ab2c19e..1290ce6 100644 --- a/core/manufacturer.class.php +++ b/core/manufacturer.class.php @@ -381,7 +381,7 @@ class Manufacturer extends Main { if ($result->num_rows > 0) { while ($obj = $result->fetch_object()) { - $return[$obj->manufacturer_id] = $manufacturers[$obj->manufacturer_id]; + $return[$obj->manufacturer_id] = isset($manufacturers[$obj->manufacturer_id]) ? $manufacturers[$obj->manufacturer_id] : null; } asort($return); return $return; diff --git a/core/postgres/KremplDB.php b/core/postgres/KremplDB.php index 4012078..32329c7 100644 --- a/core/postgres/KremplDB.php +++ b/core/postgres/KremplDB.php @@ -13,6 +13,153 @@ class KremplDB { private $mysql_conn = null; private $config = null; + /** + * "nicht mehr lieferbar" als SQL-Regex -- muss deckungsgleich mit den PHP-Pendants + * v2_nml_info() und v2_is_nml_wg() in tba_render.php bleiben. + * Zeichenklassen statt Backslash-Escapes, damit die Muster durch PHP-String und + * MySQL-Parser unveraendert durchkommen. + */ + const NML_NAME_RE = '[(]nml[e]?[)]'; + const NML_WG_RE = 'lieferbar|NMLE|Nachfolge|kein Ersatz'; + + /** + * Aufloesung der Alternativartikel ("Nachfolger") fuer eine Liste von Shop-Artikeln. + * + * Die Zuordnung wird im Backend unter Item-Editor > Tab "Alternativartikel" gepflegt und + * landet in `item_item_assign` (type=3, im Spaltenkommentar "real friends"), geschrieben + * von modules/import/item_save_service.py. Sie steht NICHT im Artikelnamen -- der bisherige + * Namens-Regex /Nachfolger\s+(\S*\d\S*)/ erreicht 71 aktive Artikel, die Tabelle 4.150. + * + * Das "e" in "(nmle)" bedeutet "Ersatz vorhanden": 96,8 % dieser Artikel haben eine + * Zuordnung, gegenueber 7,2 % bei "(nml)" und 1,7 % in der WG "kein Ersatz". + * + * Zeigt eine Zuordnung auf einen Artikel, der selbst nicht mehr lieferbar ist, laeuft die + * Kette weiter (145 Faelle). Rekursiv gemessen: 1.607 direkt kaufbare Ziele, 1.704 mit + * Kettenverfolgung, maximale Tiefe 3. Der `pfad`-String bricht Zyklen, $max_tiefe ist der + * Backstop. Zurueck kommen nur KAUFBARE Endpunkte -- einen nml-Artikel als "Ersatz" + * anzubieten waere schlechter als gar kein Hinweis. + * + * @param int[] $item_ids Shop-Artikel-IDs (items.id) + * @return array [item_id => ['id','number','name','price_brutto','bargain_brutto', + * 'default_image_file_name','wg_name','tiefe','weitere']] + */ + public function getAlternativesBatch(array $item_ids, $max_tiefe = 4) { + if (!$item_ids) return []; + + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + $ids = array_values(array_unique(array_filter(array_map('intval', $item_ids)))); + if (!$ids) return []; + $ids_str = implode(',', $ids); + $max_tiefe = max(1, (int)$max_tiefe); + + $nml_name = self::NML_NAME_RE; + $nml_wg = self::NML_WG_RE; + + // Startpunkte sind nur die nicht mehr lieferbaren Artikel: nur dort ist ein Nachfolger + // eine Antwort. Bei kaufbaren Artikeln zeigt der Shop die Alternativen ohnehin selbst + // auf der Artikelseite (item_description.tpl, Anker "realfriends"). + $sql = << 0 + THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) + ELSE NULL END AS bargain_brutto, + COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name + FROM chain ch + JOIN items i ON i.id = ch.cur AND i.active = 1 + LEFT JOIN structure s ON s.id = i.structure_id + LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 + LEFT JOIN tax t ON t.id = i.tax_id + WHERE i.name NOT REGEXP '$nml_name' + AND COALESCE(s.name, '') NOT REGEXP '$nml_wg' + ORDER BY ch.start_id, ch.tiefe, ch.pos, i.id +SQL; + + $result = mysqli_query($this->mysql_conn, $sql); + if (!$result) { + // Ein fehlender Nachfolger-Hinweis darf die Suche nicht abbrechen. + error_log('tba-search getAlternativesBatch: ' . mysqli_error($this->mysql_conn)); + return []; + } + + $out = []; + while ($row = mysqli_fetch_assoc($result)) { + $start = (int)$row['start_id']; + if (!isset($out[$start])) { + // Erster Treffer je Artikel gewinnt: kleinste Kettentiefe, dann die im Backend + // gepflegte Reihenfolge (position). + unset($row['start_id']); + $row['tiefe'] = (int)$row['tiefe']; + $row['weitere'] = 0; + $out[$start] = $row; + } else { + $out[$start]['weitere']++; + } + } + + return $out; + } + + /** + * Haengt an jeden Artikel der Liste seine kaufbare Alternative unter dem Schluessel 'alt'. + * Ein Query fuer die ganze Liste; ohne Treffer bleibt 'alt' ungesetzt. + */ + private function attachAlternatives(array &$items) { + if (!$items) return; + + $ids = []; + foreach ($items as $it) { + if (isset($it['id'])) $ids[] = (int)$it['id']; + } + + $alts = $this->getAlternativesBatch($ids); + if (!$alts) return; + + foreach ($items as &$it) { + $id = (int)($it['id'] ?? 0); + if (isset($alts[$id])) $it['alt'] = $alts[$id]; + } + unset($it); + } + public function __construct($connect_mysql = false) { $config_file = __DIR__ . '/config.ini'; @@ -88,7 +235,16 @@ class KremplDB { * @param int $limit Maximum number of results (default: 20) * @return array Array of devices with scores and ersatzteile */ - public function searchGeraete($search_term, $limit = 20) { + /** + * @param string $search_term + * @param int $limit + * @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das + * pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche + * Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer + * gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin + * nur die Anzahl (siehe getErsatzteilCountsBatch()). + */ + public function searchGeraete($search_term, $limit = 20, $with_parts = false) { if (empty($search_term)) { return []; } @@ -104,14 +260,168 @@ class KremplDB { } while ($row = pg_fetch_assoc($result)) { - // For each device, find matching ersatzteile - $row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']); + if ($with_parts) { + $row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']); + } $results[] = $row; } return $results; } + /** + * Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query. + * + * Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden -- + * die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten + * hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX. + * + * @param array $geraet_ids + * @return array geraet_id => Anzahl + */ + public function getErsatzteilCountsBatch(array $geraet_ids) { + $ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids)))); + if (!$ids) return []; + + // LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von + // idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal + // benutzt wird statt einmal ueber die ganze Tabelle zu scannen. + // Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms, + // LATERAL + @> 3,6 ms. + $query = " + SELECT g.gid, x.anzahl + FROM unnest($1::bigint[]) AS g(gid) + CROSS JOIN LATERAL ( + SELECT COUNT(*) AS anzahl + FROM ersatzteil_mapping em + WHERE em.geraet_ids @> ARRAY[g.gid] + ) x + "; + $result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']); + if (!$result) { + throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn)); + } + $out = []; + while ($row = pg_fetch_assoc($result)) { + $out[$row['gid']] = (int)$row['anzahl']; + } + return $out; + } + + /** + * Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?"). + * + * search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke, + * score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de, + * die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist + * fuer Kunden der verlaesslichste Wiedererkennungsanker. + * + * Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup. + * + * @param int $geraet_id + * @return array|null Assoziatives Array oder null, wenn nicht gefunden + */ + public function getGeraetDetails($geraet_id) { + $query = " + SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung, + zusatz_nummer, produktionsstart, produktionsende, + wgtext1, wgtext2, zusatz, bezeichnung_original + FROM geraete + WHERE id = $1 + LIMIT 1 + "; + $result = pg_query_params($this->pg_conn, $query, [$geraet_id]); + if (!$result) { + throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn)); + } + $row = pg_fetch_assoc($result); + return $row ?: null; + } + + /** + * Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query. + * + * Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als + * "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen + * z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum + * unterscheiden -- den liefert search_geraete_smart() aber nicht mit. + * + * @param array $ids Geraet-IDs + * @return array geraet_id => Detailzeile + */ + public function getGeraetDetailsBatch(array $ids) { + $ids = array_values(array_unique(array_map('intval', array_filter($ids)))); + if (!$ids) return []; + + $query = " + SELECT id, nr, marke, typ, typ_de, modell_bezeichnung, + produktionsstart, produktionsende, wgtext1, wgtext2 + FROM geraete + WHERE id = ANY($1) + "; + $result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']); + if (!$result) { + throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn)); + } + $out = []; + while ($row = pg_fetch_assoc($result)) { + $out[$row['id']] = $row; + } + return $out; + } + + /** + * Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips + * einen echten Shop-Link bekommen koennen. + * + * getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL -- + * ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie + * getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number, + * darunter in items.attribute_7. + * + * @param array $navision_ids + * @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...] + */ + public function getShopItemsByNavisionIds(array $navision_ids) { + $ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) { + return $v !== ''; + }))); + if (!$ids) return []; + + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + $esc = []; + foreach ($ids as $v) { + $esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'"; + } + $in = implode(',', $esc); + + $sql = " + SELECT i.id, i.number, i.name, i.attribute_7, i.short_uri, + CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id + FROM items i + WHERE ( + (LENGTH(i.number) >= 8 AND i.number IN ($in)) + OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in)) + ) + AND i.active = 1 + "; + $result = mysqli_query($this->mysql_conn, $sql); + if (!$result) { + throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn)); + } + $map = []; + while ($row = mysqli_fetch_assoc($result)) { + $key = trim((string)$row['matched_navision_id']); + if ($key !== '' && !isset($map[$key])) { + $map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']]; + } + } + return $map; + } + /** * Get all ersatzteile (spare parts) for a specific device * @@ -128,7 +438,10 @@ class KremplDB { e.marke FROM ersatzteil_mapping em INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id - WHERE $1 = ANY(em.geraet_ids) + -- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die + -- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen + -- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms). + WHERE em.geraet_ids @> ARRAY[$1::bigint] ORDER BY e.navision_id LIMIT $2 "; @@ -284,7 +597,8 @@ class KremplDB { SELECT e.navision_id FROM ersatzteil_mapping em INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id - WHERE $1 = ANY(em.geraet_ids) + -- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms). + WHERE em.geraet_ids @> ARRAY[$1::bigint] AND e.navision_id IS NOT NULL ORDER BY e.navision_id "; @@ -319,11 +633,22 @@ class KremplDB { i.base_price, i.manufacturer_id, i.structure_id, + i.default_image_file_name, + i.short_uri, + ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto, + CASE WHEN ip.bargain_price_1 > 0 + THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) + ELSE NULL END AS bargain_brutto, CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 - END AS matched_navision_id + END AS matched_navision_id, + COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name, + i.structure_id AS wg_id FROM items i + LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 + LEFT JOIN tax t ON t.id = i.tax_id + LEFT JOIN structure s ON s.id = i.structure_id WHERE ( (LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str)) OR @@ -345,6 +670,78 @@ class KremplDB { $items[] = $row; } + $this->attachAlternatives($items); + + return $items; + } + + /** + * Full-text-ähnliche Suche auf MySQL items. + * Multi-Token-AND auf name + number + attribute_7. Nur aktive Items. + * Sortiert nach inventory (verfügbare zuerst), dann name. + * + * @param string $search_term + * @param int $limit + * @return array + */ + public function searchItems($search_term, $limit = 20) { + if (empty($search_term)) return []; + + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + $tokens = preg_split('/\s+/', trim($search_term)); + $tokens = array_filter($tokens, fn($t) => strlen($t) > 0); + if (!$tokens) return []; + + $where_parts = []; + foreach ($tokens as $tok) { + $tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok); + $where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')"; + } + $where = implode(' AND ', $where_parts); + $limit = (int)$limit; + + $sql = " + SELECT + i.id, + i.number, + i.name, + i.attribute_7 AS navision_id_alt, + i.inventory, + i.base_price, + i.default_image_file_name, + i.short_uri, + CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id, + ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto, + CASE WHEN ip.bargain_price_1 > 0 + THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) + ELSE NULL END AS bargain_brutto, + COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name, + i.structure_id AS wg_id + FROM items i + LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 + LEFT JOIN tax t ON t.id = i.tax_id + LEFT JOIN structure s ON s.id = i.structure_id + WHERE i.active = 1 + AND $where + ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC + LIMIT $limit + "; + + $result = mysqli_query($this->mysql_conn, $sql); + if (!$result) { + throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn)); + } + + $items = []; + while ($row = mysqli_fetch_assoc($result)) { + $items[] = $row; + } + + $this->attachAlternatives($items); + return $items; } diff --git a/core/postgres/KremplDB.php.bak_20260805_alternativ b/core/postgres/KremplDB.php.bak_20260805_alternativ new file mode 100644 index 0000000..b32ff3d --- /dev/null +++ b/core/postgres/KremplDB.php.bak_20260805_alternativ @@ -0,0 +1,602 @@ +searchGeraete('AWB 921 PH'); + */ + +class KremplDB { + private $pg_conn = null; + private $mysql_conn = null; + private $config = null; + + public function __construct($connect_mysql = false) { + $config_file = __DIR__ . '/config.ini'; + + if (!file_exists($config_file)) { + throw new Exception("Config file not found: $config_file"); + } + + $this->config = parse_ini_file($config_file, true); + + if (!isset($this->config['postgresql'])) { + throw new Exception("PostgreSQL config section not found in config.ini"); + } + + $this->connectPostgres(); + + if ($connect_mysql) { + $this->connectMySQL(); + } + } + + private function connectPostgres() { + $pg = $this->config['postgresql']; + + $conn_string = sprintf( + "host=%s port=%s dbname=%s user=%s password=%s", + $pg['host'] ?? 'localhost', + $pg['port'] ?? '5432', + $pg['database'] ?? 'krempl_data', + $pg['user'] ?? 'krempl_user', + $pg['password'] ?? '' + ); + + $this->pg_conn = @pg_connect($conn_string); + + if (!$this->pg_conn) { + throw new Exception("PostgreSQL connection failed. Check credentials in config.ini"); + } + } + + private function connectMySQL() { + if (!isset($this->config['mysql'])) { + throw new Exception("MySQL config section not found in config.ini"); + } + + $my = $this->config['mysql']; + + $this->mysql_conn = @mysqli_connect( + $my['host'] ?? 'localhost', + $my['user'] ?? '', + $my['password'] ?? '', + $my['database'] ?? '' + ); + + if (!$this->mysql_conn) { + throw new Exception("MySQL connection failed: " . mysqli_connect_error()); + } + + mysqli_set_charset($this->mysql_conn, 'utf8mb4'); + } + + public function getPostgresConnection() { + return $this->pg_conn; + } + + public function getMySQLConnection() { + return $this->mysql_conn; + } + + /** + * Search for devices using intelligent normalized search + * + * @param string $search_term The search term (e.g. "AWB 921 PH", "ZCS 2100B") + * @param int $limit Maximum number of results (default: 20) + * @return array Array of devices with scores and ersatzteile + */ + /** + * @param string $search_term + * @param int $limit + * @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das + * pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche + * Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer + * gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin + * nur die Anzahl (siehe getErsatzteilCountsBatch()). + */ + public function searchGeraete($search_term, $limit = 20, $with_parts = false) { + if (empty($search_term)) { + return []; + } + + $results = []; + + // Search for devices using the smart search function + $query = "SELECT * FROM search_geraete_smart($1, $2)"; + $result = pg_query_params($this->pg_conn, $query, [$search_term, $limit]); + + if (!$result) { + throw new Exception("Search query failed: " . pg_last_error($this->pg_conn)); + } + + while ($row = pg_fetch_assoc($result)) { + if ($with_parts) { + $row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']); + } + $results[] = $row; + } + + return $results; + } + + /** + * Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query. + * + * Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden -- + * die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten + * hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX. + * + * @param array $geraet_ids + * @return array geraet_id => Anzahl + */ + public function getErsatzteilCountsBatch(array $geraet_ids) { + $ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids)))); + if (!$ids) return []; + + // LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von + // idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal + // benutzt wird statt einmal ueber die ganze Tabelle zu scannen. + // Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms, + // LATERAL + @> 3,6 ms. + $query = " + SELECT g.gid, x.anzahl + FROM unnest($1::bigint[]) AS g(gid) + CROSS JOIN LATERAL ( + SELECT COUNT(*) AS anzahl + FROM ersatzteil_mapping em + WHERE em.geraet_ids @> ARRAY[g.gid] + ) x + "; + $result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']); + if (!$result) { + throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn)); + } + $out = []; + while ($row = pg_fetch_assoc($result)) { + $out[$row['gid']] = (int)$row['anzahl']; + } + return $out; + } + + /** + * Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?"). + * + * search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke, + * score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de, + * die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist + * fuer Kunden der verlaesslichste Wiedererkennungsanker. + * + * Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup. + * + * @param int $geraet_id + * @return array|null Assoziatives Array oder null, wenn nicht gefunden + */ + public function getGeraetDetails($geraet_id) { + $query = " + SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung, + zusatz_nummer, produktionsstart, produktionsende, + wgtext1, wgtext2, zusatz, bezeichnung_original + FROM geraete + WHERE id = $1 + LIMIT 1 + "; + $result = pg_query_params($this->pg_conn, $query, [$geraet_id]); + if (!$result) { + throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn)); + } + $row = pg_fetch_assoc($result); + return $row ?: null; + } + + /** + * Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query. + * + * Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als + * "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen + * z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum + * unterscheiden -- den liefert search_geraete_smart() aber nicht mit. + * + * @param array $ids Geraet-IDs + * @return array geraet_id => Detailzeile + */ + public function getGeraetDetailsBatch(array $ids) { + $ids = array_values(array_unique(array_map('intval', array_filter($ids)))); + if (!$ids) return []; + + $query = " + SELECT id, nr, marke, typ, typ_de, modell_bezeichnung, + produktionsstart, produktionsende, wgtext1, wgtext2 + FROM geraete + WHERE id = ANY($1) + "; + $result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']); + if (!$result) { + throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn)); + } + $out = []; + while ($row = pg_fetch_assoc($result)) { + $out[$row['id']] = $row; + } + return $out; + } + + /** + * Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips + * einen echten Shop-Link bekommen koennen. + * + * getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL -- + * ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie + * getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number, + * darunter in items.attribute_7. + * + * @param array $navision_ids + * @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...] + */ + public function getShopItemsByNavisionIds(array $navision_ids) { + $ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) { + return $v !== ''; + }))); + if (!$ids) return []; + + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + $esc = []; + foreach ($ids as $v) { + $esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'"; + } + $in = implode(',', $esc); + + $sql = " + SELECT i.id, i.number, i.name, i.attribute_7, + CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id + FROM items i + WHERE ( + (LENGTH(i.number) >= 8 AND i.number IN ($in)) + OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in)) + ) + AND i.active = 1 + "; + $result = mysqli_query($this->mysql_conn, $sql); + if (!$result) { + throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn)); + } + $map = []; + while ($row = mysqli_fetch_assoc($result)) { + $key = trim((string)$row['matched_navision_id']); + if ($key !== '' && !isset($map[$key])) { + $map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']]; + } + } + return $map; + } + + /** + * Get all ersatzteile (spare parts) for a specific device + * + * @param int $geraet_id The device ID + * @param int $limit Maximum number of results (default: 200) + * @return array Array of ersatzteile with navision_id, krempl_id, etc. + */ + public function getErsatzteileForGeraet($geraet_id, $limit = 200) { + $query = " + SELECT + e.id AS krempl_id, + e.navision_id, + e.originalnummer, + e.marke + FROM ersatzteil_mapping em + INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id + -- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die + -- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen + -- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms). + WHERE em.geraet_ids @> ARRAY[$1::bigint] + ORDER BY e.navision_id + LIMIT $2 + "; + + $result = pg_query_params($this->pg_conn, $query, [$geraet_id, $limit]); + + if (!$result) { + throw new Exception("Ersatzteile query failed: " . pg_last_error($this->pg_conn)); + } + + $ersatzteile = []; + while ($row = pg_fetch_assoc($result)) { + $ersatzteile[] = $row; + } + + return $ersatzteile; + } + + /** + * Get all devices compatible with a specific ersatzteil (by navision_id) + * + * @param int $navision_id The Navision ID from the shop + * @param int $limit Maximum number of results (default: 500) + * @return array Array of compatible devices + */ + public function getGeraeteForNavisionId($navision_id, $limit = 500) { + // First find the krempl ersatzteil_id + $query1 = "SELECT id FROM ersatzteile WHERE navision_id = $1 LIMIT 1"; + $result1 = pg_query_params($this->pg_conn, $query1, [$navision_id]); + + if (!$result1) { + throw new Exception("Navision lookup failed: " . pg_last_error($this->pg_conn)); + } + + $row = pg_fetch_assoc($result1); + if (!$row) { + return []; // No krempl data for this navision_id + } + + $ersatzteil_id = $row['id']; + + // Get the array of device IDs + $query2 = "SELECT geraet_ids FROM ersatzteil_mapping WHERE ersatzteil_id = $1"; + $result2 = pg_query_params($this->pg_conn, $query2, [$ersatzteil_id]); + + if (!$result2) { + throw new Exception("Mapping lookup failed: " . pg_last_error($this->pg_conn)); + } + + $row = pg_fetch_assoc($result2); + if (!$row || !$row['geraet_ids']) { + return []; + } + + // Parse the PostgreSQL array format + $geraet_ids_str = trim($row['geraet_ids'], '{}'); + $geraet_ids = array_map('intval', explode(',', $geraet_ids_str)); + + // Get device details + $query3 = " + SELECT + id, + nr, + modell_bezeichnung, + typ, + typ_de, + marke, + zusatz + FROM geraete + WHERE id = ANY($1) + ORDER BY marke, modell_bezeichnung + LIMIT $2 + "; + + $result3 = pg_query_params($this->pg_conn, $query3, ['{' . implode(',', $geraet_ids) . '}', $limit]); + + if (!$result3) { + throw new Exception("Geraete lookup failed: " . pg_last_error($this->pg_conn)); + } + + $geraete = []; + while ($row = pg_fetch_assoc($result3)) { + $geraete[] = $row; + } + + return $geraete; + } + + /** + * Rebuild the search index after CSV re-import + * Call this after running import.py + * + * @return int Number of rows indexed + */ + public function rebuildSearchIndex() { + $query = "SELECT rebuild_geraete_search_index()"; + $result = pg_query($this->pg_conn, $query); + + if (!$result) { + throw new Exception("Rebuild index failed: " . pg_last_error($this->pg_conn)); + } + + $row = pg_fetch_row($result); + return (int)$row[0]; + } + + /** + * Get database statistics + * + * @return array Statistics about the database + */ + public function getStats() { + $stats = []; + + $queries = [ + 'geraete_count' => "SELECT COUNT(*) FROM geraete", + 'ersatzteile_count' => "SELECT COUNT(*) FROM ersatzteile", + 'mapping_count' => "SELECT COUNT(*) FROM ersatzteil_mapping", + 'passendwie_count' => "SELECT COUNT(*) FROM passendwie", + 'search_index_count' => "SELECT COUNT(*) FROM geraete_search_index", + ]; + + foreach ($queries as $key => $query) { + $result = pg_query($this->pg_conn, $query); + if ($result) { + $row = pg_fetch_row($result); + $stats[$key] = (int)$row[0]; + } + } + + return $stats; + } + + /** + * Get available shop items for a device (Hybrid: PostgreSQL + MySQL) + * + * This function combines Krempl data (PostgreSQL) with shop inventory (MySQL) + * to show only the spare parts that are actually available in the shop. + * + * @param int $geraet_id The Krempl device ID + * @param int $limit Maximum number of items to return (default: 200) + * @return array Array of shop items with full details + */ + public function getAvailableItemsForGeraet($geraet_id, $limit = 200) { + // Ensure MySQL is connected + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + // Step 1: Get all Navision IDs from PostgreSQL for this device + $navision_ids = []; + $query = " + SELECT e.navision_id + FROM ersatzteil_mapping em + INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id + -- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms). + WHERE em.geraet_ids @> ARRAY[$1::bigint] + AND e.navision_id IS NOT NULL + ORDER BY e.navision_id + "; + + $result = pg_query_params($this->pg_conn, $query, [$geraet_id]); + + if (!$result) { + throw new Exception("Failed to get navision_ids: " . pg_last_error($this->pg_conn)); + } + + while ($row = pg_fetch_assoc($result)) { + $navision_ids[] = (int)$row['navision_id']; + } + + if (empty($navision_ids)) { + return []; // No parts found + } + + // Step 2: Query MySQL for items that match these Navision IDs + // Logic: number >= 8 digits → navision_id is in `number` + // number < 8 digits → navision_id is in `attribute_7` + + $navision_ids_str = implode(',', $navision_ids); + + $mysql_query = " + SELECT + i.id, + i.number, + i.name, + i.attribute_7 AS navision_id_alt, + i.inventory, + i.base_price, + i.manufacturer_id, + i.structure_id, + i.default_image_file_name, + ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto, + CASE WHEN ip.bargain_price_1 > 0 + THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) + ELSE NULL END AS bargain_brutto, + CASE + WHEN LENGTH(i.number) >= 8 THEN i.number + ELSE i.attribute_7 + END AS matched_navision_id, + COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name, + i.structure_id AS wg_id + FROM items i + LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 + LEFT JOIN tax t ON t.id = i.tax_id + LEFT JOIN structure s ON s.id = i.structure_id + WHERE ( + (LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str)) + OR + (LENGTH(i.number) < 8 AND i.attribute_7 IN ($navision_ids_str)) + ) + AND i.active = 1 + ORDER BY i.name + LIMIT $limit + "; + + $mysql_result = mysqli_query($this->mysql_conn, $mysql_query); + + if (!$mysql_result) { + throw new Exception("MySQL query failed: " . mysqli_error($this->mysql_conn)); + } + + $items = []; + while ($row = mysqli_fetch_assoc($mysql_result)) { + $items[] = $row; + } + + return $items; + } + + /** + * Full-text-ähnliche Suche auf MySQL items. + * Multi-Token-AND auf name + number + attribute_7. Nur aktive Items. + * Sortiert nach inventory (verfügbare zuerst), dann name. + * + * @param string $search_term + * @param int $limit + * @return array + */ + public function searchItems($search_term, $limit = 20) { + if (empty($search_term)) return []; + + if (!$this->mysql_conn) { + $this->connectMySQL(); + } + + $tokens = preg_split('/\s+/', trim($search_term)); + $tokens = array_filter($tokens, fn($t) => strlen($t) > 0); + if (!$tokens) return []; + + $where_parts = []; + foreach ($tokens as $tok) { + $tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok); + $where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')"; + } + $where = implode(' AND ', $where_parts); + $limit = (int)$limit; + + $sql = " + SELECT + i.id, + i.number, + i.name, + i.attribute_7 AS navision_id_alt, + i.inventory, + i.base_price, + i.default_image_file_name, + CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id, + ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto, + CASE WHEN ip.bargain_price_1 > 0 + THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) + ELSE NULL END AS bargain_brutto, + COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name, + i.structure_id AS wg_id + FROM items i + LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 + LEFT JOIN tax t ON t.id = i.tax_id + LEFT JOIN structure s ON s.id = i.structure_id + WHERE i.active = 1 + AND $where + ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC + LIMIT $limit + "; + + $result = mysqli_query($this->mysql_conn, $sql); + if (!$result) { + throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn)); + } + + $items = []; + while ($row = mysqli_fetch_assoc($result)) { + $items[] = $row; + } + return $items; + } + + public function __destruct() { + if ($this->pg_conn) { + pg_close($this->pg_conn); + } + if ($this->mysql_conn) { + mysqli_close($this->mysql_conn); + } + } +} diff --git a/core/postgres/config.ini.bak_20260716_080401 b/core/postgres/config.ini.bak_20260716_080401 new file mode 100644 index 0000000..d0a36f9 --- /dev/null +++ b/core/postgres/config.ini.bak_20260716_080401 @@ -0,0 +1,30 @@ +[postgresql] +host = localhost +port = 5432 +database = krempl_data +user = krempl_user +password = KremplSecure2025 + +[mysql] +host = localhost +user = webshopadm +password = "Je=53qi2Jey53qi2" +database = webshop-sql2022 + +[csv_files] +geraete = /var/www/vhosts/intelectra.de/httpdocs/upload/geraete_Export.csv +artikel = /var/www/vhosts/intelectra.de/httpdocs/upload/artikel_Export.csv +mapping = /var/www/vhosts/intelectra.de/httpdocs/upload/artikelgeraet_Export.csv +passendwie = /var/www/vhosts/intelectra.de/httpdocs/upload/passendwie_Export.csv + +[import_settings] +batch_size_geraete = 1000 +batch_size_artikel = 1000 +batch_size_passendwie = 1000 +batch_size_mapping = 500 +mapping_in_memory = true +progress_interval = 1000000 + +[logging] +log_level = INFO +log_file = diff --git a/core/postgres/tba_render.php b/core/postgres/tba_render.php new file mode 100644 index 0000000..a8bc6d6 --- /dev/null +++ b/core/postgres/tba_render.php @@ -0,0 +1,349 @@ + Jahr. In produktionsstart/-ende stecken ZWEI Formate: + * YYYYMMDD z.B. "19790101" (AEG Lavatherm 400) + * YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988) + * Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY. + */ + function v2_jahr4($v) { + $s = preg_replace('/\D/', '', (string)$v); + if ($s === '') return ''; + if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD) + if (strlen($s) === 4) { // YYMM + $yy = (int)substr($s, 0, 2); + return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy); + } + if (strlen($s) === 2) { // nur YY + $yy = (int)$s; + return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy); + } + return ''; + } + + /** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */ + function v2_monat($v) { + $s = preg_replace('/\D/', '', (string)$v); + $j = v2_jahr4($s); + if ($j === '') return ''; + if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD) + elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM + else return $j; + $m = (int)$m; + return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j; + } + + /** + * Shop-URL zu einem Artikel -- leer, wenn kein Shop-Artikel bekannt ist. + * + * Bevorzugt die sprechende SEO-URL "/.html". Das Feld `items.short_uri` + * ist bei allen 16.927 aktiven Artikeln gefuellt (Format "-") + * und wird vom Shop selbst so verlinkt (item_box.tpl). Aufgeloest wird sie in der + * .htaccess ueber "RewriteRule ^([aA-zZ0-9-_,]+).(html)$ index.php?item_suri=$1". + * + * Diese Regel akzeptiert nur die Zeichen ihrer Klasse. Deshalb wird hier geprueft, + * BEVOR verlinkt wird -- eine short_uri mit abweichenden Zeichen wuerde nicht + * greifen und den Kunden auf einen 404 schicken. In dem Fall bleibt es bei der + * ID-URL, die immer funktioniert. + * + * Bewusst enger als die .htaccess: deren "aA-zZ" ist ein alter Tippfehler und deckt + * als ASCII-Bereich A(65)-z(122) auch ` [ \ ] ^ _ ab. Fuenf Artikel haben deshalb + * einen Backtick in der short_uri und funktionieren nur zufaellig; die bekommen + * hier die ID-URL. Auf den Tippfehler zu bauen waere fahrlaessig. + */ + function v2_shopurl($item_id, $short_uri = null) { + if (!$item_id) return ''; + $uri = trim((string)$short_uri); + if ($uri !== '' && preg_match('/^[A-Za-z0-9_,-]+$/', $uri)) { + return tba_url($uri . '.html'); + } + return tba_url('index.php?item_id=' . (int)$item_id); + } + + /** + * Chip fuer ein Ersatzteil/Artikel. Gibt einen aus, wenn ein Shop-Artikel + * bekannt ist, sonst ein -- damit nichts ins Leere klickt. + */ + function v2_chip($label, $sub, $item_id, $buyclass = false, $short_uri = null) { + $cls = 'v2-chip' . ($buyclass ? ' v2-buy' : ''); + $inner = '' . htmlspecialchars($label) . '' + . ($sub !== '' ? ' · ' . htmlspecialchars($sub) : ''); + $url = v2_shopurl($item_id, $short_uri); + if ($url === '') { + return '' . $inner . ''; + } + return '' . $inner . ''; + } + + /** + * Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis. + * Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit + * die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten. + * Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben. + */ + function v2_artikel_row($it) { + $url = v2_shopurl($it['id'] ?? null, $it['short_uri'] ?? null); + $img = !empty($it['default_image_file_name']) + ? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name'])) + : ''; + $pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0; + $bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0; + $nml = v2_nml_info((string)($it['name'] ?? '')); + // nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als + // der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht). + $is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? ''); + + $h = $url !== '' + ? '' + : ''); + + // Der Ersatzartikel bekommt eine EIGENE Zeile: ein Link darf nicht in einem + // Link stehen, und der Kunde soll ihn direkt anklicken koennen statt erst den + // ausgelaufenen Artikel zu oeffnen. + if ($is_nml && !empty($it['alt'])) { + $h = '
' . $h . v2_altrow($it['alt']) . '
'; + } + + return $h; + } + + /** + * Zeile "Ersatz verfuegbar" unter einem nicht mehr lieferbaren Artikel. + * Datenquelle ist die im Backend gepflegte Zuordnung (item_item_assign type=3), + * aufgeloest von KremplDB::getAlternativesBatch(). Angezeigt werden nur kaufbare + * Ziele, deshalb ist die Zusage hier belastbar. + */ + function v2_altrow($alt) { + $url = v2_shopurl($alt['id'] ?? null, $alt['short_uri'] ?? null); + if ($url === '') return ''; + + $pb = isset($alt['price_brutto']) ? (float)$alt['price_brutto'] : 0; + $bb = !empty($alt['bargain_brutto']) ? (float)$alt['bargain_brutto'] : 0; + + $h = '' + . '→ Ersatz' + . '' + . '' . htmlspecialchars((string)($alt['name'] ?? '')) . '' + . 'Art-Nr ' . htmlspecialchars((string)($alt['number'] ?? '')); + // Ueber mehr als eine Station gelaufen: der direkte Nachfolger war selbst + // ausgelaufen. Das ist eine ehrliche Angabe, keine Fussnote. + if (!empty($alt['tiefe']) && (int)$alt['tiefe'] > 1) { + $h .= ' · über ' . (int)$alt['tiefe'] . ' Nachfolger ermittelt'; + } + if (!empty($alt['weitere'])) { + $h .= ' · ' . (int)$alt['weitere'] . ' weitere Alternative' + . ((int)$alt['weitere'] > 1 ? 'n' : ''); + } + $h .= ''; + + if ($bb > 0 && $pb > 0 && $bb < $pb) { + $h .= '' . number_format($bb, 2, ',', '.') . ' €' + . '' . number_format($pb, 2, ',', '.') . ' €'; + } elseif ($pb > 0) { + $h .= '' . number_format($pb, 2, ',', '.') . ' €'; + } else { + $h .= 'Preis auf Anfrage'; + } + + return $h . ''; + } + + /** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */ + function v2_is_nml_wg($wg) { + return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg); + } + + /** + * nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker. + * + * Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026: + * ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die + * WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix) + * und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven. + * Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer: + * Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel + * (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger. + * Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die + * Unterscheidung g_succ/g_dead am Namenstext haengen. + */ + function v2_nml_full($it) { + $info = v2_nml_info((string)($it['name'] ?? '')); + if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) { + $info['is_nml'] = true; + } + return $info; + } + + /** + * Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe + * gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+ + * Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...", + * "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende. + */ + function v2_artikel_liste(array $items, $threshold = 12, $max = null) { + if (!$items) return ''; + + // nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht + // anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile + // im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen. + $nml_flag = function ($it) { + return (v2_is_nml_wg($it['wg_name'] ?? '') + || v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0; + }; + usort($items, function ($a, $b) use ($nml_flag) { + return $nml_flag($a) - $nml_flag($b); + }); + + // In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine + // Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen. + // Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT + // verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein + // toter Text und der Kunde erfuhr nie, was er noch kaufen koennte. + $restitems = []; + if ($max !== null && count($items) > $max) { + $restitems = array_slice($items, $max); + $items = array_slice($items, 0, $max); + } + $more = ''; + if ($restitems) { + $more = '
+ ' . count($restitems) + . ' weitere Artikel für dieses Gerät anzeigen
'; + foreach ($restitems as $it) { $more .= v2_artikel_row($it); } + $more .= '
'; + } + + if (count($items) <= $threshold) { + $h = '
'; + foreach ($items as $it) { $h .= v2_artikel_row($it); } + return $h . '
' . $more; + } + + $groups = []; + foreach ($items as $it) { + $wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe'; + $groups[$wg][] = $it; + } + // Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende. + uksort($groups, function ($a, $b) use (&$groups) { + $na = v2_is_nml_wg($a) ? 1 : 0; + $nb = v2_is_nml_wg($b) ? 1 : 0; + if ($na !== $nb) return $na - $nb; + return count($groups[$b]) <=> count($groups[$a]); + }); + + $h = ''; + foreach ($groups as $wg => $its) { + $cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : ''; + $h .= '

' . htmlspecialchars($wg) + . ' ' . count($its) . '

'; + foreach ($its as $it) { $h .= v2_artikel_row($it); } + $h .= '
'; + } + return $h . $more; + } + + function v2_confidence($score) { + $s = (int)$score; + if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓']; + if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓']; + if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈']; + return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?']; + } + // nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger " + function v2_nml_info($name) { + $is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name); + $nachf = null; + // Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus) + if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) { + $nachf = $m[1]; + } + $kein_ersatz = (stripos($name, 'kein Ersatz') !== false); + // Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus + $clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name); + $clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean); + $clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean); + $clean = trim(preg_replace('/\s{2,}/', ' ', $clean)); + return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)]; + } + } diff --git a/core/postgres/tba_render.php.bak_20260805_alternativ b/core/postgres/tba_render.php.bak_20260805_alternativ new file mode 100644 index 0000000..389cbb5 --- /dev/null +++ b/core/postgres/tba_render.php.bak_20260805_alternativ @@ -0,0 +1,272 @@ + Jahr. In produktionsstart/-ende stecken ZWEI Formate: + * YYYYMMDD z.B. "19790101" (AEG Lavatherm 400) + * YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988) + * Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY. + */ + function v2_jahr4($v) { + $s = preg_replace('/\D/', '', (string)$v); + if ($s === '') return ''; + if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD) + if (strlen($s) === 4) { // YYMM + $yy = (int)substr($s, 0, 2); + return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy); + } + if (strlen($s) === 2) { // nur YY + $yy = (int)$s; + return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy); + } + return ''; + } + + /** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */ + function v2_monat($v) { + $s = preg_replace('/\D/', '', (string)$v); + $j = v2_jahr4($s); + if ($j === '') return ''; + if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD) + elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM + else return $j; + $m = (int)$m; + return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j; + } + + /** Shop-URL zu einer items.id -- leer, wenn kein Shop-Artikel bekannt ist. */ + function v2_shopurl($item_id) { + return $item_id ? tba_url('index.php?item_id=' . (int)$item_id) : ''; + } + + /** + * Chip fuer ein Ersatzteil/Artikel. Gibt einen aus, wenn ein Shop-Artikel + * bekannt ist, sonst ein -- damit nichts ins Leere klickt. + */ + function v2_chip($label, $sub, $item_id, $buyclass = false) { + $cls = 'v2-chip' . ($buyclass ? ' v2-buy' : ''); + $inner = '' . htmlspecialchars($label) . '' + . ($sub !== '' ? ' · ' . htmlspecialchars($sub) : ''); + $url = v2_shopurl($item_id); + if ($url === '') { + return '' . $inner . ''; + } + return '' . $inner . ''; + } + + /** + * Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis. + * Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit + * die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten. + * Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben. + */ + function v2_artikel_row($it) { + $url = v2_shopurl($it['id'] ?? null); + $img = !empty($it['default_image_file_name']) + ? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name'])) + : ''; + $pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0; + $bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0; + $nml = v2_nml_info((string)($it['name'] ?? '')); + // nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als + // der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht). + $is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? ''); + + $h = $url !== '' + ? '' + : ''); + } + + /** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */ + function v2_is_nml_wg($wg) { + return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg); + } + + /** + * nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker. + * + * Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026: + * ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die + * WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix) + * und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven. + * Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer: + * Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel + * (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger. + * Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die + * Unterscheidung g_succ/g_dead am Namenstext haengen. + */ + function v2_nml_full($it) { + $info = v2_nml_info((string)($it['name'] ?? '')); + if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) { + $info['is_nml'] = true; + } + return $info; + } + + /** + * Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe + * gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+ + * Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...", + * "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende. + */ + function v2_artikel_liste(array $items, $threshold = 12, $max = null) { + if (!$items) return ''; + + // nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht + // anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile + // im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen. + $nml_flag = function ($it) { + return (v2_is_nml_wg($it['wg_name'] ?? '') + || v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0; + }; + usort($items, function ($a, $b) use ($nml_flag) { + return $nml_flag($a) - $nml_flag($b); + }); + + // In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine + // Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen. + // Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT + // verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein + // toter Text und der Kunde erfuhr nie, was er noch kaufen koennte. + $restitems = []; + if ($max !== null && count($items) > $max) { + $restitems = array_slice($items, $max); + $items = array_slice($items, 0, $max); + } + $more = ''; + if ($restitems) { + $more = '
+ ' . count($restitems) + . ' weitere Artikel für dieses Gerät anzeigen
'; + foreach ($restitems as $it) { $more .= v2_artikel_row($it); } + $more .= '
'; + } + + if (count($items) <= $threshold) { + $h = '
'; + foreach ($items as $it) { $h .= v2_artikel_row($it); } + return $h . '
' . $more; + } + + $groups = []; + foreach ($items as $it) { + $wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe'; + $groups[$wg][] = $it; + } + // Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende. + uksort($groups, function ($a, $b) use (&$groups) { + $na = v2_is_nml_wg($a) ? 1 : 0; + $nb = v2_is_nml_wg($b) ? 1 : 0; + if ($na !== $nb) return $na - $nb; + return count($groups[$b]) <=> count($groups[$a]); + }); + + $h = ''; + foreach ($groups as $wg => $its) { + $cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : ''; + $h .= '

' . htmlspecialchars($wg) + . ' ' . count($its) . '

'; + foreach ($its as $it) { $h .= v2_artikel_row($it); } + $h .= '
'; + } + return $h . $more; + } + + function v2_confidence($score) { + $s = (int)$score; + if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓']; + if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓']; + if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈']; + return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?']; + } + // nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger " + function v2_nml_info($name) { + $is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name); + $nachf = null; + // Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus) + if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) { + $nachf = $m[1]; + } + $kein_ersatz = (stripos($name, 'kein Ersatz') !== false); + // Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus + $clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name); + $clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean); + $clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean); + $clean = trim(preg_replace('/\s{2,}/', ' ', $clean)); + return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)]; + } + } diff --git a/core/revocation.class.php b/core/revocation.class.php index 3da386e..f6ce39c 100644 --- a/core/revocation.class.php +++ b/core/revocation.class.php @@ -301,9 +301,7 @@ class Revocation { } $msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–'); $msg .= ''; - $msg .= '

Bitte Rückzahlung über die Warenwirtschaft ' - . 'veranlassen. Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche ' - . 'Bestellung erfolgen.

'; + $msg .= '

Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.

'; $msg .= '

Admin: Widerrufsliste öffnen

'; @@ -324,27 +322,19 @@ class Revocation { $order_number = $order->order_number ?? ('#'.$revocation->order_id); $name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? '')); - $subject = 'Bestätigung Ihres Widerrufs – Bestellung '.$order_number; + $ts = strtotime($revocation->submitted_at); + $datum = date('d.m.Y', $ts); + $uhrzeit = date('H:i', $ts); - $msg = '

Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',

'; - $msg .= '

hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung ' - . ''.htmlspecialchars($order_number).' am ' - . date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.

'; - $msg .= '

Nächste Schritte

'; - $msg .= '

Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:

'; - $msg .= '

' - . ''.REVOCATION_RETURN_COMPANY.'
' - . REVOCATION_RETURN_STREET.'
' - . REVOCATION_RETURN_CITY.'

'; - $msg .= '

'.REVOCATION_RETURN_COSTS_NOTE.'

'; - $msg .= '

Die Rückzahlung erfolgt innerhalb von 14 Tagen nach Eingang Ihres Widerrufs über ' - . 'dieselbe Zahlungsart, die Sie bei der ursprünglichen Bestellung verwendet haben – ' - . 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart.

'; - $msg .= '

Bei Rückfragen erreichen Sie uns jederzeit unter ' - . ''.REVOCATION_OWNER_EMAIL.'.

'; - $msg .= '

'.REVOCATION_RETURN_COMPANY.'

'; - - $ok = $mailer->send_mail($subject, $msg, $revocation->customer_email); + $ok = $this->send_neutral_eingangsbestaetigung( + $revocation->customer_email, + $name, + $revocation->customer_email, + $order_number, + $datum, + $uhrzeit, + $revocation->reason_text ?? '' + ); if ($ok) { $this->db->query( @@ -487,20 +477,25 @@ class Revocation { } /** - * Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail. - * Wenn noch kein revocation-Record existiert, wird er inline angelegt. + * Widerruf ohne Token. + * Pflichtfelder: Name + E-Mail. Button-Klick = Erklärung. + * Bestellnummer + Artikel sind OPTIONAL (Händlerbund-Linie). Wenn Order + * gefunden → DB-Tracking. Sonst → reiner Anonym-Pfad mit Owner-Mail zur + * manuellen Zuordnung. Der Widerruf wird in jedem Fall entgegengenommen. */ - public function submit_anonymous($name, $order_number, $email, $reason_id, $reason_text, $ip, $user_agent) { + public function submit_anonymous($name, $order_number, $email, $article, $ip, $user_agent) { if (!REVOCATION_ENABLED) { $this->error = 'feature_disabled'; return false; } + $name = trim((string)$name); $order_number = trim((string)$order_number); $email = trim(strtolower((string)$email)); + $article = trim((string)$article); - if ($order_number === '') { - $this->error = 'missing_order'; + if ($name === '') { + $this->error = 'missing_name'; return false; } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -508,22 +503,24 @@ class Revocation { return false; } - $order = $this->fetch_order_by_number_email($order_number, $email); + // Order-Lookup nur informativ. Wenn keine Order angegeben oder + // keine gefunden oder nicht widerrufsfähig → Anonym-Pfad. + $order = ($order_number !== '') + ? $this->fetch_order_by_number_email($order_number, $email) + : null; + if (!$order) { - $this->error = 'order_not_found'; - return false; + return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent); } $group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id); if (!RevocationHelper::is_b2c($group_id)) { - $this->error = 'not_eligible'; - return false; + return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent); } $expires_at = RevocationHelper::calc_expires_at($order->order_date); if (strtotime($expires_at) < time()) { - $this->error = 'expired'; - return false; + return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent); } // Existing Eintrag wiederverwenden, sonst inline anlegen @@ -563,11 +560,10 @@ class Revocation { $stmt->close(); } - $reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null; - $reason_text = trim((string)$reason_text); - if (mb_strlen($reason_text) > 2000) { - $reason_text = mb_substr($reason_text, 0, 2000); - } + $reason_id = null; + $reason_text = ($article !== '' && mb_strlen($article) > 2000) + ? mb_substr($article, 0, 2000) + : $article; $ip_anon = RevocationHelper::anonymize_ip($ip); $ua = $user_agent ? mb_substr($user_agent, 0, 255) : null; @@ -623,4 +619,100 @@ class Revocation { if (!$res || $res->num_rows === 0) return false; return $res->fetch_object(); } + + // ============================================================ + // Anonym-Pfad: ohne (passende) Bestellung + // ============================================================ + + /** + * Widerruf ohne Order-Match. + * Kein DB-Eintrag, dafür Owner-Mail mit Hinweis auf manuelle Zuordnung + * und Eingangsbestätigung an den Kunden. + */ + private function process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent) { + $ip_anon = RevocationHelper::anonymize_ip($ip); + $ua = $user_agent ? mb_substr((string)$user_agent, 0, 255) : null; + $article = trim((string)$article); + if (mb_strlen($article) > 2000) { + $article = mb_substr($article, 0, 2000); + } + $this->send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua); + $this->send_anonymous_customer_confirmation($name, $order_number, $email, $article); + return true; + } + + private function send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua) { + $mailer = new mail_tools($this->base_object); + $subject = 'Widerruf eingegangen (manuelle Zuordnung erforderlich)'; + + $msg = '

Widerruf eingegangen

'; + $msg .= '

'; + $msg .= 'Bitte manuell zuordnen. Der Widerruf wurde ohne (oder mit nicht gefundener) Bestellnummer eingereicht. '; + $msg .= 'Es wurde kein automatischer DB-Eintrag erzeugt.'; + $msg .= '

'; + $msg .= ''; + $msg .= $this->mail_row('Name', htmlspecialchars($name)); + $msg .= $this->mail_row('E-Mail', htmlspecialchars($email)); + $msg .= $this->mail_row('Bestellnummer', $order_number !== '' ? htmlspecialchars($order_number) : '(nicht angegeben)'); + $msg .= $this->mail_row('Artikel/Bemerkung', $article !== '' ? nl2br(htmlspecialchars($article)) : '(nicht angegeben)'); + $msg .= $this->mail_row('Datum/Uhrzeit', date('d.m.Y H:i:s')); + $msg .= $this->mail_row('IP (anon.)', $ip_anon ?? '–'); + $msg .= '
'; + $msg .= '

Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.

'; + + return $mailer->send_mail($subject, $msg, REVOCATION_OWNER_EMAIL); + } + + private function send_anonymous_customer_confirmation($name, $order_number, $email, $article) { + return $this->send_neutral_eingangsbestaetigung( + $email, + $name, + $email, + $order_number !== '' ? $order_number : '(nicht angegeben)', + date('d.m.Y'), + date('H:i'), + $article + ); + } + + /** + * Neutrale Eingangsbestätigung gemäß Händlerbund-Vorlage. + * + * WICHTIG: Diese E-Mail darf NUR den Eingang der Widerrufserklärung + * bestätigen und KEINE wirksame Bestätigung des Widerrufs selbst sein. + * Keine Rücksendeadresse, keine Frist-Hinweise, keine Werbung. + * + * @param string $to Empfänger-Mail + * @param string $consumer_name Name des Verbrauchers für die Anrede + * @param string $consumer_email E-Mail aus dem Formular (für „Inhalt") + * @param string $order_number Bestellnr aus dem Formular ('' = nicht angegeben) + * @param string $date_str Eingangsdatum (dd.mm.yyyy) + * @param string $time_str Eingangsuhrzeit (HH:MM) + * @param string $article Artikel/Bemerkung (optional) + */ + private function send_neutral_eingangsbestaetigung($to, $consumer_name, $consumer_email, + $order_number, $date_str, $time_str, $article) { + + $mailer = new mail_tools($this->base_object); + $subject = 'Ihr Widerruf ist bei uns eingegangen'; + + $name_safe = htmlspecialchars(trim((string)$consumer_name)); + $anrede = $name_safe !== '' ? 'Hallo '.$name_safe.',' : 'Hallo,'; + + $msg = '

'.$anrede.'

'; + $msg .= '

wir bestätigen hiermit den Eingang Ihres Widerrufs mit folgendem Inhalt:

'; + $msg .= '
    '; + $msg .= '
  • Name: '.$name_safe.'
  • '; + $msg .= '
  • E-Mail: '.htmlspecialchars($consumer_email).'
  • '; + $msg .= '
  • Bestellnummer: '.htmlspecialchars($order_number).'
  • '; + if (!empty($article)) { + $msg .= '
  • Artikel/Bemerkung: '.nl2br(htmlspecialchars($article)).'
  • '; + } + $msg .= '
  • Eingang am: '.htmlspecialchars($date_str).' um '.htmlspecialchars($time_str).' Uhr
  • '; + $msg .= '
'; + $msg .= '

Wir prüfen Ihr Anliegen und melden uns zeitnah bei Ihnen.

'; + $msg .= '

Viele Grüße
Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'

'; + + return $mailer->send_mail($subject, $msg, $to); + } } diff --git a/libs/klarna/klarnaconfig.php b/libs/klarna/klarnaconfig.php index 2e8f63a..386b9c1 100644 --- a/libs/klarna/klarnaconfig.php +++ b/libs/klarna/klarnaconfig.php @@ -129,6 +129,7 @@ class KlarnaConfig implements ArrayAccess * * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return isset($this->options[$offset]); @@ -141,6 +142,7 @@ class KlarnaConfig implements ArrayAccess * * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { if (!$this->offsetExists($offset)) { @@ -157,6 +159,7 @@ class KlarnaConfig implements ArrayAccess * * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->options[$offset] = $value; @@ -169,6 +172,7 @@ class KlarnaConfig implements ArrayAccess * * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->options[$offset]); diff --git a/modules/website_cashdesk.php b/modules/website_cashdesk.php index fbea101..bc7b55f 100644 --- a/modules/website_cashdesk.php +++ b/modules/website_cashdesk.php @@ -497,7 +497,8 @@ class website_cashdesk { $site_content_object = new SiteContent($this->base_object); $agb_data = array(); - $agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(55); + // Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet. + $agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(10737); $agb_data['agbs'] = $site_content_object->get_textbox_data_by_id(56); $agb_data['agbs_b2b'] = $site_content_object->get_textbox_data_by_id(10632); $agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60); diff --git a/modules/website_customer_registration.php b/modules/website_customer_registration.php index 38b6b6c..3d7b55d 100644 --- a/modules/website_customer_registration.php +++ b/modules/website_customer_registration.php @@ -229,7 +229,8 @@ class website_customer_registration { $site_content_object = new SiteContent($this->base_object); $agb_data = array(); - $agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(55); + // Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet. + $agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(10737); $agb_data['agbs'] = $site_content_object->get_textbox_data_by_id(56); $agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60); $this->layout_object->assign('agb_data', $agb_data); diff --git a/modules/website_init.php b/modules/website_init.php index 5881155..d2a38b5 100644 --- a/modules/website_init.php +++ b/modules/website_init.php @@ -205,7 +205,9 @@ else if (isset($_GET['item_id']) && $_GET['item_id'] && (!isset($_GET['action']) $layout_object->metaTagObject->overwriteVar('VonHersteller', 'von '.$hersteller); $layout_object->metaTagObject->overwriteVar('Hersteller', $hersteller); $layout_object->metaTagObject->overwriteVar('ArtikelAttribut6', str_replace(';',',',$content_object->item_data->attribute_6)); - $structure_id = (isset($_GET['menu_id']) && $_GET['menu_id']) ? $_GET['menu_id'] : $content_object->structure_data[0]->id; + $structure_id = (isset($_GET['menu_id']) && $_GET['menu_id']) + ? $_GET['menu_id'] + : (isset($content_object->structure_data[0]->id) ? $content_object->structure_data[0]->id : 0); $structure_object = new Structure($base_object); $tree_ids = array_reverse($structure_object->get_all_tree_ids($structure_id)); $z = 0; diff --git a/modules/website_item.php b/modules/website_item.php index f77214b..8989450 100644 --- a/modules/website_item.php +++ b/modules/website_item.php @@ -18,6 +18,7 @@ include_once './core/customer_group.class.php'; include_once './core/cs_ticket.class.php'; include_once './core/shop_klarna.class.php'; +#[\AllowDynamicProperties] class website_item { private $base_object; diff --git a/tba-search.php b/tba-search.php index 4396154..2f4540c 100644 --- a/tba-search.php +++ b/tba-search.php @@ -5,30 +5,202 @@ */ require_once __DIR__ . '/core/postgres/KremplDB.php'; +require_once __DIR__ . '/core/postgres/tba_render.php'; + +/** + * AJAX: Shop-Artikel eines Geraets nachladen. + * + * Muss vor jeder HTML-Ausgabe stehen. Hintergrund: vorher lud die Seite die Artikel fuer + * ALLE 20 Treffer vor, obwohl 19 davon hinter "Nicht mein Geraet" eingeklappt sind -- + * das waren 40 MySQL-Queries auf Vorrat und der Grund fuer ~16 s Laufzeit. Jetzt laedt nur + * der Top-Treffer vor, die uebrigen Kacheln holen ihre Liste beim Aufklappen hier ab. + * An der Legacy-Shop-DB wird dabei nichts geaendert, sie wird nur seltener gefragt. + */ +if (isset($_GET['ajax']) && $_GET['ajax'] === 'items') { + header('Content-Type: text/html; charset=UTF-8'); + header('Cache-Control: no-store'); + $gid = isset($_GET['geraet_id']) ? (int)$_GET['geraet_id'] : 0; + if ($gid <= 0) { + http_response_code(400); + echo '
Ungültige Geräte-ID.
'; + exit; + } + try { + $db = new KremplDB(true); + $buy = $db->getAvailableItemsForGeraet($gid, 200); + if ($buy) { + echo '

Verfügbare Artikel für dieses Gerät (' . count($buy) . '):

'; + echo v2_artikel_liste($buy, 12, 12); + } else { + // Zwei sehr unterschiedliche Faelle, die man nicht in einen Satz packen darf: + // a) der Hersteller fuehrt zu diesem Geraet gar keine Ersatzteile (z.B. Beko + // CN332100 BEKO_RUSSIA, 0 Eintraege in ersatzteil_mapping) -- da ist nichts zu holen. + // b) es gibt Ersatzteile, aber keines davon ist ein aktiver Shop-Artikel -- das + // lohnt eine Rueckfrage beim Grosshandel. + // Der Zusatz-Query laeuft nur im Leerfall. + $parts = $db->getErsatzteileForGeraet($gid, 1); + if (!$parts) { + echo '
Zu diesem Gerät sind in den Herstellerdaten ' + . 'keine Ersatzteile hinterlegt.
'; + } else { + echo '
Für dieses Gerät sind Ersatzteile bekannt, aber ' + . 'derzeit keines davon im Shop verfügbar.
'; + } + } + } catch (Exception $e) { + http_response_code(500); + echo '
Artikel konnten nicht geladen werden.
'; + } + exit; +} $search_term = isset($_GET['q']) ? trim($_GET['q']) : ''; $results = []; +$item_results = []; $search_time = 0; +$item_search_time = 0; $error_message = ''; -$show_shop_items = isset($_GET['shop_items']) && $_GET['shop_items'] === '1'; +// "Nur verfuegbare Shop-Artikel" ist jetzt der IST-Zustand (Default AN): das verkleinert die +// Trefferliste auf real bestellbare Teile, und getAvailableItemsForGeraet() liefert dabei +// items.id mit -- also sind die Chips direkt in den Shop verlinkbar. Abschaltbar mit +// ?shop_items=0 (die Checkbox sendet das ueber ein vorgeschaltetes hidden-Feld). +$show_shop_items = !isset($_GET['shop_items']) || $_GET['shop_items'] !== '0'; +$v2_guided = false; // gefuehrte Ansicht aktiv (ein klarer Top-Treffer) +$v2_top_detail = null; // Volldaten des Top-Geraets aus geraete +$v2_brandpick = []; // marke => anzahl, wenn erst der Hersteller geklaert werden muss +$v2_marke = isset($_GET['marke']) ? trim($_GET['marke']) : ''; // gewaehlter Hersteller +$v2_variants = []; // BSH-Bauzustaende /01 /02 ..., wenn nur der Zeitraum unterscheidet +$v2_variant_base = ''; // gemeinsame Typnummer ohne Suffix, z.B. "WT46W560BY" +$v2_shopmap = []; // navision_id => Shop-Artikel, fuer die Links an den Chips +$v2_partcounts = []; // geraet_id => Anzahl Ersatzteile (fuer die eingeklappten Kacheln) try { - // Connect to both PostgreSQL and MySQL if shop items are requested - $db = new KremplDB($show_shop_items); + // MySQL wird in beiden Fällen gebraucht (rechte Spalte = Artikel-Suche) + $db = new KremplDB(true); if ($search_term !== '') { + // Links: Geräte-Suche (PostgreSQL) $start_time = microtime(true); - $results = $db->searchGeraete($search_term, 20); + // Ersatzteile je Treffer nur im Debug-Modus (?shop_items=0) mitladen -- dort zeigen die + // Kacheln Ersatzteil-Chips statt des Lazy-Aufklappers. Im Normalfall kostet das 20 + // ueberfluessige PG-Queries, deren Ergebnis nur der Top-Treffer braucht. + $results = $db->searchGeraete($search_term, 20, !$show_shop_items); + $search_time = (microtime(true) - $start_time) * 1000; - // If shop items requested, replace ersatzteile with actual shop items - if ($show_shop_items) { - foreach ($results as &$device) { - $device['shop_items'] = $db->getAvailableItemsForGeraet($device['geraet_id'], 200); - } - unset($device); + // Rechts: Artikel-/Ersatzteil-Suche (MySQL) + $start_time2 = microtime(true); + $item_results = $db->searchItems($search_term, 20); + $item_search_time = (microtime(true) - $start_time2) * 1000; + + // --- Hersteller-Filter (kommt aus der Hersteller-Rueckfrage) --- + if ($v2_marke !== '') { + $results = array_values(array_filter($results, function ($d) use ($v2_marke) { + return isset($d['marke']) && trim($d['marke']) === $v2_marke; + })); } - $search_time = (microtime(true) - $start_time) * 1000; // Convert to milliseconds + // --- Stufe 1: muss erst der Hersteller geklaert werden? --- + // Dasselbe Modell laeuft oft unter mehreren Konzern-/Versandhausmarken. Dann haben + // alle Top-Treffer denselben Score und denselben Modellnamen -- eine Bestaetigung + // waere geraten. Der Hersteller steht dem Kunden aber auf dem Typenschild zur + // Verfuegung, also fragen wir zuerst danach. Beispiel MUFF21VLQS: 10x Score 100 + // ueber AEG, Electrolux, Frigidaire, Gibson. + if (count($results) > 1) { + $top_score = (int)$results[0]['score']; + $topgroup = array_filter($results, function ($d) use ($top_score) { + return (int)$d['score'] === $top_score; + }); + $brands = []; + $models = []; + foreach ($topgroup as $d) { + $m = trim($d['marke'] ?? ''); + if ($m !== '') { $brands[$m] = ($brands[$m] ?? 0) + 1; } + $models[mb_strtolower(trim($d['modell_bezeichnung'] ?? ''))] = true; + } + // Nur fragen, wenn es wirklich EIN Modell bei MEHREREN Marken ist. + if (count($brands) > 1 && count($models) === 1) { + arsort($brands); + $v2_brandpick = $brands; + } + + // --- Stufe 1b: Varianten-Auswahl (BSH-Bauzustaende) --- + // SIEMENS/BOSCH haengen "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde + // das Suffix weg ("WT46W560BY"), kommen alle Bauzustaende mit gleichem Score + // zurueck und unterscheiden sich NUR im Produktionszeitraum. Dann fragen wir + // danach, statt zehn optisch identische Zeilen zu zeigen. + if (!$v2_brandpick) { + $basis = []; + foreach ($topgroup as $d) { + $n = trim($d['nr'] ?? ''); + $basis[($p = strpos($n, '/')) !== false ? substr($n, 0, $p) : $n] = true; + } + if (count($basis) === 1 && count($topgroup) > 1 && count($brands) <= 1) { + $v2_variant_base = (string)array_key_first($basis); + // Nur wenn die Treffer wirklich Suffix-Varianten sind, nicht zufaellig + // gleichnamige Geraete: mindestens einer muss ein "/" tragen. + $has_suffix = false; + foreach ($topgroup as $d) { if (strpos((string)($d['nr'] ?? ''), '/') !== false) { $has_suffix = true; break; } } + if ($has_suffix) { + $det_batch = $db->getGeraetDetailsBatch(array_column($topgroup, 'geraet_id')); + foreach ($topgroup as $d) { + $det = $det_batch[$d['geraet_id']] ?? []; + $v2_variants[] = [ + 'nr' => (string)($d['nr'] ?? ''), + 'id' => $d['geraet_id'], + 'von' => $det['produktionsstart'] ?? '', + 'bis' => $det['produktionsende'] ?? '', + 'typ_de'=> $det['typ_de'] ?? '', + 'marke' => $d['marke'] ?? '', + ]; + } + usort($v2_variants, function ($a, $b) { return strcmp($a['nr'], $b['nr']); }); + } + } + } + } + + // --- Stufe 2: gibt es EINEN klaren Top-Treffer? --- + // Nur dann darf man "Das ist dein Geraet" behaupten. Bedingung: Top-Score >= 95 + // UND mindestens 10 Punkte Abstand zum Zweiten. Beispiel "Lavatherm 400": 100 vs. 75. + // Entfaellt, solange die Hersteller-Frage offen ist. + if (!$v2_brandpick && !$v2_variants && count($results) > 0) { + $v2_top_score_raw = (int)$results[0]['score']; + $v2_second_raw = isset($results[1]) ? (int)$results[1]['score'] : 0; + if ($v2_top_score_raw >= 95 && ($v2_top_score_raw - $v2_second_raw) >= 10) { + $v2_guided = true; + $v2_top_detail = $db->getGeraetDetails($results[0]['geraet_id']); + } + } + + // --- Ersatzteile + Shop-Artikel NUR fuer den Top-Treffer vorladen --- + // Bewusst erst hier, nach Hersteller-Filter und Stufen-Ermittlung: vorher stand an + // Position 0 evtl. ein anderes Geraet. Die uebrigen Kacheln sind eingeklappt und + // laden per AJAX nach (siehe Endpoint am Dateianfang) -- sie brauchen bis dahin nur + // die Anzahl, die ein einzelner Batch-Query liefert statt 20 Einzelabfragen. + if (isset($results[0])) { + $results[0]['ersatzteile'] = $db->getErsatzteileForGeraet($results[0]['geraet_id']); + if ($show_shop_items) { + $results[0]['shop_items'] = $db->getAvailableItemsForGeraet($results[0]['geraet_id'], 200); + } + } + $v2_partcounts = count($results) > 1 + ? $db->getErsatzteilCountsBatch(array_column($results, 'geraet_id')) + : []; + + // --- Shop-Links fuer die Ersatzteil-Chips --- + // Die Chips kommen aus PostgreSQL und kennen nur navision_id. Damit sie -- wie die + // Artikel weiter unten -- in den Shop verlinken, wird einmalig gesammelt aufgeloest. + // Nur die tatsaechlich gerenderten (max. 10 je Geraet), sonst waeren es Tausende. + // Nur fuer den Top-Treffer: dessen Ersatzteil-Chips sind die einzigen, die ohne + // Lazy-Load gerendert werden. Vorher sammelte das die navision_ids ALLER 20 Geraete + // (bis 200 Stueck) fuer einen IN-Query, dessen Ergebnis niemand brauchte. + if (isset($results[0])) { + $nav_ids = []; + foreach (array_slice($results[0]['ersatzteile'] ?? [], 0, 10) as $p) { + if (!empty($p['navision_id'])) { $nav_ids[] = $p['navision_id']; } + } + if ($nav_ids) { $v2_shopmap = $db->getShopItemsByNavisionIds($nav_ids); } + } } } catch (Exception $e) { $error_message = $e->getMessage(); @@ -54,7 +226,7 @@ try { } .container { - max-width: 1200px; + max-width: 1600px; margin: 0 auto; background: white; padding: 30px; @@ -62,6 +234,51 @@ try { box-shadow: 0 2px 4px rgba(0,0,0,0.1); } + .split-results { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 20px; + } + .split-results > div { + min-width: 0; + } + .split-results h2 { + font-size: 16px; + color: #444; + background: #f4f4f4; + padding: 10px 14px; + border-left: 4px solid #4CAF50; + margin-bottom: 14px; + } + .split-results .right-pane h2 { + border-left-color: #1976D2; + } + .item-card { + border: 1px solid #e0e0e0; + border-radius: 4px; + margin-bottom: 8px; + padding: 10px 12px; + background: #fafafa; + font-size: 13px; + } + .item-card .item-name { + font-weight: 600; + color: #333; + margin-bottom: 4px; + } + .item-card .item-meta { + color: #666; + font-size: 12px; + } + .item-card .item-meta strong {color:#1976D2;} + .inventory-positive {color:#2e7d32;font-weight:bold;} + .inventory-zero {color:#aaa;} + .inventory-null {color:#aaa;font-style:italic;} + @media (max-width: 900px) { + .split-results { grid-template-columns: 1fr; } + } + h1 { color: #333; margin-bottom: 20px; @@ -295,11 +512,9 @@ try { />
-
+ @@ -311,151 +526,691 @@ try { -
- 0): ?> - Geräte gefunden für "" - ( ms) - - Keine Ergebnisse für "" - -
+ Bauzustand -> Bestaetigung. + // ================================================================== + // Treffer in "stark" (>=75) und "schwach" (<75) teilen + $v2_strong = []; $v2_weak = []; + foreach ($results as $d) { ((int)$d['score'] >= 75) ? $v2_strong[] = $d : $v2_weak[] = $d; } + // Artikel klassifizieren: kaufbar / ausgelaufen-mit-Nachfolger / tot + $g_buy = []; $g_succ = []; $g_dead = []; + foreach ($item_results as $it) { + $it['_nml'] = v2_nml_full($it); + // Ob ein Ersatz existiert, entscheidet primaer die im Backend gepflegte Zuordnung + // (item_item_assign type=3, angehaengt als $it['alt'] von KremplDB). Sie erreicht + // 4.150 aktive Artikel gegenueber 71 beim Namens-Regex, und ihr Ziel ist nachweislich + // kaufbar. Der Namenshinweis bleibt nur als Rueckfallebene. + if (!$it['_nml']['is_nml']) $g_buy[] = $it; // regulär + elseif (!empty($it['alt']) || $it['_nml']['nachfolger']) $g_succ[] = $it; // ausgelaufen, Ersatz da + else $g_dead[] = $it; // ausgelaufen, kein Ersatz + } + $v2_top_score = isset($results[0]) ? (int)$results[0]['score'] : 0; + // Intent-Einordnung + // Der Leerfall MUSS zuerst geprueft werden: sonst greift der else-Zweig und verspricht + // "Mehrere moegliche Treffer", obwohl 0 Geraete und 0 Artikel gefunden wurden + // (z.B. bei einem Tippfehler wie "spannrolle boisch"). + $v2_leer = (count($results) === 0 && count($item_results) === 0); + if ($v2_leer) $v2_headline = 'Dazu haben wir nichts gefunden.'; + elseif ($v2_top_score >= 90) $v2_headline = 'Wir haben dein Gerät gefunden.'; + elseif (count($v2_strong) > 0) $v2_headline = 'Das könnte dein Gerät sein:'; + elseif (count($item_results)) $v2_headline = 'Passende Artikel gefunden:'; + else $v2_headline = 'Mehrere mögliche Treffer – grenz gern ein.'; + ?> + - - = 100) $score_class = 'score-100'; - elseif ($score >= 95) $score_class = 'score-95'; - elseif ($score >= 90) $score_class = 'score-90'; - elseif ($score >= 80) $score_class = 'score-80'; - elseif ($score >= 75) $score_class = 'score-75'; - elseif ($score >= 60) $score_class = 'score-60'; - ?> +
+ +
+ + +
+ Prüf bitte die Schreibweise, oder gib die Typnummer vom Typenschild ein + (z.B. WT46W560BY oder Lavatherm 400). +
+ +
+ + · kaufbare Artikel + + + +
+ - '; + exit; + } + try { + $db = new KremplDB(true); + $buy = $db->getAvailableItemsForGeraet($gid, 200); + if ($buy) { + echo '

Verfügbare Artikel für dieses Gerät (' . count($buy) . '):

'; + echo v2_artikel_liste($buy, 12, 12); + } else { + // Zwei sehr unterschiedliche Faelle, die man nicht in einen Satz packen darf: + // a) der Hersteller fuehrt zu diesem Geraet gar keine Ersatzteile (z.B. Beko + // CN332100 BEKO_RUSSIA, 0 Eintraege in ersatzteil_mapping) -- da ist nichts zu holen. + // b) es gibt Ersatzteile, aber keines davon ist ein aktiver Shop-Artikel -- das + // lohnt eine Rueckfrage beim Grosshandel. + // Der Zusatz-Query laeuft nur im Leerfall. + $parts = $db->getErsatzteileForGeraet($gid, 1); + if (!$parts) { + echo '
Zu diesem Gerät sind in den Herstellerdaten ' + . 'keine Ersatzteile hinterlegt.
'; + } else { + echo '
Für dieses Gerät sind Ersatzteile bekannt, aber ' + . 'derzeit keines davon im Shop verfügbar.
'; + } + } + } catch (Exception $e) { + http_response_code(500); + echo '
Artikel konnten nicht geladen werden.
'; + } + exit; +} + +$search_term = isset($_GET['q']) ? trim($_GET['q']) : ''; +$results = []; +$item_results = []; +$search_time = 0; +$item_search_time = 0; +$error_message = ''; +// "Nur verfuegbare Shop-Artikel" ist jetzt der IST-Zustand (Default AN): das verkleinert die +// Trefferliste auf real bestellbare Teile, und getAvailableItemsForGeraet() liefert dabei +// items.id mit -- also sind die Chips direkt in den Shop verlinkbar. Abschaltbar mit +// ?shop_items=0 (die Checkbox sendet das ueber ein vorgeschaltetes hidden-Feld). +$show_shop_items = !isset($_GET['shop_items']) || $_GET['shop_items'] !== '0'; +$v2_guided = false; // gefuehrte Ansicht aktiv (ein klarer Top-Treffer) +$v2_top_detail = null; // Volldaten des Top-Geraets aus geraete +$v2_brandpick = []; // marke => anzahl, wenn erst der Hersteller geklaert werden muss +$v2_marke = isset($_GET['marke']) ? trim($_GET['marke']) : ''; // gewaehlter Hersteller +$v2_variants = []; // BSH-Bauzustaende /01 /02 ..., wenn nur der Zeitraum unterscheidet +$v2_variant_base = ''; // gemeinsame Typnummer ohne Suffix, z.B. "WT46W560BY" +$v2_shopmap = []; // navision_id => Shop-Artikel, fuer die Links an den Chips +$v2_partcounts = []; // geraet_id => Anzahl Ersatzteile (fuer die eingeklappten Kacheln) + +try { + // MySQL wird in beiden Fällen gebraucht (rechte Spalte = Artikel-Suche) + $db = new KremplDB(true); + + if ($search_term !== '') { + // Links: Geräte-Suche (PostgreSQL) + $start_time = microtime(true); + // Ersatzteile je Treffer nur im Debug-Modus (?shop_items=0) mitladen -- dort zeigen die + // Kacheln Ersatzteil-Chips statt des Lazy-Aufklappers. Im Normalfall kostet das 20 + // ueberfluessige PG-Queries, deren Ergebnis nur der Top-Treffer braucht. + $results = $db->searchGeraete($search_term, 20, !$show_shop_items); + $search_time = (microtime(true) - $start_time) * 1000; + + // Rechts: Artikel-/Ersatzteil-Suche (MySQL) + $start_time2 = microtime(true); + $item_results = $db->searchItems($search_term, 20); + $item_search_time = (microtime(true) - $start_time2) * 1000; + + // --- Hersteller-Filter (kommt aus der Hersteller-Rueckfrage) --- + if ($v2_marke !== '') { + $results = array_values(array_filter($results, function ($d) use ($v2_marke) { + return isset($d['marke']) && trim($d['marke']) === $v2_marke; + })); + } + + // --- Stufe 1: muss erst der Hersteller geklaert werden? --- + // Dasselbe Modell laeuft oft unter mehreren Konzern-/Versandhausmarken. Dann haben + // alle Top-Treffer denselben Score und denselben Modellnamen -- eine Bestaetigung + // waere geraten. Der Hersteller steht dem Kunden aber auf dem Typenschild zur + // Verfuegung, also fragen wir zuerst danach. Beispiel MUFF21VLQS: 10x Score 100 + // ueber AEG, Electrolux, Frigidaire, Gibson. + if (count($results) > 1) { + $top_score = (int)$results[0]['score']; + $topgroup = array_filter($results, function ($d) use ($top_score) { + return (int)$d['score'] === $top_score; + }); + $brands = []; + $models = []; + foreach ($topgroup as $d) { + $m = trim($d['marke'] ?? ''); + if ($m !== '') { $brands[$m] = ($brands[$m] ?? 0) + 1; } + $models[mb_strtolower(trim($d['modell_bezeichnung'] ?? ''))] = true; + } + // Nur fragen, wenn es wirklich EIN Modell bei MEHREREN Marken ist. + if (count($brands) > 1 && count($models) === 1) { + arsort($brands); + $v2_brandpick = $brands; + } + + // --- Stufe 1b: Varianten-Auswahl (BSH-Bauzustaende) --- + // SIEMENS/BOSCH haengen "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde + // das Suffix weg ("WT46W560BY"), kommen alle Bauzustaende mit gleichem Score + // zurueck und unterscheiden sich NUR im Produktionszeitraum. Dann fragen wir + // danach, statt zehn optisch identische Zeilen zu zeigen. + if (!$v2_brandpick) { + $basis = []; + foreach ($topgroup as $d) { + $n = trim($d['nr'] ?? ''); + $basis[($p = strpos($n, '/')) !== false ? substr($n, 0, $p) : $n] = true; + } + if (count($basis) === 1 && count($topgroup) > 1 && count($brands) <= 1) { + $v2_variant_base = (string)array_key_first($basis); + // Nur wenn die Treffer wirklich Suffix-Varianten sind, nicht zufaellig + // gleichnamige Geraete: mindestens einer muss ein "/" tragen. + $has_suffix = false; + foreach ($topgroup as $d) { if (strpos((string)($d['nr'] ?? ''), '/') !== false) { $has_suffix = true; break; } } + if ($has_suffix) { + $det_batch = $db->getGeraetDetailsBatch(array_column($topgroup, 'geraet_id')); + foreach ($topgroup as $d) { + $det = $det_batch[$d['geraet_id']] ?? []; + $v2_variants[] = [ + 'nr' => (string)($d['nr'] ?? ''), + 'id' => $d['geraet_id'], + 'von' => $det['produktionsstart'] ?? '', + 'bis' => $det['produktionsende'] ?? '', + 'typ_de'=> $det['typ_de'] ?? '', + 'marke' => $d['marke'] ?? '', + ]; + } + usort($v2_variants, function ($a, $b) { return strcmp($a['nr'], $b['nr']); }); + } + } + } + } + + // --- Stufe 2: gibt es EINEN klaren Top-Treffer? --- + // Nur dann darf man "Das ist dein Geraet" behaupten. Bedingung: Top-Score >= 95 + // UND mindestens 10 Punkte Abstand zum Zweiten. Beispiel "Lavatherm 400": 100 vs. 75. + // Entfaellt, solange die Hersteller-Frage offen ist. + if (!$v2_brandpick && !$v2_variants && count($results) > 0) { + $v2_top_score_raw = (int)$results[0]['score']; + $v2_second_raw = isset($results[1]) ? (int)$results[1]['score'] : 0; + if ($v2_top_score_raw >= 95 && ($v2_top_score_raw - $v2_second_raw) >= 10) { + $v2_guided = true; + $v2_top_detail = $db->getGeraetDetails($results[0]['geraet_id']); + } + } + + // --- Ersatzteile + Shop-Artikel NUR fuer den Top-Treffer vorladen --- + // Bewusst erst hier, nach Hersteller-Filter und Stufen-Ermittlung: vorher stand an + // Position 0 evtl. ein anderes Geraet. Die uebrigen Kacheln sind eingeklappt und + // laden per AJAX nach (siehe Endpoint am Dateianfang) -- sie brauchen bis dahin nur + // die Anzahl, die ein einzelner Batch-Query liefert statt 20 Einzelabfragen. + if (isset($results[0])) { + $results[0]['ersatzteile'] = $db->getErsatzteileForGeraet($results[0]['geraet_id']); + if ($show_shop_items) { + $results[0]['shop_items'] = $db->getAvailableItemsForGeraet($results[0]['geraet_id'], 200); + } + } + $v2_partcounts = count($results) > 1 + ? $db->getErsatzteilCountsBatch(array_column($results, 'geraet_id')) + : []; + + // --- Shop-Links fuer die Ersatzteil-Chips --- + // Die Chips kommen aus PostgreSQL und kennen nur navision_id. Damit sie -- wie die + // Artikel weiter unten -- in den Shop verlinken, wird einmalig gesammelt aufgeloest. + // Nur die tatsaechlich gerenderten (max. 10 je Geraet), sonst waeren es Tausende. + // Nur fuer den Top-Treffer: dessen Ersatzteil-Chips sind die einzigen, die ohne + // Lazy-Load gerendert werden. Vorher sammelte das die navision_ids ALLER 20 Geraete + // (bis 200 Stueck) fuer einen IN-Query, dessen Ergebnis niemand brauchte. + if (isset($results[0])) { + $nav_ids = []; + foreach (array_slice($results[0]['ersatzteile'] ?? [], 0, 10) as $p) { + if (!empty($p['navision_id'])) { $nav_ids[] = $p['navision_id']; } + } + if ($nav_ids) { $v2_shopmap = $db->getShopItemsByNavisionIds($nav_ids); } + } + } +} catch (Exception $e) { + $error_message = $e->getMessage(); +} +?> + + + + + + Krempl PostgreSQL Search Test + + + +
+

Krempl PostgreSQL Search Test

+ + + + +
+ Fehler bei der Datenbankverbindung: + +
+ + + + Bauzustand -> Bestaetigung. + // ================================================================== + // Treffer in "stark" (>=75) und "schwach" (<75) teilen + $v2_strong = []; $v2_weak = []; + foreach ($results as $d) { ((int)$d['score'] >= 75) ? $v2_strong[] = $d : $v2_weak[] = $d; } + // Artikel klassifizieren: kaufbar / ausgelaufen-mit-Nachfolger / tot + $g_buy = []; $g_succ = []; $g_dead = []; + foreach ($item_results as $it) { + $it['_nml'] = v2_nml_full($it); + // Ob ein Ersatz existiert, entscheidet primaer die im Backend gepflegte Zuordnung + // (item_item_assign type=3, angehaengt als $it['alt'] von KremplDB). Sie erreicht + // 4.150 aktive Artikel gegenueber 71 beim Namens-Regex, und ihr Ziel ist nachweislich + // kaufbar. Der Namenshinweis bleibt nur als Rueckfallebene. + if (!$it['_nml']['is_nml']) $g_buy[] = $it; // regulär + elseif (!empty($it['alt']) || $it['_nml']['nachfolger']) $g_succ[] = $it; // ausgelaufen, Ersatz da + else $g_dead[] = $it; // ausgelaufen, kein Ersatz + } + $v2_top_score = isset($results[0]) ? (int)$results[0]['score'] : 0; + // Intent-Einordnung + // Der Leerfall MUSS zuerst geprueft werden: sonst greift der else-Zweig und verspricht + // "Mehrere moegliche Treffer", obwohl 0 Geraete und 0 Artikel gefunden wurden + // (z.B. bei einem Tippfehler wie "spannrolle boisch"). + $v2_leer = (count($results) === 0 && count($item_results) === 0); + if ($v2_leer) $v2_headline = 'Dazu haben wir nichts gefunden.'; + elseif ($v2_top_score >= 90) $v2_headline = 'Wir haben dein Gerät gefunden.'; + elseif (count($v2_strong) > 0) $v2_headline = 'Das könnte dein Gerät sein:'; + elseif (count($item_results)) $v2_headline = 'Passende Artikel gefunden:'; + else $v2_headline = 'Mehrere mögliche Treffer – grenz gern ein.'; + ?> + + +
+ +
+ + +
+ Prüf bitte die Schreibweise, oder gib die Typnummer vom Typenschild ein + (z.B. WT46W560BY oder Lavatherm 400). +
+ +
+ + · kaufbare Artikel + + + +
+ + + + + +
+ Hersteller: + ↺ alle Hersteller zeigen +
+ + + +
+
Mehrere Hersteller führen dieses Modell
+
+
Welcher Hersteller steht auf deinem Gerät?
+
+ $n): ?> + + + + +
+
+ Der Hersteller steht meist auf dem Typenschild oder als Logo auf der Blende. +
+
+ + + +
+
Dieses Modell gibt es in mehreren Bauzuständen
+
+
+ + · Varianten +
+
+ Wann wurde dein Gerät gebaut? Die Nummer auf dem Typenschild endet auf + /01, /02 … – wenn du sie findest, ist das der schnellste Weg. +
+
+ + + + + + +
+
+ + + +
+
Ist das dein Gerät?
+
+
+
+ +
Gerätetyp
+ + +
Bauart
+ + +
Baujahr
+ +
+ + + +
+

Verfügbare Artikel für dieses Gerät ():

+ +
+ +
+

passende Ersatzteile — Auswahl:

+ + + + + 10): ?> + + weitere + +
+ + +
+ + Ja – Artikel im Shop zeigen () + + des + // Alternativen-Blocks direkt darunter. Das genuegt. ?> +
+
+ + + count($a); }); + $v2_list = []; + foreach ($by_marke as $m => $ds) { + $v2_mcount[$m] = count($ds); + foreach ($ds as $d) { $d['_mgroup'] = $m; $v2_list[] = $d; } + } + } + ?> + $d): + list($clabel,$ccol,$cbg,$cicon) = v2_confidence($d['score']); + $parts = $d['ersatzteile'] ?? []; + $buy = $d['shop_items'] ?? null; // nur wenn shop_items-Toggle an + if (!$v2_alt_open && ($v2_guided && $v2_top_detail)) { + echo '
Nicht mein Gerät – ' + . $v2_alt_total . ' weitere Treffer anzeigen'; + $v2_alt_open = true; + } + // Bei der Varianten-Karte stehen dieselben Geraete schon als Buttons oben -- + // die ausfuehrliche Liste (mit Ersatzteilen) deshalb ebenfalls einklappen. + if (!$v2_alt_open && $v2_variants) { + echo '
Alle ' + . count($v2_variants) . ' Bauzustände mit Ersatzteilen anzeigen'; + $v2_alt_open = true; + } + // Dito bei der Hersteller-Rueckfrage: erst die Marke klaeren, die Rohliste + // (oft 20 optisch identische Kacheln) gehoert dahinter. + if (!$v2_alt_open && $v2_brandpick) { + echo '
Alle ' + . count($results) . ' Treffer ungefiltert anzeigen'; + $v2_alt_open = true; + } + if (isset($d['_mgroup']) && $d['_mgroup'] !== $v2_last_mgroup) { + $v2_last_mgroup = $d['_mgroup']; + echo '

'.htmlspecialchars($v2_last_mgroup) + . ' '.(int)($v2_mcount[$v2_last_mgroup] ?? 0).'

'; + } + ?> +
+
+
+
+
+ Typ: + · Nr: + + · Baujahr: + +
+
+ + + +
+ + +
+

Verfügbare Artikel für dieses Gerät ():

+ + +
+ + +
+
+ Verfügbare Artikel im Shop anzeigen +
+
+ + +
Ersatzteile laut Herstellerdaten hinterlegt
+ +
+ +
+

passende Ersatzteile — Auswahl:

+ + + + + 10): ?> + + weitere + +
+ +
+ + + + +
+ Weitere mögliche Geräte anzeigen () – schwächere Treffer + +
+
+
+
+
Typ: · Nr:
+
+ +
+
+ +
+ + + '; } + ?> + + + + +
+

🛒 Diese Artikel kannst du direkt kaufen

+ 0 -- 10.160x 0, 8.724x NULL). + // Bestellbar steuert sie ueber items.active, und searchItems() filtert + // bereits mit WHERE i.active = 1 -> alles hier ist bestellbar. + // Shop-Artikel und Bilder liegen nur auf LIVE -> absolute URLs + $url = v2_shopurl($it['id'] ?? null, $it['short_uri'] ?? null); + $img = !empty($it['default_image_file_name']) + ? tba_url('web/intelectra_shop/images/article/'.rawurlencode($it['default_image_file_name'])) + : ''; + $pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0; + $bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0; + ?> +
+ + + + +
kein Bild
+ +
+
+
Art-Nr
+
+
+
+
+ 0 && $pb > 0 && $bb < $pb): ?> +  € +  € + 0): ?> +  € + + Preis auf Anfrage + +
+ 0): ?>
inkl. MwSt.
+ + Zum Artikel + +
+
+ +
+ + + +
+

🔄 Ausgelaufen – aber es gibt einen Nachfolger

+ +
+
+ +
+ +
+
+
Art-Nr · nicht mehr lieferbar
+
+ + landete in + // 18 von 58 Faellen wieder bei einem ausgelaufenen Artikel. + $ap = isset($alt['price_brutto']) ? (float)$alt['price_brutto'] : 0; + $ab = !empty($alt['bargain_brutto']) ? (float)$alt['bargain_brutto'] : 0; + ?> +
+
→ Ersatz verfügbar
+
+ +
+
+ Art-Nr + 0 && $ap > 0 && $ab < $ap): ?> + · € + 0): ?> + · € + + 1): ?> + · über Nachfolger ermittelt + +
+ + → Zum Ersatzartikel + +
+ +
+
Nachfolger:
+ + → Nachfolger suchen + +
+ +
+ +
+ + + +
+ Nicht mehr lieferbar, kein Ersatz () – ausgeblendet + +
+
+
+
Art-Nr
+
+
✕ entfällt
+
+ +
+ + + + + + +
+ + +
+ Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "Kohlefilter Bauknecht") +
+ +
+ + + + diff --git a/themes/easyshop_advanced/templates/content_box_textbox.tpl b/themes/easyshop_advanced/templates/content_box_textbox.tpl index d692110..5ac96ed 100644 --- a/themes/easyshop_advanced/templates/content_box_textbox.tpl +++ b/themes/easyshop_advanced/templates/content_box_textbox.tpl @@ -21,7 +21,7 @@
- {if $block->configuration.image_name} + {if isset($block->configuration.image_name) && $block->configuration.image_name} {if $block->configuration.image_position == 'left'} {$padding='padding-right:10px;'} {elseif $block->configuration.image_position == 'right'} diff --git a/themes/easyshop_advanced/templates/main.tpl b/themes/easyshop_advanced/templates/main.tpl index 4d4f3ad..e901e4b 100644 --- a/themes/easyshop_advanced/templates/main.tpl +++ b/themes/easyshop_advanced/templates/main.tpl @@ -190,6 +190,60 @@ - {if $search_item_categories} + {if isset($search_item_categories) && $search_item_categories}
{if isset($content_box_title) || $content_box_title} diff --git a/web/intelectra_shop/themes/theme2/templates/item_box.tpl b/web/intelectra_shop/themes/theme2/templates/item_box.tpl index 889821e..da07336 100644 --- a/web/intelectra_shop/themes/theme2/templates/item_box.tpl +++ b/web/intelectra_shop/themes/theme2/templates/item_box.tpl @@ -31,7 +31,7 @@ Zeige alle Bilder {/if} - {if $show_memory_items == 1} + {if isset($show_memory_items) && $show_memory_items == 1} von Merkliste entfernen {/if}
@@ -69,7 +69,7 @@ {/if}
@@ -212,7 +212,7 @@ {/if}
- {if $shopArticle->sample_price && $shopArticle->sample_unit && $shopArticle->sample_price > 0 || $item_debug} + {if $shopArticle->sample_price && $shopArticle->sample_unit && $shopArticle->sample_price > 0 || (isset($item_debug) && $item_debug)} Preis / {$shopArticle->sample_unit} = {$shopArticle->sample_price|number_format:"2":",":"."} €
{/if} {$shopArticle->price_tax_text} diff --git a/web/intelectra_shop/themes/theme2/templates/main.tpl b/web/intelectra_shop/themes/theme2/templates/main.tpl index a630280..bca1eea 100644 --- a/web/intelectra_shop/themes/theme2/templates/main.tpl +++ b/web/intelectra_shop/themes/theme2/templates/main.tpl @@ -1,329 +1,374 @@ - - - - - - - - {if isset($shopArticle) && $shopArticle->id && $shopArticle->structure_id != $menu_id} - - {/if} - - - - {foreach item=val key=key from=$metatags} - {if $key != 'footer_text'} - {if $key != 'title'} - - {else} - {$val} - {/if} - {/if} - {/foreach} - - - - - - - - - - - - - {**} - - - - - - - - - - - - - - {if isset($theme_data->css) && $theme_data->css} - - {/if} - - - - {**} - {**} - - - - - - - {**} - - - - - - - - - - - {literal} - - - - - {/literal} - - {literal} - - - - - {/literal} - - - - -
{$template_text.default.searchload_text}
- -
-
- - - {if $items_in_memory } - Merkliste ({$items_in_memory}) - {/if} - - Schnelleinkauf -
-
-
-
-
- Sachsenring 48, D-94315 Straubing

- Ersatzteile und Zubehör für Elektro Hausgeräte | Versandgroßhandel | Einzelhandel    
- Telefonische Beratung: +49 (0) 9421/62015   -
- {if $top_widgets} - {foreach from=$top_widgets item=widget} - {$widget} - {/foreach} - {/if} -
-
-
-
-
- {if $header_widgets} - {foreach from=$header_widgets item=widget} - {$widget} - {/foreach} - {/if} -
-
-
-
- {if $subheader_widgets} - {foreach from=$subheader_widgets item=widget} - {$widget} - {/foreach} - {/if} -
- -
- {*
header_image}style="background:transparent url({$SHOP_DIR}images/header/{$theme_data->header_image}) no-repeat;"{/if}>
*} - - - -
- {if !isset($site_type) || $site_type != 'full_width'} - - - - -
- -
- -
-

Suche

-
-
-
- {*
- - - - -
*} -
- - - -
- - {if $template_text.main_widget_search.subsearch_field_text} -
{$template_text.main_widget_search.subsearch_field_text}
- {/if} - - {if $template_text.main_widget_search.above_field_text} -
{$template_text.main_widget_search.above_field_text}
- {/if} -
-
-


-
- {$maincontent} -
-
- {else} - -
- -
- -
-

Suche

-
-
-
- {*
- - - - -
*} -
- - -
- - {if $template_text.main_widget_search.subsearch_field_text} -
{$template_text.main_widget_search.subsearch_field_text}
- {/if} - - {if $template_text.main_widget_search.above_field_text} -
{$template_text.main_widget_search.above_field_text}
- {/if} - {if $template_text.main_widget_search.above_field_text2} -
{$template_text.main_widget_search.above_field_text2}
- {/if} - {if $template_text.main_widget_search.above_field_text3} -
{$template_text.main_widget_search.above_field_text3}
- {/if} -
-
-


-
- {$maincontent} -
-
- {/if} -
-
- - - - - - - - - - + + + + + + + + {if isset($shopArticle) && $shopArticle->id && $shopArticle->structure_id != $menu_id} + + {/if} + + + + {foreach item=val key=key from=$metatags} + {if $key != 'footer_text'} + {if $key != 'title'} + + {else} + {$val} + {/if} + {/if} + {/foreach} + + + + + + + + + + + + + {**} + + + + + + + + + + + + + + {if isset($theme_data->css) && $theme_data->css} + + {/if} + + + + {**} + {**} + + + + + + + {**} + + + + + + + + + + + {literal} + + {/literal} + + {* Entfernt am 16.08.2026 (Angleich an Live, dort am 05.08.2026 entfernt) -- alles tot: + - Google Optimize Anti-Flicker-Snippet: Optimize ist seit September 2023 abgeschaltet. + Das Snippet setzte "opacity: 0" auf und wartete bis zu 4000 ms darauf, dass + Optimize die Klasse wieder entfernt. Da nie jemand antwortet, lief die Wartezeit + immer voll durch -- bis zu 4 Sekunden leere Seite. + - Universal Analytics (UA-126168744-1, gtag.js + analytics.js): UA verarbeitet seit + Juli 2023 keine Daten mehr. + - "GTM-T7HR4RC" war KEIN Tag-Manager-Container, sondern die Optimize-Container-ID. + + reCAPTCHA bleibt (wird von den Formularen gebraucht), steht aber weiterhin OHNE + async/defer im und blockiert damit das Rendering -- offener Punkt, auf Live + ebenso. *} + + + + +
{$template_text.default.searchload_text}
+ +
+
+ + + {if $items_in_memory } + Merkliste ({$items_in_memory}) + {/if} + + Schnelleinkauf +
+
+
+
+
+ Sachsenring 48, D-94315 Straubing

+ Ersatzteile und Zubehör für Elektro Hausgeräte | Versandgroßhandel | Einzelhandel    
+ Telefonische Beratung: +49 (0) 9421/62015   +
+ {if $top_widgets} + {foreach from=$top_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+
+ {if $header_widgets} + {foreach from=$header_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+ {if $subheader_widgets} + {foreach from=$subheader_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+ +
+ {*
header_image}style="background:transparent url({$SHOP_DIR}images/header/{$theme_data->header_image}) no-repeat;"{/if}>
*} + + + +
+ {if !isset($site_type) || $site_type != 'full_width'} + + + + +
+ +
+ +
+

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {else} + +
+ +
+ +
{* width kam als Inline-Style (992px) und schlug jede CSS-Regel -- jetzt in customized_theme.css, damit die Breitenstufen greifen *} +

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} + {if $template_text.main_widget_search.above_field_text2} +
{$template_text.main_widget_search.above_field_text2}
+ {/if} + {if $template_text.main_widget_search.above_field_text3} +
{$template_text.main_widget_search.above_field_text3}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {/if} +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_google b/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_google new file mode 100644 index 0000000..0ff008b --- /dev/null +++ b/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_google @@ -0,0 +1,383 @@ + + + + + + + + {if isset($shopArticle) && $shopArticle->id && $shopArticle->structure_id != $menu_id} + + {/if} + + + + {foreach item=val key=key from=$metatags} + {if $key != 'footer_text'} + {if $key != 'title'} + + {else} + {$val} + {/if} + {/if} + {/foreach} + + + + + + + + + + + + + {**} + + + + + + + + + + + + + + {if isset($theme_data->css) && $theme_data->css} + + {/if} + + + + {**} + {**} + + + + + + + {**} + + + + + + + + + + + {literal} + + + + + {/literal} + + {literal} + + + + + {/literal} + + + + +
{$template_text.default.searchload_text}
+ +
+
+ + + {if $items_in_memory } + Merkliste ({$items_in_memory}) + {/if} + + Schnelleinkauf +
+
+
+
+
+ Sachsenring 48, D-94315 Straubing

+ Ersatzteile und Zubehör für Elektro Hausgeräte | Versandgroßhandel | Einzelhandel    
+ Telefonische Beratung: +49 (0) 9421/62015   +
+ {if $top_widgets} + {foreach from=$top_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+
+ {if $header_widgets} + {foreach from=$header_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+ {if $subheader_widgets} + {foreach from=$subheader_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+ +
+ {*
header_image}style="background:transparent url({$SHOP_DIR}images/header/{$theme_data->header_image}) no-repeat;"{/if}>
*} + + + +
+ {if !isset($site_type) || $site_type != 'full_width'} + + + + +
+ +
+ +
+

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {else} + +
+ +
+ +
+

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} + {if $template_text.main_widget_search.above_field_text2} +
{$template_text.main_widget_search.above_field_text2}
+ {/if} + {if $template_text.main_widget_search.above_field_text3} +
{$template_text.main_widget_search.above_field_text3}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {/if} +
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_inlinewidth b/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_inlinewidth new file mode 100644 index 0000000..0468f53 --- /dev/null +++ b/web/intelectra_shop/themes/theme2/templates/main.tpl.bak_20260816_inlinewidth @@ -0,0 +1,374 @@ + + + + + + + + {if isset($shopArticle) && $shopArticle->id && $shopArticle->structure_id != $menu_id} + + {/if} + + + + {foreach item=val key=key from=$metatags} + {if $key != 'footer_text'} + {if $key != 'title'} + + {else} + {$val} + {/if} + {/if} + {/foreach} + + + + + + + + + + + + + {**} + + + + + + + + + + + + + + {if isset($theme_data->css) && $theme_data->css} + + {/if} + + + + {**} + {**} + + + + + + + {**} + + + + + + + + + + + {literal} + + {/literal} + + {* Entfernt am 16.08.2026 (Angleich an Live, dort am 05.08.2026 entfernt) -- alles tot: + - Google Optimize Anti-Flicker-Snippet: Optimize ist seit September 2023 abgeschaltet. + Das Snippet setzte "opacity: 0" auf und wartete bis zu 4000 ms darauf, dass + Optimize die Klasse wieder entfernt. Da nie jemand antwortet, lief die Wartezeit + immer voll durch -- bis zu 4 Sekunden leere Seite. + - Universal Analytics (UA-126168744-1, gtag.js + analytics.js): UA verarbeitet seit + Juli 2023 keine Daten mehr. + - "GTM-T7HR4RC" war KEIN Tag-Manager-Container, sondern die Optimize-Container-ID. + + reCAPTCHA bleibt (wird von den Formularen gebraucht), steht aber weiterhin OHNE + async/defer im und blockiert damit das Rendering -- offener Punkt, auf Live + ebenso. *} + + + + +
{$template_text.default.searchload_text}
+ +
+
+ + + {if $items_in_memory } + Merkliste ({$items_in_memory}) + {/if} + + Schnelleinkauf +
+
+
+
+
+ Sachsenring 48, D-94315 Straubing

+ Ersatzteile und Zubehör für Elektro Hausgeräte | Versandgroßhandel | Einzelhandel    
+ Telefonische Beratung: +49 (0) 9421/62015   +
+ {if $top_widgets} + {foreach from=$top_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+
+ {if $header_widgets} + {foreach from=$header_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+
+
+
+ {if $subheader_widgets} + {foreach from=$subheader_widgets item=widget} + {$widget} + {/foreach} + {/if} +
+ +
+ {*
header_image}style="background:transparent url({$SHOP_DIR}images/header/{$theme_data->header_image}) no-repeat;"{/if}>
*} + + + +
+ {if !isset($site_type) || $site_type != 'full_width'} + + + + +
+ +
+ +
+

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {else} + +
+ +
+ +
+

Suche

+
+
+
+ {*
+ + + + +
*} +
+ + +
+ + {if $template_text.main_widget_search.subsearch_field_text} +
{$template_text.main_widget_search.subsearch_field_text}
+ {/if} + + {if $template_text.main_widget_search.above_field_text} +
{$template_text.main_widget_search.above_field_text}
+ {/if} + {if $template_text.main_widget_search.above_field_text2} +
{$template_text.main_widget_search.above_field_text2}
+ {/if} + {if $template_text.main_widget_search.above_field_text3} +
{$template_text.main_widget_search.above_field_text3}
+ {/if} +
+
+


+
+ {$maincontent} +
+
+ {/if} +
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/widerruf.php b/widerruf.php index fef87e4..9586dc8 100644 --- a/widerruf.php +++ b/widerruf.php @@ -30,38 +30,104 @@ include_once './core/revocation.class.php'; $revocation = new Revocation($base_object); $view = 'form'; -$prefill = ['name' => '', 'order_number' => '', 'email' => '']; +$prefill = ['name' => '', 'email' => '', 'order_number' => '', 'article' => '']; $error = null; +// ------------------------------------------------------------ +// Tor-Exit-Liste — wird per Cron alle 4h aktualisiert. +// Datei in /var/cache/tor/exit-list.txt, eine IP pro Zeile. +// ------------------------------------------------------------ +function widerruf_is_tor_exit($ip) { + static $set = null; + if ($set === null) { + $f = '/var/cache/tor/exit-list.txt'; + if (file_exists($f) && (time() - filemtime($f)) < 172800) { + $arr = @file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $set = $arr ? array_flip(array_map('trim', $arr)) : []; + } else { + $set = []; // fail-open wenn Liste fehlt/zu alt + } + } + return $ip !== '' && isset($set[$ip]); +} + +$client_ip = $_SERVER['REMOTE_ADDR'] ?? ''; +$is_tor = widerruf_is_tor_exit($client_ip); + // ------------------------------------------------------------ // Request dispatch // ------------------------------------------------------------ if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $form_name = isset($_POST['name']) ? trim((string)$_POST['name']) : ''; - $form_order_number = isset($_POST['order_number']) ? trim((string)$_POST['order_number']) : ''; - $form_email = isset($_POST['email']) ? trim((string)$_POST['email']) : ''; - $reason_id = isset($_POST['reason_id']) ? (int)$_POST['reason_id'] : 0; - $reason_text = isset($_POST['reason_text']) ? (string)$_POST['reason_text'] : ''; - $confirm = isset($_POST['confirm']) && $_POST['confirm'] === '1'; + $ip_now = $_SERVER['REMOTE_ADDR'] ?? ''; + $ua_now = $_SERVER['HTTP_USER_AGENT'] ?? ''; - // Eingaben für Fehlerfall ins Formular zurückspielen - $prefill = [ - 'name' => $form_name, - 'order_number' => $form_order_number, - 'email' => $form_email, - ]; + // ------------------------------------------------------------ + // Bot-Schutz: tor-exit + honeypot + time-trap + session-token + rate-limit. + // Bei Bot-Verdacht still "success" zurückgeben (Bot soll nichts merken). + // ------------------------------------------------------------ + $is_bot = false; + $bot_reason = ''; - if (!$confirm) { - $view = 'form'; - $error = 'no_confirm'; + if ($is_tor) { + $is_bot = true; $bot_reason = 'tor-exit'; + } + + if (!$is_bot && (!empty($_POST['website']) || !empty($_POST['fax']))) { + $is_bot = true; $bot_reason = 'honeypot'; + } + + if (!$is_bot) { + $ts = isset($_POST['ts']) ? (int)$_POST['ts'] : 0; + $delta = time() - $ts; + if ($ts <= 0 || $delta < 4 || $delta > 7200) { + $is_bot = true; $bot_reason = 'timetrap delta='.$delta; + } + } + + if (!$is_bot) { + if (empty($_POST['form_token']) || empty($_SESSION['widerruf_token']) + || !hash_equals((string)$_SESSION['widerruf_token'], (string)$_POST['form_token'])) { + $is_bot = true; $bot_reason = 'token-mismatch'; + } + } + + if (!$is_bot) { + $rl_hash = sha1($ip_now.'|widerruf'); + $rl_file = sys_get_temp_dir().'/widerruf_rl_'.$rl_hash; + $last = file_exists($rl_file) ? (int)@file_get_contents($rl_file) : 0; + if (time() - $last < 60) { + $is_bot = true; $bot_reason = 'ratelimit last='.$last; + } else { + @file_put_contents($rl_file, (string)time()); + } + } + + if ($is_bot) { + error_log('[widerruf] BOT BLOCKED reason='.$bot_reason.' ip='.$ip_now.' ua='.substr($ua_now, 0, 120)); + // Token einmalig verbrauchen, damit derselbe nicht reusable ist + unset($_SESSION['widerruf_token']); + $view = 'success'; } else { - $ip = $_SERVER['REMOTE_ADDR'] ?? ''; - $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + // Token verbrauchen + unset($_SESSION['widerruf_token']); + + $form_name = isset($_POST['name']) ? trim((string)$_POST['name']) : ''; + $form_email = isset($_POST['email']) ? trim((string)$_POST['email']) : ''; + $form_order_number = isset($_POST['order_number']) ? trim((string)$_POST['order_number']) : ''; + $form_article = isset($_POST['article']) ? trim((string)$_POST['article']) : ''; + + $prefill = [ + 'name' => $form_name, + 'email' => $form_email, + 'order_number' => $form_order_number, + 'article' => $form_article, + ]; + if ($revocation->submit_anonymous( $form_name, $form_order_number, $form_email, - $reason_id, $reason_text, $ip, $ua + $form_article, $ip_now, $ua_now )) { $view = 'success'; } else { @@ -72,33 +138,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } else { - // GET — bei Token-Link: Felder vorausfüllen - $token = isset($_GET['t']) ? (string)$_GET['t'] : ''; - if ($token !== '') { - $pf = $revocation->prefill_from_token($token); - if ($pf) { - $prefill = $pf; - } elseif ($revocation->get_error() === 'feature_disabled') { - $view = 'error'; - $error = 'feature_disabled'; + // GET — Tor-Exit-Nutzer:innen sehen statt Form einen Hinweis auf alternative Wege + if ($is_tor) { + error_log('[widerruf] TOR EXIT GET ip='.$client_ip); + $view = 'tor_blocked'; + } else { + // Bei Token-Link: Felder vorausfüllen + $token = isset($_GET['t']) ? (string)$_GET['t'] : ''; + if ($token !== '') { + $pf = $revocation->prefill_from_token($token); + if ($pf) { + $prefill = $pf; + } elseif ($revocation->get_error() === 'feature_disabled') { + $view = 'error'; + $error = 'feature_disabled'; + } + // Bei anderen Token-Fehlern: leeres Formular anbieten ohne Hinweistext } - // Bei anderen Token-Fehlern: leeres Formular anbieten ohne Hinweistext } } +// Neuen Form-Token + Timestamp für Render erzeugen +if ($view === 'form') { + $_SESSION['widerruf_token'] = bin2hex(random_bytes(16)); + $form_token_render = $_SESSION['widerruf_token']; + $form_ts_render = time(); +} + $reasons = $revocation->fetch_reasons(); $messages = [ - 'no_confirm' => 'Bitte bestätigen Sie den Widerruf durch Setzen des Hakens.', - 'missing_order' => 'Bitte geben Sie Ihre Bestellnummer an.', + 'missing_name' => 'Bitte geben Sie Ihren Namen an.', 'invalid_email' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.', - 'order_not_found' => 'Wir konnten keine Bestellung mit dieser Bestellnummer und E-Mail-Adresse finden. ' - . 'Bitte prüfen Sie Ihre Eingaben.', - 'expired' => 'Die 14-tägige Widerrufsfrist ist überschritten. ' - . 'Wenden Sie sich bitte direkt an uns.', 'already_submitted' => 'Für diese Bestellung wurde bereits ein Widerruf erklärt.', 'feature_disabled' => 'Die elektronische Widerrufsfunktion ist aktuell noch nicht aktiv.', - 'not_eligible' => 'Für diese Bestellung steht der elektronische Widerruf nicht zur Verfügung.', ]; $display_error = isset($messages[$error]) ? $messages[$error] : null; @@ -140,39 +213,66 @@ $display_error = isset($messages[$error]) ? $messages[$error] : null;

Vertrag widerrufen

Gemäß § 355 BGB haben Sie das Recht, Ihren Vertrag binnen 14 Tagen - ohne Angabe von Gründen zu widerrufen. Füllen Sie bitte die folgenden - drei Pflichtfelder aus.

+ ohne Angabe von Gründen zu widerrufen. Pflichtfelder sind Name + und E-Mail-Adresse. Bestellnummer und Artikel sind + freiwillige Angaben, die uns die Zuordnung erleichtern.

-
+ + + + + + - - - - + + - + + -

Nach dem Absenden erhalten Sie eine - Bestätigungs-E-Mail mit der Rücksendeadresse und den weiteren Informationen - zur Rücksendung.

+ + +

Mit Klick auf den Button erklären Sie + den Widerruf Ihres Vertrags. Sie erhalten anschließend eine Eingangs­bestätigung + per E-Mail.

+ + +

Widerruf über anonyme Verbindung

+

Sie scheinen über eine anonyme Verbindung (z. B. Tor) auf diese Seite zuzugreifen. + Aus Spam-Schutz-Gründen ist das Online-Formular über anonyme Netzwerke deaktiviert.

+

Ihr Widerrufsrecht bleibt selbstverständlich unverändert. + Sie können Ihren Widerruf jederzeit auf einem der folgenden Wege erklären:

+
    +
  • per E-Mail an
  • +
  • per Brief an die im Impressum genannte Anschrift
  • +
  • mit unserem Muster-Widerrufsformular (Download / Ausdruck)
  • +
+

· + · +

+

Widerruf entgegengenommen