wip: Mobile-Theme-Testphase und PHP-8.2-Guards (Stand Juni 2026)
@@ -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
|
RewriteEngine On
|
||||||
|
|||||||
@@ -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) {
|
foreach ($text_values as $obj) {
|
||||||
$this->shopConfiguration[$obj->name] = str_replace('{$THEME_DIR}', STANDARD_DIR.'web/'.SHOP_SYSTEM.'/themes/'.$this->shopConfiguration['THEME'], $obj->setting);
|
$this->shopConfiguration[$obj->name] = str_replace('{$THEME_DIR}', STANDARD_DIR.'web/'.SHOP_SYSTEM.'/themes/'.$this->shopConfiguration['THEME'], $obj->setting);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,7 +404,8 @@ class Cs_ticket extends Main {
|
|||||||
|
|
||||||
// get agb's ...
|
// get agb's ...
|
||||||
$site_content_object = new SiteContent($this->base_object);
|
$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]'] = $site_content_object->get_textbox_data_by_id(56);
|
||||||
$var_data['[#agb_b2b]'] = $site_content_object->get_textbox_data_by_id(10632);
|
$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);
|
$var_data['[#widerruf]'] = $site_content_object->get_textbox_data_by_id(60);
|
||||||
|
|||||||
@@ -4670,7 +4670,7 @@ private function generateImageFileName($warning) {
|
|||||||
$obj->tax_rate = $this->tax_rates[$obj->tax_id]->steuersatz;
|
$obj->tax_rate = $this->tax_rates[$obj->tax_id]->steuersatz;
|
||||||
$obj->manufacturer_name = '';
|
$obj->manufacturer_name = '';
|
||||||
if (isset($this->manufacturers[$obj->manufacturer_id])) {
|
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 = '';
|
$obj->structure_name = '';
|
||||||
if (isset($this->structure[$obj->structure_id])) {
|
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->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->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;
|
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
|
||||||
|
|
||||||
if ($show_taxes) {
|
if ($show_taxes) {
|
||||||
@@ -5401,7 +5401,7 @@ private function generateImageFileName($warning) {
|
|||||||
$obj->tax = $this->all_tax[$obj->tax_id]->steuersatz;
|
$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->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;
|
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
|
||||||
|
|
||||||
if ($show_taxes) {
|
if ($show_taxes) {
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ class layout extends Smarty {
|
|||||||
header('content-type: text/html; charset=utf-8');
|
header('content-type: text/html; charset=utf-8');
|
||||||
|
|
||||||
$this->__construct();
|
$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_id = SHOP_SYSTEM.'_'.$base_object->config->shopConfiguration['THEME'];
|
||||||
$this->compile_dir = ROOT_DIR.'/tmp/smarty_compile';
|
$this->compile_dir = ROOT_DIR.'/tmp/smarty_compile';
|
||||||
$this->cache_dir = ROOT_DIR.'/tmp/smarty_cache';
|
$this->cache_dir = ROOT_DIR.'/tmp/smarty_cache';
|
||||||
@@ -65,6 +74,12 @@ class layout extends Smarty {
|
|||||||
$tpl_paths = array();
|
$tpl_paths = array();
|
||||||
$tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/'.$base_object->config->shopConfiguration['THEME'].'/templates';
|
$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) {
|
if ($theme->parent_theme) {
|
||||||
$tpl_paths[] = ROOT_DIR.'/themes/'.$theme->parent_theme.'/templates';
|
$tpl_paths[] = ROOT_DIR.'/themes/'.$theme->parent_theme.'/templates';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ class Manufacturer extends Main {
|
|||||||
|
|
||||||
if ($result->num_rows > 0) {
|
if ($result->num_rows > 0) {
|
||||||
while ($obj = $result->fetch_object()) {
|
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);
|
asort($return);
|
||||||
return $return;
|
return $return;
|
||||||
|
|||||||
@@ -13,6 +13,153 @@ class KremplDB {
|
|||||||
private $mysql_conn = null;
|
private $mysql_conn = null;
|
||||||
private $config = 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) {
|
public function __construct($connect_mysql = false) {
|
||||||
$config_file = __DIR__ . '/config.ini';
|
$config_file = __DIR__ . '/config.ini';
|
||||||
|
|
||||||
@@ -88,7 +235,16 @@ class KremplDB {
|
|||||||
* @param int $limit Maximum number of results (default: 20)
|
* @param int $limit Maximum number of results (default: 20)
|
||||||
* @return array Array of devices with scores and ersatzteile
|
* @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)) {
|
if (empty($search_term)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -104,14 +260,168 @@ class KremplDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while ($row = pg_fetch_assoc($result)) {
|
while ($row = pg_fetch_assoc($result)) {
|
||||||
// For each device, find matching ersatzteile
|
if ($with_parts) {
|
||||||
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
|
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
|
||||||
|
}
|
||||||
$results[] = $row;
|
$results[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
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
|
* Get all ersatzteile (spare parts) for a specific device
|
||||||
*
|
*
|
||||||
@@ -128,7 +438,10 @@ class KremplDB {
|
|||||||
e.marke
|
e.marke
|
||||||
FROM ersatzteil_mapping em
|
FROM ersatzteil_mapping em
|
||||||
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
|
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
|
ORDER BY e.navision_id
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
";
|
";
|
||||||
@@ -284,7 +597,8 @@ class KremplDB {
|
|||||||
SELECT e.navision_id
|
SELECT e.navision_id
|
||||||
FROM ersatzteil_mapping em
|
FROM ersatzteil_mapping em
|
||||||
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
|
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
|
AND e.navision_id IS NOT NULL
|
||||||
ORDER BY e.navision_id
|
ORDER BY e.navision_id
|
||||||
";
|
";
|
||||||
@@ -319,11 +633,22 @@ class KremplDB {
|
|||||||
i.base_price,
|
i.base_price,
|
||||||
i.manufacturer_id,
|
i.manufacturer_id,
|
||||||
i.structure_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
|
CASE
|
||||||
WHEN LENGTH(i.number) >= 8 THEN i.number
|
WHEN LENGTH(i.number) >= 8 THEN i.number
|
||||||
ELSE i.attribute_7
|
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
|
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 (
|
WHERE (
|
||||||
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
|
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
|
||||||
OR
|
OR
|
||||||
@@ -345,6 +670,78 @@ class KremplDB {
|
|||||||
$items[] = $row;
|
$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;
|
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 =
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -301,9 +301,7 @@ class Revocation {
|
|||||||
}
|
}
|
||||||
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
|
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
|
||||||
$msg .= '</table>';
|
$msg .= '</table>';
|
||||||
$msg .= '<p style="margin-top:20px;"><strong>Bitte Rückzahlung über die Warenwirtschaft '
|
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
|
||||||
. 'veranlassen.</strong> Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche '
|
|
||||||
. 'Bestellung erfolgen.</p>';
|
|
||||||
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
|
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
|
||||||
. '/index.php?admin_modul=admin_revocation_list">Widerrufsliste öffnen</a></p>';
|
. '/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);
|
$order_number = $order->order_number ?? ('#'.$revocation->order_id);
|
||||||
$name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? ''));
|
$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>';
|
$ok = $this->send_neutral_eingangsbestaetigung(
|
||||||
$msg .= '<p>hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung '
|
$revocation->customer_email,
|
||||||
. '<strong>'.htmlspecialchars($order_number).'</strong> am '
|
$name,
|
||||||
. date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.</p>';
|
$revocation->customer_email,
|
||||||
$msg .= '<h3>Nächste Schritte</h3>';
|
$order_number,
|
||||||
$msg .= '<p>Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:</p>';
|
$datum,
|
||||||
$msg .= '<p style="background:#f4f4f4;padding:12px;border-left:4px solid #c00;">'
|
$uhrzeit,
|
||||||
. '<strong>'.REVOCATION_RETURN_COMPANY.'</strong><br>'
|
$revocation->reason_text ?? ''
|
||||||
. 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);
|
|
||||||
|
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
@@ -487,20 +477,25 @@ class Revocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail.
|
* Widerruf ohne Token.
|
||||||
* Wenn noch kein revocation-Record existiert, wird er inline angelegt.
|
* 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) {
|
if (!REVOCATION_ENABLED) {
|
||||||
$this->error = 'feature_disabled';
|
$this->error = 'feature_disabled';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$name = trim((string)$name);
|
||||||
$order_number = trim((string)$order_number);
|
$order_number = trim((string)$order_number);
|
||||||
$email = trim(strtolower((string)$email));
|
$email = trim(strtolower((string)$email));
|
||||||
|
$article = trim((string)$article);
|
||||||
|
|
||||||
if ($order_number === '') {
|
if ($name === '') {
|
||||||
$this->error = 'missing_order';
|
$this->error = 'missing_name';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
@@ -508,22 +503,24 @@ class Revocation {
|
|||||||
return false;
|
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) {
|
if (!$order) {
|
||||||
$this->error = 'order_not_found';
|
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
|
$group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
|
||||||
if (!RevocationHelper::is_b2c($group_id)) {
|
if (!RevocationHelper::is_b2c($group_id)) {
|
||||||
$this->error = 'not_eligible';
|
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
|
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
|
||||||
if (strtotime($expires_at) < time()) {
|
if (strtotime($expires_at) < time()) {
|
||||||
$this->error = 'expired';
|
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing Eintrag wiederverwenden, sonst inline anlegen
|
// Existing Eintrag wiederverwenden, sonst inline anlegen
|
||||||
@@ -563,11 +560,10 @@ class Revocation {
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null;
|
$reason_id = null;
|
||||||
$reason_text = trim((string)$reason_text);
|
$reason_text = ($article !== '' && mb_strlen($article) > 2000)
|
||||||
if (mb_strlen($reason_text) > 2000) {
|
? mb_substr($article, 0, 2000)
|
||||||
$reason_text = mb_substr($reason_text, 0, 2000);
|
: $article;
|
||||||
}
|
|
||||||
$ip_anon = RevocationHelper::anonymize_ip($ip);
|
$ip_anon = RevocationHelper::anonymize_ip($ip);
|
||||||
$ua = $user_agent ? mb_substr($user_agent, 0, 255) : null;
|
$ua = $user_agent ? mb_substr($user_agent, 0, 255) : null;
|
||||||
|
|
||||||
@@ -623,4 +619,100 @@ class Revocation {
|
|||||||
if (!$res || $res->num_rows === 0) return false;
|
if (!$res || $res->num_rows === 0) return false;
|
||||||
return $res->fetch_object();
|
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ä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üfen Ihr Anliegen und melden uns zeitnah bei Ihnen.</p>';
|
||||||
|
$msg .= '<p>Viele Grüße<br>Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'</p>';
|
||||||
|
|
||||||
|
return $mailer->send_mail($subject, $msg, $to);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class KlarnaConfig implements ArrayAccess
|
|||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function offsetExists($offset)
|
public function offsetExists($offset)
|
||||||
{
|
{
|
||||||
return isset($this->options[$offset]);
|
return isset($this->options[$offset]);
|
||||||
@@ -141,6 +142,7 @@ class KlarnaConfig implements ArrayAccess
|
|||||||
*
|
*
|
||||||
* @return mixed
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function offsetGet($offset)
|
public function offsetGet($offset)
|
||||||
{
|
{
|
||||||
if (!$this->offsetExists($offset)) {
|
if (!$this->offsetExists($offset)) {
|
||||||
@@ -157,6 +159,7 @@ class KlarnaConfig implements ArrayAccess
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function offsetSet($offset, $value)
|
public function offsetSet($offset, $value)
|
||||||
{
|
{
|
||||||
$this->options[$offset] = $value;
|
$this->options[$offset] = $value;
|
||||||
@@ -169,6 +172,7 @@ class KlarnaConfig implements ArrayAccess
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function offsetUnset($offset)
|
public function offsetUnset($offset)
|
||||||
{
|
{
|
||||||
unset($this->options[$offset]);
|
unset($this->options[$offset]);
|
||||||
|
|||||||
@@ -497,7 +497,8 @@ class website_cashdesk {
|
|||||||
$site_content_object = new SiteContent($this->base_object);
|
$site_content_object = new SiteContent($this->base_object);
|
||||||
|
|
||||||
$agb_data = array();
|
$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'] = $site_content_object->get_textbox_data_by_id(56);
|
||||||
$agb_data['agbs_b2b'] = $site_content_object->get_textbox_data_by_id(10632);
|
$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);
|
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ class website_customer_registration {
|
|||||||
$site_content_object = new SiteContent($this->base_object);
|
$site_content_object = new SiteContent($this->base_object);
|
||||||
|
|
||||||
$agb_data = array();
|
$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'] = $site_content_object->get_textbox_data_by_id(56);
|
||||||
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
|
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
|
||||||
$this->layout_object->assign('agb_data', $agb_data);
|
$this->layout_object->assign('agb_data', $agb_data);
|
||||||
|
|||||||
@@ -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('VonHersteller', 'von '.$hersteller);
|
||||||
$layout_object->metaTagObject->overwriteVar('Hersteller', $hersteller);
|
$layout_object->metaTagObject->overwriteVar('Hersteller', $hersteller);
|
||||||
$layout_object->metaTagObject->overwriteVar('ArtikelAttribut6', str_replace(';',',',$content_object->item_data->attribute_6));
|
$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);
|
$structure_object = new Structure($base_object);
|
||||||
$tree_ids = array_reverse($structure_object->get_all_tree_ids($structure_id));
|
$tree_ids = array_reverse($structure_object->get_all_tree_ids($structure_id));
|
||||||
$z = 0;
|
$z = 0;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ include_once './core/customer_group.class.php';
|
|||||||
include_once './core/cs_ticket.class.php';
|
include_once './core/cs_ticket.class.php';
|
||||||
include_once './core/shop_klarna.class.php';
|
include_once './core/shop_klarna.class.php';
|
||||||
|
|
||||||
|
#[\AllowDynamicProperties]
|
||||||
class website_item {
|
class website_item {
|
||||||
|
|
||||||
private $base_object;
|
private $base_object;
|
||||||
|
|||||||
@@ -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); ?> 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); ?> 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, ',', '.'); ?> €</span>
|
||||||
|
<span style="font-size:12px;color:#999;text-decoration:line-through;font-weight:400"><?php echo number_format($pb, 2, ',', '.'); ?> €</span>
|
||||||
|
<?php elseif ($pb > 0): ?>
|
||||||
|
<?php echo number_format($pb, 2, ',', '.'); ?> €
|
||||||
|
<?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 <Nr>" 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>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{if $block->configuration.image_name}
|
{if isset($block->configuration.image_name) && $block->configuration.image_name}
|
||||||
{if $block->configuration.image_position == 'left'}
|
{if $block->configuration.image_position == 'left'}
|
||||||
{$padding='padding-right:10px;'}
|
{$padding='padding-right:10px;'}
|
||||||
{elseif $block->configuration.image_position == 'right'}
|
{elseif $block->configuration.image_position == 'right'}
|
||||||
|
|||||||
@@ -190,6 +190,60 @@
|
|||||||
|
|
||||||
<div id="footer_wrapper">
|
<div id="footer_wrapper">
|
||||||
<div id="footer">
|
<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. § 356a 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}
|
{foreach from=$footer_widgets item=widget}
|
||||||
{$widget}
|
{$widget}
|
||||||
{/foreach}
|
{/foreach}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{/foreach}
|
{/foreach}
|
||||||
<a title="Vertrag widerrufen" href="{$SUB_DIR}widerruf.php" class="widerruf_link">
|
<a title="Widerrufsbelehrung" href="{$SUB_DIR}Widerrufsrecht/" class=" ">
|
||||||
<span>Vertrag widerrufen</span>
|
<span>Widerrufsbelehrung</span>
|
||||||
|
</a>
|
||||||
|
<a title="Muster-Widerrufsformular" href="{$SUB_DIR}Widerrufsformular-Muster/" class=" ">
|
||||||
|
<span>Muster-Widerrufsformular</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="widget_title"><span>{$template_text.main_widget_menu2.menu2_title}</span></div>
|
<div class="widget_title"><span>{$template_text.main_widget_menu2.menu2_title}</span></div>
|
||||||
<div class="widget_content_wrapper">
|
<div class="widget_content_wrapper">
|
||||||
{foreach from=$main_widget_menu_2.menu_items item=menu_item}
|
{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>•
|
{* 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>•
|
||||||
{if !$user_lang || $user_lang == $system_configuration.default_system_language || !$menu_item->name_en}
|
{if !$user_lang || $user_lang == $system_configuration.default_system_language || !$menu_item->name_en}
|
||||||
{$menu_item->name}
|
{$menu_item->name}
|
||||||
{else}
|
{else}
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/foreach}
|
{/foreach}
|
||||||
</div>
|
</div>
|
||||||
<div class="widget_footer"></div>
|
<div class="widget_footer"></div>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |