wip: Mobile-Theme-Testphase und PHP-8.2-Guards (Stand Juni 2026)

This commit is contained in:
Thomas Bartelt
2026-09-13 19:10:56 +02:00
parent 08beac8d67
commit 20d55f77b8
1448 changed files with 16015 additions and 583 deletions
+7
View File
@@ -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
+32
View File
@@ -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);
}
+2 -1
View File
@@ -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);
+3 -3
View File
@@ -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) {
+15
View File
@@ -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';
}
+1 -1
View File
@@ -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;
+403 -6
View File
@@ -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 = <<<SQL
WITH RECURSIVE chain AS (
SELECT
a.item_parent_id AS start_id,
a.item_child_id AS cur,
1 AS tiefe,
a.position AS pos,
CAST(CONCAT(',', a.item_parent_id, ',', a.item_child_id, ',') AS CHAR(512)) AS pfad
FROM item_item_assign a
JOIN items p ON p.id = a.item_parent_id
LEFT JOIN structure ps ON ps.id = p.structure_id
WHERE a.type = 3
AND a.item_parent_id IN ($ids_str)
AND (p.name REGEXP '$nml_name' OR COALESCE(ps.name, '') REGEXP '$nml_wg')
UNION ALL
SELECT
ch.start_id,
a.item_child_id,
ch.tiefe + 1,
a.position,
CONCAT(ch.pfad, a.item_child_id, ',')
FROM chain ch
JOIN items c ON c.id = ch.cur AND c.active = 1
LEFT JOIN structure cs ON cs.id = c.structure_id
JOIN item_item_assign a ON a.item_parent_id = ch.cur AND a.type = 3
WHERE ch.tiefe < $max_tiefe
AND ch.pfad NOT LIKE CONCAT('%,', a.item_child_id, ',%')
AND (c.name REGEXP '$nml_name' OR COALESCE(cs.name, '') REGEXP '$nml_wg')
)
SELECT
ch.start_id,
ch.tiefe,
i.id,
i.number,
i.name,
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,
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;
}
@@ -0,0 +1,602 @@
<?php
/**
* Krempl PostgreSQL Database Connection Class
*
* Usage:
* require_once __DIR__ . '/core/postgres/KremplDB.php';
* $db = new KremplDB();
* $results = $db->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);
}
}
}
@@ -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 =
+349
View File
@@ -0,0 +1,349 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
// 05.08.2026 von intelectra.de auf intelectra-shop.de umgestellt: intelectra.de leitet seit
// dem per 301 dorthin um (Domain-Konsolidierung, siehe .htaccess des Live-Docroots).
// Ohne diese Aenderung liefe jeder Artikel- und Bildlink der Suche durch die Weiterleitung.
define('TBA_SHOP_BASE', 'https://intelectra-shop.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> 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 "/<short_uri>.html". Das Feld `items.short_uri`
* ist bei allen 16.927 aktiven Artikeln gefuellt (Format "<Name-Slug>-<Artikelnr>")
* 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 <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false, $short_uri = null) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id, $short_uri);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* 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 !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
// Die aus dem Namen gelesene Nummer nur als Rueckfallebene: liegt eine
// gepflegte Zuordnung vor, steht der Ersatz gleich darunter als Zeile und
// die blosse Nummer waere doppelt.
if (empty($it['alt']) && !empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
$h .= ($url !== '' ? '</a>' : '</div>');
// 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 = '<div class="v2-rowgrp">' . $h . v2_altrow($it['alt']) . '</div>';
}
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 = '<a class="v2-altrow" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
. '<span class="v2-altrow-tag">→ Ersatz</span>'
. '<span class="v2-altrow-txt">'
. '<span class="v2-altrow-name">' . htmlspecialchars((string)($alt['name'] ?? '')) . '</span>'
. '<span class="v2-altrow-nr">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 .= '</span></span><span class="v2-altrow-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b>' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
return $h . '</span></a>';
}
/** 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 = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $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 .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
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 <Nr>"
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)];
}
}
@@ -0,0 +1,272 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
define('TBA_SHOP_BASE', 'https://intelectra.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> 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 <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* 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 !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
if (!empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
return $h . ($url !== '' ? '</a>' : '</div>');
}
/** 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 = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $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 .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
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 <Nr>"
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)];
}
}
+132 -40
View File
@@ -301,9 +301,7 @@ class Revocation {
}
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
$msg .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte Rückzahlung über die Warenwirtschaft '
. 'veranlassen.</strong> Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche '
. 'Bestellung erfolgen.</p>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
. '/index.php?admin_modul=admin_revocation_list">Widerrufsliste öffnen</a></p>';
@@ -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 = '<p>Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',</p>';
$msg .= '<p>hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung '
. '<strong>'.htmlspecialchars($order_number).'</strong> am '
. date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.</p>';
$msg .= '<h3>Nächste Schritte</h3>';
$msg .= '<p>Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:</p>';
$msg .= '<p style="background:#f4f4f4;padding:12px;border-left:4px solid #c00;">'
. '<strong>'.REVOCATION_RETURN_COMPANY.'</strong><br>'
. REVOCATION_RETURN_STREET.'<br>'
. REVOCATION_RETURN_CITY.'</p>';
$msg .= '<p>'.REVOCATION_RETURN_COSTS_NOTE.'</p>';
$msg .= '<p>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.</p>';
$msg .= '<p>Bei Rückfragen erreichen Sie uns jederzeit unter '
. '<a href="mailto:'.REVOCATION_OWNER_EMAIL.'">'.REVOCATION_OWNER_EMAIL.'</a>.</p>';
$msg .= '<hr><p style="font-size:0.85em;color:#666;">'.REVOCATION_RETURN_COMPANY.'</p>';
$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 = '<h2>Widerruf eingegangen</h2>';
$msg .= '<p style="background:#fff3cd;border-left:4px solid #ffa500;padding:10px;">';
$msg .= '<strong>Bitte manuell zuordnen.</strong> Der Widerruf wurde ohne (oder mit nicht gefundener) Bestellnummer eingereicht. ';
$msg .= 'Es wurde kein automatischer DB-Eintrag erzeugt.';
$msg .= '</p>';
$msg .= '<table cellpadding="4" cellspacing="0" border="0" style="font-family:Arial,sans-serif;">';
$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 .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
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 = '<p>'.$anrede.'</p>';
$msg .= '<p>wir best&auml;tigen hiermit den Eingang Ihres Widerrufs mit folgendem Inhalt:</p>';
$msg .= '<ul style="line-height:1.6;">';
$msg .= '<li><strong>Name:</strong> '.$name_safe.'</li>';
$msg .= '<li><strong>E-Mail:</strong> '.htmlspecialchars($consumer_email).'</li>';
$msg .= '<li><strong>Bestellnummer:</strong> '.htmlspecialchars($order_number).'</li>';
if (!empty($article)) {
$msg .= '<li><strong>Artikel/Bemerkung:</strong> '.nl2br(htmlspecialchars($article)).'</li>';
}
$msg .= '<li><strong>Eingang am:</strong> '.htmlspecialchars($date_str).' um '.htmlspecialchars($time_str).' Uhr</li>';
$msg .= '</ul>';
$msg .= '<p>Wir pr&uuml;fen Ihr Anliegen und melden uns zeitnah bei Ihnen.</p>';
$msg .= '<p>Viele Gr&uuml;&szlig;e<br>Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'</p>';
return $mailer->send_mail($subject, $msg, $to);
}
}
+4
View File
@@ -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]);
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+3 -1
View File
@@ -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;
+1
View File
@@ -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;
+896 -141
View File
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
<?php
/**
* Krempl PostgreSQL Search Test Interface
* Search for devices and show matching ersatzteile
*/
require_once __DIR__ . '/core/postgres/KremplDB.php';
$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';
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);
$results = $db->searchGeraete($search_term, 20);
if ($show_shop_items) {
foreach ($results as &$device) {
$device['shop_items'] = $db->getAvailableItemsForGeraet($device['geraet_id'], 200);
}
unset($device);
}
$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;
}
} catch (Exception $e) {
$error_message = $e->getMessage();
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krempl PostgreSQL Search Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
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;
}
.search-box {
margin-bottom: 30px;
}
.search-input {
width: 100%;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #4CAF50;
}
.search-btn {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
white-space: nowrap;
}
.search-btn:hover {
background: #45a049;
}
#shopItemsToggle {
cursor: pointer;
width: 18px;
height: 18px;
}
.search-info {
color: #666;
margin-bottom: 20px;
font-size: 14px;
}
.device-card {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 20px;
background: #fafafa;
overflow: hidden;
}
.device-card.collapsed .ersatzteile-section {
display: none;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 15px;
margin-bottom: 0;
border-bottom: 2px solid #e0e0e0;
}
.score-badge {
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.score-badge:hover {
opacity: 0.9;
transform: scale(1.02);
}
.toggle-arrow {
display: inline-block;
margin-left: 8px;
transition: transform 0.3s;
font-size: 10px;
}
.device-card.collapsed .toggle-arrow {
transform: rotate(-90deg);
}
.device-info {
flex: 1;
}
.device-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.device-title a {
color: #333;
text-decoration: none;
transition: color 0.2s;
}
.device-title a:hover {
color: #4CAF50;
text-decoration: underline;
}
.device-details {
color: #666;
font-size: 14px;
}
.device-details span {
display: inline-block;
margin-right: 15px;
}
.score-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-weight: bold;
font-size: 14px;
}
.score-100 { background: #4CAF50; color: white; }
.score-95 { background: #66BB6A; color: white; }
.score-90 { background: #81C784; color: white; }
.score-80 { background: #FFC107; color: white; }
.score-75 { background: #FFD54F; color: #333; }
.score-60 { background: #FFE082; color: #333; }
.score-default { background: #BDBDBD; color: white; }
.ersatzteile-section {
padding: 15px;
}
.ersatzteile-title {
font-weight: bold;
color: #333;
margin-bottom: 10px;
margin-top: 10px;
font-size: 14px;
}
.ersatzteile-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 4px;
overflow: hidden;
}
.ersatzteile-table th {
background: #f0f0f0;
padding: 8px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #555;
}
.ersatzteile-table td {
padding: 8px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
}
.ersatzteile-table tr:hover {
background: #f9f9f9;
}
.navision-id {
font-weight: bold;
color: #1976D2;
}
.no-results {
text-align: center;
padding: 40px;
color: #999;
}
.match-type {
display: inline-block;
font-size: 11px;
padding: 2px 6px;
background: #e0e0e0;
border-radius: 3px;
color: #666;
margin-left: 8px;
}
.error-message {
background: #ffebee;
border: 1px solid #ef5350;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.error-message strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>Krempl PostgreSQL Search Test</h1>
<div class="search-box">
<form method="GET" action="" id="searchForm">
<input
type="text"
name="q"
class="search-input"
placeholder="Gerätesuche (z.B. AWB 921 PH, ZCS 2100B, 2100B)"
value="<?php echo htmlspecialchars($search_term); ?>"
autofocus
/>
<div style="margin-top: 10px; display: flex; align-items: center; gap: 15px;">
<button type="submit" class="search-btn">Suchen</button>
<label style="font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" name="shop_items" value="1" <?php echo $show_shop_items ? 'checked' : ''; ?> id="shopItemsToggle" onchange="document.getElementById('searchForm').submit();">
<span>Nur verfügbare Shop-Artikel (MySQL)</span>
</label>
</div>
</form>
</div>
<?php if ($error_message): ?>
<div class="error-message">
<strong>Fehler bei der Datenbankverbindung:</strong>
<?php echo htmlspecialchars($error_message); ?>
</div>
<?php endif; ?>
<?php if ($search_term !== '' && !$error_message): ?>
<div class="split-results">
<!-- LINKS: Geräte-Suche (PostgreSQL) -->
<div class="left-pane">
<h2>Geräte (PostgreSQL)
— <?php echo count($results); ?> Treffer
(<?php echo number_format($search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Geräte gefunden.
</div>
<?php endif; ?>
<?php foreach ($results as $device): ?>
<?php
$score = (int)$device['score'];
$score_class = 'score-default';
if ($score >= 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';
?>
<div class="device-card collapsed" id="device-<?php echo $device['geraet_id']; ?>">
<div class="device-header">
<div class="device-info">
<div class="device-title">
<a href="geraet.php?id=<?php echo $device['geraet_id']; ?>&q=<?php echo urlencode($search_term); ?><?php echo $show_shop_items ? '&shop_items=1' : ''; ?>" style="color: #333; text-decoration: none;">
<?php echo htmlspecialchars($device['modell_bezeichnung']); ?>
</a>
<span class="match-type"><?php echo htmlspecialchars($device['match_type']); ?></span>
</div>
<div class="device-details">
<span><strong>Marke:</strong> <?php echo htmlspecialchars($device['marke']); ?></span>
<span><strong>Typ:</strong> <?php echo htmlspecialchars($device['typ']); ?></span>
<span><strong>Nr:</strong> <?php echo htmlspecialchars($device['nr']); ?></span>
<span><strong>ID:</strong> <?php echo htmlspecialchars($device['geraet_id']); ?></span>
</div>
</div>
<div>
<span class="score-badge <?php echo $score_class; ?>" onclick="toggleDevice(<?php echo $device['geraet_id']; ?>)">
Score: <?php echo $score; ?>
<span class="toggle-arrow">▼</span>
</span>
</div>
</div>
<?php if ($show_shop_items && isset($device['shop_items'])): ?>
<!-- Show available shop items -->
<?php if (count($device['shop_items']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Verfügbare Shop-Artikel:
<span style="background: #4CAF50; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['shop_items']); ?> verfügbar
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Artikel-Nr</th>
<th>Artikelname</th>
<th>Navision-ID</th>
<th>Lagerbestand</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['shop_items'] as $item): ?>
<tr>
<td><?php echo htmlspecialchars($item['number']); ?></td>
<td><?php echo htmlspecialchars($item['name']); ?></td>
<td class="navision-id"><?php echo htmlspecialchars($item['matched_navision_id']); ?></td>
<td><?php echo (int)$item['inventory']; ?> Stück</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine verfügbaren Shop-Artikel gefunden
</div>
</div>
<?php endif; ?>
<?php else: ?>
<!-- Show all Krempl ersatzteile -->
<?php if (count($device['ersatzteile']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Passende Ersatzteile (Krempl):
<span style="background: #2196F3; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['ersatzteile']); ?> gesamt
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Navision ID</th>
<th>Krempl ID</th>
<th>Originalnummer</th>
<th>Marke</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['ersatzteile'] as $ers): ?>
<tr>
<td class="navision-id"><?php echo htmlspecialchars($ers['navision_id']); ?></td>
<td><?php echo htmlspecialchars($ers['krempl_id']); ?></td>
<td><?php echo htmlspecialchars($ers['originalnummer']); ?></td>
<td><?php echo htmlspecialchars($ers['marke']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine Ersatzteile gefunden
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div><!-- /left-pane -->
<!-- RECHTS: Artikel-/Ersatzteil-Suche (MySQL) -->
<div class="right-pane">
<h2>Ersatzteile / Artikel (MySQL items)
— <?php echo count($item_results); ?> Treffer
(<?php echo number_format($item_search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($item_results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Artikel gefunden.
</div>
<?php else: ?>
<?php foreach ($item_results as $item): ?>
<div class="item-card">
<div class="item-name"><?= htmlspecialchars($item['name']) ?></div>
<div class="item-meta">
Art-Nr: <strong><?= htmlspecialchars($item['number']) ?></strong>
· Navision: <strong><?= htmlspecialchars($item['matched_navision_id']) ?></strong>
· Lager:
<?php
$inv = $item['inventory'];
if ($inv === null) {
echo '<span class="inventory-null">k.A.</span>';
} elseif ((int)$inv > 0) {
echo '<span class="inventory-positive">' . (int)$inv . ' Stk</span>';
} else {
echo '<span class="inventory-zero">0</span>';
}
?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /right-pane -->
</div><!-- /split-results -->
<?php
// ==================================================================
// A/B – NEUE DARSTELLUNG (Prototyp, geführte Ergebnis-Ansicht)
// Nutzt dieselben Daten ($results / $item_results), nur menschlich.
// ==================================================================
if (!function_exists('v2_confidence')) {
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 <Nr>"
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)];
}
}
// 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_info($it['name']);
if (!$it['_nml']['is_nml']) $g_buy[] = $it; // regulär
elseif ($it['_nml']['nachfolger']) $g_succ[] = $it; // ausgelaufen, Nachfolger da
else $g_dead[] = $it; // ausgelaufen, kein Ersatz
}
$v2_top_score = isset($results[0]) ? (int)$results[0]['score'] : 0;
// Intent-Einordnung
if ($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.';
?>
<hr style="margin:40px 0 0; border:none; border-top:3px dashed #cfcfcf;">
<style>
.v2{margin-top:24px;font-size:15px}
.v2 .v2-flag{display:inline-block;background:#111;color:#fff;font-size:11px;font-weight:700;
letter-spacing:.5px;padding:3px 10px;border-radius:4px;text-transform:uppercase}
.v2 .v2-head{font-size:22px;font-weight:700;color:#222;margin:14px 0 4px}
.v2 .v2-sub{color:#777;font-size:13px;margin-bottom:22px}
.v2-dev{border:1px solid #e3e3e3;border-radius:10px;margin-bottom:16px;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.05)}
.v2-dev-top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;
padding:16px 18px;background:#fff}
.v2-dev-name{font-size:18px;font-weight:700;color:#1a1a1a;line-height:1.3}
.v2-dev-meta{color:#666;font-size:13px;margin-top:5px}
.v2-dev-meta b{color:#444}
.v2-conf{white-space:nowrap;font-size:12px;font-weight:700;padding:6px 12px;border-radius:20px}
.v2-parts{padding:14px 18px;border-top:1px solid #f0f0f0;background:#fcfcfc}
.v2-parts h4{font-size:13px;color:#555;margin-bottom:9px;font-weight:600}
.v2-chip{display:inline-block;background:#fff;border:1px solid #e0e0e0;border-radius:16px;
padding:4px 11px;margin:0 5px 6px 0;font-size:12px;color:#444}
.v2-chip b{color:#1565c0}
.v2-buy{background:#e8f5e9;border-color:#c8e6c9}
.v2-more{margin:6px 0 22px}
.v2-more summary{cursor:pointer;color:#1565c0;font-size:14px;padding:8px 0}
.v2-shop{margin-top:30px}
.v2-shop h3{font-size:16px;color:#333;margin-bottom:12px;border-left:4px solid #1976D2;padding-left:10px}
.v2-item{display:flex;justify-content:space-between;align-items:center;gap:14px;
border:1px solid #eee;border-radius:8px;padding:12px 15px;margin-bottom:8px;background:#fff}
.v2-item-name{font-weight:600;color:#222}
.v2-item-meta{color:#888;font-size:12px;margin-top:3px}
.v2-stock{white-space:nowrap;font-size:13px;font-weight:700}
.v2-in{color:#2e7d32}.v2-out{color:#c62828}.v2-na{color:#aaa;font-style:italic;font-weight:400}
.v2-cart{background:#4CAF50;color:#fff;border:none;border-radius:6px;padding:8px 14px;
font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
.v2-cart:disabled{background:#ccc;cursor:not-allowed}
.v2-hint{background:#fffdf3;border:1px dashed #e6d9a8;border-radius:8px;padding:10px 14px;
font-size:12px;color:#8a7a3a;margin-top:20px}
</style>
<div class="v2">
<span class="v2-flag">◇ Variante B – geführte Ansicht (Prototyp)</span>
<div class="v2-head"><?php echo htmlspecialchars($v2_headline); ?></div>
<div class="v2-sub">
<?php echo count($v2_strong); ?> passende Geräte
· <?php echo count($g_buy); ?> kaufbare Artikel
<?php if (count($g_succ)) echo ' · '.count($g_succ).' mit Nachfolger'; ?>
<?php if (count($g_dead)) echo ' · '.count($g_dead).' ausgelaufen'; ?>
<?php if (count($v2_weak)) echo ' · '.count($v2_weak).' weitere mögliche Geräte'; ?>
</div>
<?php foreach ($v2_strong as $i => $d):
list($clabel,$ccol,$cbg,$cicon) = v2_confidence($d['score']);
$parts = $d['ersatzteile'] ?? [];
$buy = $d['shop_items'] ?? null; // nur wenn shop_items-Toggle an
?>
<div class="v2-dev">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta">
<b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?>
· <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?>
<?php if (!empty($d['produktionsstart']) || !empty($d['produktionsende'])): ?>
· <b>Baujahr:</b> <?php echo htmlspecialchars(trim(($d['produktionsstart']??'').'–'.($d['produktionsende']??''),'–')); ?>
<?php endif; ?>
</div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;">
<?php echo $cicon.' '.htmlspecialchars($clabel); ?>
</span>
</div>
<?php if ($buy && count($buy)): ?>
<div class="v2-parts">
<h4>✓ Sofort erhältlich für dieses Gerät (<?php echo count($buy); ?>):</h4>
<?php foreach (array_slice($buy,0,8) as $b): ?>
<span class="v2-chip v2-buy"><b><?php echo htmlspecialchars($b['number']); ?></b>
· <?php echo htmlspecialchars(mb_strimwidth($b['name'],0,42,'…')); ?>
<?php if ((int)$b['inventory']>0) echo '· '.(int)$b['inventory'].' Stk'; ?></span>
<?php endforeach; ?>
</div>
<?php elseif (count($parts)): ?>
<div class="v2-parts">
<h4><?php echo count($parts); ?> passende Ersatzteile — Auswahl:</h4>
<?php foreach (array_slice($parts,0,10) as $p): ?>
<span class="v2-chip"><b><?php echo htmlspecialchars($p['originalnummer']); ?></b>
<?php if (!empty($p['marke'])) echo '· '.htmlspecialchars($p['marke']); ?></span>
<?php endforeach; ?>
<?php if (count($parts) > 10): ?>
<span class="v2-chip" style="border-style:dashed;color:#999;">+<?php echo count($parts)-10; ?> weitere</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (count($v2_weak)): ?>
<details class="v2-more">
<summary>Weitere mögliche Geräte anzeigen (<?php echo count($v2_weak); ?>) – schwächere Treffer</summary>
<?php foreach ($v2_weak as $d): list($clabel,$ccol,$cbg,$cicon)=v2_confidence($d['score']); ?>
<div class="v2-dev" style="opacity:.75">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name" style="font-size:15px"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta"><b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?> · <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?></div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;"><?php echo $cicon.' '.htmlspecialchars($clabel); ?></span>
</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php $keep_shop = $show_shop_items ? '&shop_items=1' : ''; ?>
<?php if (count($g_buy)): ?>
<div class="v2-shop">
<h3>🛒 Diese Artikel kannst du direkt kaufen</h3>
<?php foreach ($g_buy as $it):
$inv = $it['inventory'];
$can = ($inv !== null && (int)$inv > 0);
// Bilder liegen nur auf LIVE → absolute URL
$img = !empty($it['default_image_file_name'])
? 'https://intelectra.de/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;
?>
<div class="v2-item">
<div style="display:flex;align-items:center;gap:14px;min-width:0">
<?php if ($img): ?>
<img src="<?php echo $img; ?>" alt="" loading="lazy"
style="width:64px;height:64px;object-fit:contain;background:#fff;border:1px solid #eee;border-radius:6px;flex:0 0 64px"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width:64px;height:64px;flex:0 0 64px;background:#f5f5f5;border:1px dashed #ddd;border-radius:6px;
display:flex;align-items:center;justify-content:center;color:#bbb;font-size:11px">kein Bild</div>
<?php endif; ?>
<div style="min-width:0">
<div class="v2-item-name"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
</div>
<div style="text-align:right;flex:0 0 auto">
<div style="font-size:17px;font-weight:700;color:#1a1a1a;margin-bottom:2px">
<?php if ($bb > 0 && $pb > 0 && $bb < $pb): ?>
<span style="color:#c62828"><?php echo number_format($bb, 2, ',', '.'); ?>&nbsp;€</span>
<span style="font-size:12px;color:#999;text-decoration:line-through;font-weight:400"><?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€</span>
<?php elseif ($pb > 0): ?>
<?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€
<?php else: ?>
<span style="font-size:12px;color:#999;font-weight:400">Preis auf Anfrage</span>
<?php endif; ?>
</div>
<?php if ($pb > 0): ?><div style="font-size:10px;color:#aaa;margin-bottom:4px">inkl. MwSt.</div><?php endif; ?>
<div class="v2-stock <?php echo $inv===null?'v2-na':($can?'v2-in':'v2-out'); ?>">
<?php echo $inv===null ? 'Lager k.A.' : ($can ? '● '.(int)$inv.' ab Lager' : '○ nicht am Lager'); ?>
</div>
<button class="v2-cart" <?php echo $can?'':'disabled'; ?> style="margin-top:6px">
<?php echo $can ? 'In den Warenkorb' : 'Bestellbar'; ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_succ)): ?>
<div class="v2-shop">
<h3 style="border-left-color:#fb8c00">🔄 Ausgelaufen – aber es gibt einen Nachfolger</h3>
<?php foreach ($g_succ as $it): $nf = $it['_nml']['nachfolger']; ?>
<div class="v2-item" style="background:#fff8f0;border-color:#ffe0b2">
<div>
<div class="v2-item-name" style="color:#8a6d3b;text-decoration:line-through;text-decoration-color:#d9b38c">
<?php echo htmlspecialchars($it['_nml']['clean']); ?>
</div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?> · nicht mehr lieferbar</div>
</div>
<div style="text-align:right">
<div style="font-size:12px;color:#e65100;font-weight:600;margin-bottom:6px">Nachfolger: <?php echo htmlspecialchars($nf); ?></div>
<a href="?q=<?php echo urlencode($nf).$keep_shop; ?>"
class="v2-cart" style="background:#fb8c00;text-decoration:none;display:inline-block">
→ Nachfolger anzeigen
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_dead)): ?>
<details class="v2-more" style="margin-top:20px">
<summary>Nicht mehr lieferbar, kein Ersatz (<?php echo count($g_dead); ?>) – ausgeblendet</summary>
<?php foreach ($g_dead as $it): ?>
<div class="v2-item" style="opacity:.55">
<div>
<div class="v2-item-name" style="text-decoration:line-through"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
<div class="v2-stock v2-out" style="white-space:nowrap">✕ entfällt</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php if (!count($g_buy) && !count($g_succ) && !count($g_dead)): ?>
<!-- keine Artikel -->
<?php endif; ?>
<div class="v2-hint">
💡 <b>Prototyp-Notiz (nicht final):</b> Bild (aus <code>items.default_image_file_name</code>, geladen vom Live-Server)
+ Preis (<code>item_prices</code> Kundengruppe 1, brutto inkl. Steuersatz, Angebotspreis rot) sind jetzt drin.
<b>nml/Nachfolger ist jetzt live:</b> Der Marker <code>(nml)</code>/<code>(nmle)</code> im Artikelnamen wird erkannt —
ausgelaufene Teile <b>mit</b> „Nachfolger &lt;Nr&gt;" landen im orangen Block und verlinken per Klick auf den kaufbaren Nachfolger;
Teile <b>ohne</b> Ersatz werden ans Ende ausgeblendet. Score-Zahlen durch Vertrauens-Text ersetzt.
Tipp: oben „Nur verfügbare Shop-Artikel" aktivieren zeigt pro Gerät die kaufbaren Teile grün.
</div>
</div>
<?php else: ?>
<div class="no-results">
Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "Kohlefilter Bauknecht")
</div>
<?php endif; ?>
</div>
<script>
function toggleDevice(deviceId) {
const card = document.getElementById('device-' + deviceId);
if (card) {
card.classList.toggle('collapsed');
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,7 +21,7 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
{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'}
@@ -190,6 +190,60 @@
<div id="footer_wrapper">
<div id="footer">
{* § 356a BGB – elektronische Widerrufsfunktion: separater, gut sichtbarer Button *}
<div id="widerruf_button_block">
<a class="widerruf_button" href="{$SUB_DIR}widerruf.php" title="Vertrag widerrufen">Vertrag widerrufen</a>
<span class="widerruf_button_hint">Elektronische Widerrufsfunktion gem.&nbsp;§&nbsp;356a&nbsp;BGB</span>
</div>
<style>
#widerruf_button_block{
position:relative !important;
display:block !important;
width:100% !important;
box-sizing:border-box !important;
padding:16px 0 18px !important;
text-align:center !important;
background:transparent !important;
border-bottom:1px solid rgba(0,0,0,.08) !important;
margin-bottom:18px !important;
z-index:5 !important;
clear:both !important;
}
#widerruf_button_block a.widerruf_button{
display:inline-block !important;
padding:12px 26px !important;
background:#c00 !important;
color:#fff !important;
font-family:Arial,Helvetica,sans-serif !important;
font-size:15px !important;
font-weight:700 !important;
letter-spacing:.3px !important;
text-transform:none !important;
text-decoration:none !important;
border:0 !important;
border-radius:6px !important;
box-shadow:0 2px 5px rgba(0,0,0,.2) !important;
transition:background .15s ease,transform .05s ease !important;
line-height:1.1 !important;
cursor:pointer !important;
}
#widerruf_button_block a.widerruf_button:hover,
#widerruf_button_block a.widerruf_button:focus{
background:#a30000 !important;
color:#fff !important;
text-decoration:none !important;
}
#widerruf_button_block a.widerruf_button:active{
transform:translateY(1px) !important;
}
#widerruf_button_block .widerruf_button_hint{
display:block !important;
margin-top:8px !important;
font-size:11px !important;
color:#666 !important;
font-weight:400 !important;
}
</style>
{foreach from=$footer_widgets item=widget}
{$widget}
{/foreach}
@@ -11,7 +11,10 @@
</span>
</a>
{/foreach}
<a title="Vertrag widerrufen" href="{$SUB_DIR}widerruf.php" class="widerruf_link">
<span>Vertrag widerrufen</span>
<a title="Widerrufsbelehrung" href="{$SUB_DIR}Widerrufsrecht/" class=" ">
<span>Widerrufsbelehrung</span>
</a>
<a title="Muster-Widerrufsformular" href="{$SUB_DIR}Widerrufsformular-Muster/" class=" ">
<span>Muster-Widerrufsformular</span>
</a>
</div>
@@ -3,7 +3,9 @@
<div class="widget_title"><span>{$template_text.main_widget_menu2.menu2_title}</span></div>
<div class="widget_content_wrapper">
{foreach from=$main_widget_menu_2.menu_items item=menu_item}
<a title="{$menu_item->name}" href="{$SUB_DIR}{if $menu_item->short_uri}{$menu_item->short_uri}/{else}index.php?menu_id={$menu_item->id}{/if}" class=" {if isset($current_menu_id) && $menu_item->id == $current_menu_id}active{/if}"><span>&#x95;&nbsp;
{* Widerrufsrecht (60) + Widerrufsformular (10659) leben jetzt im Footer + als Button. Hier ausblenden. *}
{if $menu_item->id != 60 && $menu_item->id != 10659}
<a title="{$menu_item->name}" href="{$SUB_DIR}{if $menu_item->short_uri}{$menu_item->short_uri}/{else}index.php?menu_id={$menu_item->id}{/if}" class=" {if isset($current_menu_id) && $menu_item->id == $current_menu_id}active{/if}"><span>&#x95;&nbsp;
{if !$user_lang || $user_lang == $system_configuration.default_system_language || !$menu_item->name_en}
{$menu_item->name}
{else}
@@ -11,6 +13,7 @@
{/if}
</span>
</a>
{/if}
{/foreach}
</div>
<div class="widget_footer"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Some files were not shown because too many files have changed in this diff Show More