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

This commit is contained in:
Thomas Bartelt
2026-09-13 19:10:56 +02:00
parent 08beac8d67
commit 20d55f77b8
1448 changed files with 16015 additions and 583 deletions
+403 -6
View File
@@ -13,6 +13,153 @@ class KremplDB {
private $mysql_conn = null;
private $config = null;
/**
* "nicht mehr lieferbar" als SQL-Regex -- muss deckungsgleich mit den PHP-Pendants
* v2_nml_info() und v2_is_nml_wg() in tba_render.php bleiben.
* Zeichenklassen statt Backslash-Escapes, damit die Muster durch PHP-String und
* MySQL-Parser unveraendert durchkommen.
*/
const NML_NAME_RE = '[(]nml[e]?[)]';
const NML_WG_RE = 'lieferbar|NMLE|Nachfolge|kein Ersatz';
/**
* Aufloesung der Alternativartikel ("Nachfolger") fuer eine Liste von Shop-Artikeln.
*
* Die Zuordnung wird im Backend unter Item-Editor > Tab "Alternativartikel" gepflegt und
* landet in `item_item_assign` (type=3, im Spaltenkommentar "real friends"), geschrieben
* von modules/import/item_save_service.py. Sie steht NICHT im Artikelnamen -- der bisherige
* Namens-Regex /Nachfolger\s+(\S*\d\S*)/ erreicht 71 aktive Artikel, die Tabelle 4.150.
*
* Das "e" in "(nmle)" bedeutet "Ersatz vorhanden": 96,8 % dieser Artikel haben eine
* Zuordnung, gegenueber 7,2 % bei "(nml)" und 1,7 % in der WG "kein Ersatz".
*
* Zeigt eine Zuordnung auf einen Artikel, der selbst nicht mehr lieferbar ist, laeuft die
* Kette weiter (145 Faelle). Rekursiv gemessen: 1.607 direkt kaufbare Ziele, 1.704 mit
* Kettenverfolgung, maximale Tiefe 3. Der `pfad`-String bricht Zyklen, $max_tiefe ist der
* Backstop. Zurueck kommen nur KAUFBARE Endpunkte -- einen nml-Artikel als "Ersatz"
* anzubieten waere schlechter als gar kein Hinweis.
*
* @param int[] $item_ids Shop-Artikel-IDs (items.id)
* @return array [item_id => ['id','number','name','price_brutto','bargain_brutto',
* 'default_image_file_name','wg_name','tiefe','weitere']]
*/
public function getAlternativesBatch(array $item_ids, $max_tiefe = 4) {
if (!$item_ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$ids = array_values(array_unique(array_filter(array_map('intval', $item_ids))));
if (!$ids) return [];
$ids_str = implode(',', $ids);
$max_tiefe = max(1, (int)$max_tiefe);
$nml_name = self::NML_NAME_RE;
$nml_wg = self::NML_WG_RE;
// Startpunkte sind nur die nicht mehr lieferbaren Artikel: nur dort ist ein Nachfolger
// eine Antwort. Bei kaufbaren Artikeln zeigt der Shop die Alternativen ohnehin selbst
// auf der Artikelseite (item_description.tpl, Anker "realfriends").
$sql = <<<SQL
WITH RECURSIVE chain AS (
SELECT
a.item_parent_id AS start_id,
a.item_child_id AS cur,
1 AS tiefe,
a.position AS pos,
CAST(CONCAT(',', a.item_parent_id, ',', a.item_child_id, ',') AS CHAR(512)) AS pfad
FROM item_item_assign a
JOIN items p ON p.id = a.item_parent_id
LEFT JOIN structure ps ON ps.id = p.structure_id
WHERE a.type = 3
AND a.item_parent_id IN ($ids_str)
AND (p.name REGEXP '$nml_name' OR COALESCE(ps.name, '') REGEXP '$nml_wg')
UNION ALL
SELECT
ch.start_id,
a.item_child_id,
ch.tiefe + 1,
a.position,
CONCAT(ch.pfad, a.item_child_id, ',')
FROM chain ch
JOIN items c ON c.id = ch.cur AND c.active = 1
LEFT JOIN structure cs ON cs.id = c.structure_id
JOIN item_item_assign a ON a.item_parent_id = ch.cur AND a.type = 3
WHERE ch.tiefe < $max_tiefe
AND ch.pfad NOT LIKE CONCAT('%,', a.item_child_id, ',%')
AND (c.name REGEXP '$nml_name' OR COALESCE(cs.name, '') REGEXP '$nml_wg')
)
SELECT
ch.start_id,
ch.tiefe,
i.id,
i.number,
i.name,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name
FROM chain ch
JOIN items i ON i.id = ch.cur AND i.active = 1
LEFT JOIN structure s ON s.id = i.structure_id
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
WHERE i.name NOT REGEXP '$nml_name'
AND COALESCE(s.name, '') NOT REGEXP '$nml_wg'
ORDER BY ch.start_id, ch.tiefe, ch.pos, i.id
SQL;
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
// Ein fehlender Nachfolger-Hinweis darf die Suche nicht abbrechen.
error_log('tba-search getAlternativesBatch: ' . mysqli_error($this->mysql_conn));
return [];
}
$out = [];
while ($row = mysqli_fetch_assoc($result)) {
$start = (int)$row['start_id'];
if (!isset($out[$start])) {
// Erster Treffer je Artikel gewinnt: kleinste Kettentiefe, dann die im Backend
// gepflegte Reihenfolge (position).
unset($row['start_id']);
$row['tiefe'] = (int)$row['tiefe'];
$row['weitere'] = 0;
$out[$start] = $row;
} else {
$out[$start]['weitere']++;
}
}
return $out;
}
/**
* Haengt an jeden Artikel der Liste seine kaufbare Alternative unter dem Schluessel 'alt'.
* Ein Query fuer die ganze Liste; ohne Treffer bleibt 'alt' ungesetzt.
*/
private function attachAlternatives(array &$items) {
if (!$items) return;
$ids = [];
foreach ($items as $it) {
if (isset($it['id'])) $ids[] = (int)$it['id'];
}
$alts = $this->getAlternativesBatch($ids);
if (!$alts) return;
foreach ($items as &$it) {
$id = (int)($it['id'] ?? 0);
if (isset($alts[$id])) $it['alt'] = $alts[$id];
}
unset($it);
}
public function __construct($connect_mysql = false) {
$config_file = __DIR__ . '/config.ini';
@@ -88,7 +235,16 @@ class KremplDB {
* @param int $limit Maximum number of results (default: 20)
* @return array Array of devices with scores and ersatzteile
*/
public function searchGeraete($search_term, $limit = 20) {
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
if (empty($search_term)) {
return [];
}
@@ -104,14 +260,168 @@ class KremplDB {
}
while ($row = pg_fetch_assoc($result)) {
// For each device, find matching ersatzteile
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7, i.short_uri,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id
FROM items i
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* Get all ersatzteile (spare parts) for a specific device
*
@@ -128,7 +438,10 @@ class KremplDB {
e.marke
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
ORDER BY e.navision_id
LIMIT $2
";
@@ -284,7 +597,8 @@ class KremplDB {
SELECT e.navision_id
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
AND e.navision_id IS NOT NULL
ORDER BY e.navision_id
";
@@ -319,11 +633,22 @@ class KremplDB {
i.base_price,
i.manufacturer_id,
i.structure_id,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
OR
@@ -345,6 +670,78 @@ class KremplDB {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
i.short_uri,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($result)) {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
@@ -0,0 +1,602 @@
<?php
/**
* Krempl PostgreSQL Database Connection Class
*
* Usage:
* require_once __DIR__ . '/core/postgres/KremplDB.php';
* $db = new KremplDB();
* $results = $db->searchGeraete('AWB 921 PH');
*/
class KremplDB {
private $pg_conn = null;
private $mysql_conn = null;
private $config = null;
public function __construct($connect_mysql = false) {
$config_file = __DIR__ . '/config.ini';
if (!file_exists($config_file)) {
throw new Exception("Config file not found: $config_file");
}
$this->config = parse_ini_file($config_file, true);
if (!isset($this->config['postgresql'])) {
throw new Exception("PostgreSQL config section not found in config.ini");
}
$this->connectPostgres();
if ($connect_mysql) {
$this->connectMySQL();
}
}
private function connectPostgres() {
$pg = $this->config['postgresql'];
$conn_string = sprintf(
"host=%s port=%s dbname=%s user=%s password=%s",
$pg['host'] ?? 'localhost',
$pg['port'] ?? '5432',
$pg['database'] ?? 'krempl_data',
$pg['user'] ?? 'krempl_user',
$pg['password'] ?? ''
);
$this->pg_conn = @pg_connect($conn_string);
if (!$this->pg_conn) {
throw new Exception("PostgreSQL connection failed. Check credentials in config.ini");
}
}
private function connectMySQL() {
if (!isset($this->config['mysql'])) {
throw new Exception("MySQL config section not found in config.ini");
}
$my = $this->config['mysql'];
$this->mysql_conn = @mysqli_connect(
$my['host'] ?? 'localhost',
$my['user'] ?? '',
$my['password'] ?? '',
$my['database'] ?? ''
);
if (!$this->mysql_conn) {
throw new Exception("MySQL connection failed: " . mysqli_connect_error());
}
mysqli_set_charset($this->mysql_conn, 'utf8mb4');
}
public function getPostgresConnection() {
return $this->pg_conn;
}
public function getMySQLConnection() {
return $this->mysql_conn;
}
/**
* Search for devices using intelligent normalized search
*
* @param string $search_term The search term (e.g. "AWB 921 PH", "ZCS 2100B")
* @param int $limit Maximum number of results (default: 20)
* @return array Array of devices with scores and ersatzteile
*/
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
if (empty($search_term)) {
return [];
}
$results = [];
// Search for devices using the smart search function
$query = "SELECT * FROM search_geraete_smart($1, $2)";
$result = pg_query_params($this->pg_conn, $query, [$search_term, $limit]);
if (!$result) {
throw new Exception("Search query failed: " . pg_last_error($this->pg_conn));
}
while ($row = pg_fetch_assoc($result)) {
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id
FROM items i
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* Get all ersatzteile (spare parts) for a specific device
*
* @param int $geraet_id The device ID
* @param int $limit Maximum number of results (default: 200)
* @return array Array of ersatzteile with navision_id, krempl_id, etc.
*/
public function getErsatzteileForGeraet($geraet_id, $limit = 200) {
$query = "
SELECT
e.id AS krempl_id,
e.navision_id,
e.originalnummer,
e.marke
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
ORDER BY e.navision_id
LIMIT $2
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id, $limit]);
if (!$result) {
throw new Exception("Ersatzteile query failed: " . pg_last_error($this->pg_conn));
}
$ersatzteile = [];
while ($row = pg_fetch_assoc($result)) {
$ersatzteile[] = $row;
}
return $ersatzteile;
}
/**
* Get all devices compatible with a specific ersatzteil (by navision_id)
*
* @param int $navision_id The Navision ID from the shop
* @param int $limit Maximum number of results (default: 500)
* @return array Array of compatible devices
*/
public function getGeraeteForNavisionId($navision_id, $limit = 500) {
// First find the krempl ersatzteil_id
$query1 = "SELECT id FROM ersatzteile WHERE navision_id = $1 LIMIT 1";
$result1 = pg_query_params($this->pg_conn, $query1, [$navision_id]);
if (!$result1) {
throw new Exception("Navision lookup failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result1);
if (!$row) {
return []; // No krempl data for this navision_id
}
$ersatzteil_id = $row['id'];
// Get the array of device IDs
$query2 = "SELECT geraet_ids FROM ersatzteil_mapping WHERE ersatzteil_id = $1";
$result2 = pg_query_params($this->pg_conn, $query2, [$ersatzteil_id]);
if (!$result2) {
throw new Exception("Mapping lookup failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result2);
if (!$row || !$row['geraet_ids']) {
return [];
}
// Parse the PostgreSQL array format
$geraet_ids_str = trim($row['geraet_ids'], '{}');
$geraet_ids = array_map('intval', explode(',', $geraet_ids_str));
// Get device details
$query3 = "
SELECT
id,
nr,
modell_bezeichnung,
typ,
typ_de,
marke,
zusatz
FROM geraete
WHERE id = ANY($1)
ORDER BY marke, modell_bezeichnung
LIMIT $2
";
$result3 = pg_query_params($this->pg_conn, $query3, ['{' . implode(',', $geraet_ids) . '}', $limit]);
if (!$result3) {
throw new Exception("Geraete lookup failed: " . pg_last_error($this->pg_conn));
}
$geraete = [];
while ($row = pg_fetch_assoc($result3)) {
$geraete[] = $row;
}
return $geraete;
}
/**
* Rebuild the search index after CSV re-import
* Call this after running import.py
*
* @return int Number of rows indexed
*/
public function rebuildSearchIndex() {
$query = "SELECT rebuild_geraete_search_index()";
$result = pg_query($this->pg_conn, $query);
if (!$result) {
throw new Exception("Rebuild index failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_row($result);
return (int)$row[0];
}
/**
* Get database statistics
*
* @return array Statistics about the database
*/
public function getStats() {
$stats = [];
$queries = [
'geraete_count' => "SELECT COUNT(*) FROM geraete",
'ersatzteile_count' => "SELECT COUNT(*) FROM ersatzteile",
'mapping_count' => "SELECT COUNT(*) FROM ersatzteil_mapping",
'passendwie_count' => "SELECT COUNT(*) FROM passendwie",
'search_index_count' => "SELECT COUNT(*) FROM geraete_search_index",
];
foreach ($queries as $key => $query) {
$result = pg_query($this->pg_conn, $query);
if ($result) {
$row = pg_fetch_row($result);
$stats[$key] = (int)$row[0];
}
}
return $stats;
}
/**
* Get available shop items for a device (Hybrid: PostgreSQL + MySQL)
*
* This function combines Krempl data (PostgreSQL) with shop inventory (MySQL)
* to show only the spare parts that are actually available in the shop.
*
* @param int $geraet_id The Krempl device ID
* @param int $limit Maximum number of items to return (default: 200)
* @return array Array of shop items with full details
*/
public function getAvailableItemsForGeraet($geraet_id, $limit = 200) {
// Ensure MySQL is connected
if (!$this->mysql_conn) {
$this->connectMySQL();
}
// Step 1: Get all Navision IDs from PostgreSQL for this device
$navision_ids = [];
$query = "
SELECT e.navision_id
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
AND e.navision_id IS NOT NULL
ORDER BY e.navision_id
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Failed to get navision_ids: " . pg_last_error($this->pg_conn));
}
while ($row = pg_fetch_assoc($result)) {
$navision_ids[] = (int)$row['navision_id'];
}
if (empty($navision_ids)) {
return []; // No parts found
}
// Step 2: Query MySQL for items that match these Navision IDs
// Logic: number >= 8 digits → navision_id is in `number`
// number < 8 digits → navision_id is in `attribute_7`
$navision_ids_str = implode(',', $navision_ids);
$mysql_query = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.manufacturer_id,
i.structure_id,
i.default_image_file_name,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
OR
(LENGTH(i.number) < 8 AND i.attribute_7 IN ($navision_ids_str))
)
AND i.active = 1
ORDER BY i.name
LIMIT $limit
";
$mysql_result = mysqli_query($this->mysql_conn, $mysql_query);
if (!$mysql_result) {
throw new Exception("MySQL query failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($mysql_result)) {
$items[] = $row;
}
return $items;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($result)) {
$items[] = $row;
}
return $items;
}
public function __destruct() {
if ($this->pg_conn) {
pg_close($this->pg_conn);
}
if ($this->mysql_conn) {
mysqli_close($this->mysql_conn);
}
}
}
@@ -0,0 +1,30 @@
[postgresql]
host = localhost
port = 5432
database = krempl_data
user = krempl_user
password = KremplSecure2025
[mysql]
host = localhost
user = webshopadm
password = "Je=53qi2Jey53qi2"
database = webshop-sql2022
[csv_files]
geraete = /var/www/vhosts/intelectra.de/httpdocs/upload/geraete_Export.csv
artikel = /var/www/vhosts/intelectra.de/httpdocs/upload/artikel_Export.csv
mapping = /var/www/vhosts/intelectra.de/httpdocs/upload/artikelgeraet_Export.csv
passendwie = /var/www/vhosts/intelectra.de/httpdocs/upload/passendwie_Export.csv
[import_settings]
batch_size_geraete = 1000
batch_size_artikel = 1000
batch_size_passendwie = 1000
batch_size_mapping = 500
mapping_in_memory = true
progress_interval = 1000000
[logging]
log_level = INFO
log_file =
+349
View File
@@ -0,0 +1,349 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
// 05.08.2026 von intelectra.de auf intelectra-shop.de umgestellt: intelectra.de leitet seit
// dem per 301 dorthin um (Domain-Konsolidierung, siehe .htaccess des Live-Docroots).
// Ohne diese Aenderung liefe jeder Artikel- und Bildlink der Suche durch die Weiterleitung.
define('TBA_SHOP_BASE', 'https://intelectra-shop.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/**
* Shop-URL zu einem Artikel -- leer, wenn kein Shop-Artikel bekannt ist.
*
* Bevorzugt die sprechende SEO-URL "/<short_uri>.html". Das Feld `items.short_uri`
* ist bei allen 16.927 aktiven Artikeln gefuellt (Format "<Name-Slug>-<Artikelnr>")
* und wird vom Shop selbst so verlinkt (item_box.tpl). Aufgeloest wird sie in der
* .htaccess ueber "RewriteRule ^([aA-zZ0-9-_,]+).(html)$ index.php?item_suri=$1".
*
* Diese Regel akzeptiert nur die Zeichen ihrer Klasse. Deshalb wird hier geprueft,
* BEVOR verlinkt wird -- eine short_uri mit abweichenden Zeichen wuerde nicht
* greifen und den Kunden auf einen 404 schicken. In dem Fall bleibt es bei der
* ID-URL, die immer funktioniert.
*
* Bewusst enger als die .htaccess: deren "aA-zZ" ist ein alter Tippfehler und deckt
* als ASCII-Bereich A(65)-z(122) auch ` [ \ ] ^ _ ab. Fuenf Artikel haben deshalb
* einen Backtick in der short_uri und funktionieren nur zufaellig; die bekommen
* hier die ID-URL. Auf den Tippfehler zu bauen waere fahrlaessig.
*/
function v2_shopurl($item_id, $short_uri = null) {
if (!$item_id) return '';
$uri = trim((string)$short_uri);
if ($uri !== '' && preg_match('/^[A-Za-z0-9_,-]+$/', $uri)) {
return tba_url($uri . '.html');
}
return tba_url('index.php?item_id=' . (int)$item_id);
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false, $short_uri = null) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id, $short_uri);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null, $it['short_uri'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
// Die aus dem Namen gelesene Nummer nur als Rueckfallebene: liegt eine
// gepflegte Zuordnung vor, steht der Ersatz gleich darunter als Zeile und
// die blosse Nummer waere doppelt.
if (empty($it['alt']) && !empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
$h .= ($url !== '' ? '</a>' : '</div>');
// Der Ersatzartikel bekommt eine EIGENE Zeile: ein Link darf nicht in einem
// Link stehen, und der Kunde soll ihn direkt anklicken koennen statt erst den
// ausgelaufenen Artikel zu oeffnen.
if ($is_nml && !empty($it['alt'])) {
$h = '<div class="v2-rowgrp">' . $h . v2_altrow($it['alt']) . '</div>';
}
return $h;
}
/**
* Zeile "Ersatz verfuegbar" unter einem nicht mehr lieferbaren Artikel.
* Datenquelle ist die im Backend gepflegte Zuordnung (item_item_assign type=3),
* aufgeloest von KremplDB::getAlternativesBatch(). Angezeigt werden nur kaufbare
* Ziele, deshalb ist die Zusage hier belastbar.
*/
function v2_altrow($alt) {
$url = v2_shopurl($alt['id'] ?? null, $alt['short_uri'] ?? null);
if ($url === '') return '';
$pb = isset($alt['price_brutto']) ? (float)$alt['price_brutto'] : 0;
$bb = !empty($alt['bargain_brutto']) ? (float)$alt['bargain_brutto'] : 0;
$h = '<a class="v2-altrow" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
. '<span class="v2-altrow-tag">→ Ersatz</span>'
. '<span class="v2-altrow-txt">'
. '<span class="v2-altrow-name">' . htmlspecialchars((string)($alt['name'] ?? '')) . '</span>'
. '<span class="v2-altrow-nr">Art-Nr ' . htmlspecialchars((string)($alt['number'] ?? ''));
// Ueber mehr als eine Station gelaufen: der direkte Nachfolger war selbst
// ausgelaufen. Das ist eine ehrliche Angabe, keine Fussnote.
if (!empty($alt['tiefe']) && (int)$alt['tiefe'] > 1) {
$h .= ' · über ' . (int)$alt['tiefe'] . ' Nachfolger ermittelt';
}
if (!empty($alt['weitere'])) {
$h .= ' · ' . (int)$alt['weitere'] . ' weitere Alternative'
. ((int)$alt['weitere'] > 1 ? 'n' : '');
}
$h .= '</span></span><span class="v2-altrow-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b>' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
return $h . '</span></a>';
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
@@ -0,0 +1,272 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
define('TBA_SHOP_BASE', 'https://intelectra.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/** Shop-URL zu einer items.id -- leer, wenn kein Shop-Artikel bekannt ist. */
function v2_shopurl($item_id) {
return $item_id ? tba_url('index.php?item_id=' . (int)$item_id) : '';
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
if (!empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
return $h . ($url !== '' ? '</a>' : '</div>');
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}