searchGeraete('AWB 921 PH'); */ class KremplDB { private $pg_conn = null; private $mysql_conn = null; private $config = null; /** * "nicht mehr lieferbar" als SQL-Regex -- muss deckungsgleich mit den PHP-Pendants * v2_nml_info() und v2_is_nml_wg() in tba_render.php bleiben. * Zeichenklassen statt Backslash-Escapes, damit die Muster durch PHP-String und * MySQL-Parser unveraendert durchkommen. */ const NML_NAME_RE = '[(]nml[e]?[)]'; const NML_WG_RE = 'lieferbar|NMLE|Nachfolge|kein Ersatz'; /** * Aufloesung der Alternativartikel ("Nachfolger") fuer eine Liste von Shop-Artikeln. * * Die Zuordnung wird im Backend unter Item-Editor > Tab "Alternativartikel" gepflegt und * landet in `item_item_assign` (type=3, im Spaltenkommentar "real friends"), geschrieben * von modules/import/item_save_service.py. Sie steht NICHT im Artikelnamen -- der bisherige * Namens-Regex /Nachfolger\s+(\S*\d\S*)/ erreicht 71 aktive Artikel, die Tabelle 4.150. * * Das "e" in "(nmle)" bedeutet "Ersatz vorhanden": 96,8 % dieser Artikel haben eine * Zuordnung, gegenueber 7,2 % bei "(nml)" und 1,7 % in der WG "kein Ersatz". * * Zeigt eine Zuordnung auf einen Artikel, der selbst nicht mehr lieferbar ist, laeuft die * Kette weiter (145 Faelle). Rekursiv gemessen: 1.607 direkt kaufbare Ziele, 1.704 mit * Kettenverfolgung, maximale Tiefe 3. Der `pfad`-String bricht Zyklen, $max_tiefe ist der * Backstop. Zurueck kommen nur KAUFBARE Endpunkte -- einen nml-Artikel als "Ersatz" * anzubieten waere schlechter als gar kein Hinweis. * * @param int[] $item_ids Shop-Artikel-IDs (items.id) * @return array [item_id => ['id','number','name','price_brutto','bargain_brutto', * 'default_image_file_name','wg_name','tiefe','weitere']] */ public function getAlternativesBatch(array $item_ids, $max_tiefe = 4) { if (!$item_ids) return []; if (!$this->mysql_conn) { $this->connectMySQL(); } $ids = array_values(array_unique(array_filter(array_map('intval', $item_ids)))); if (!$ids) return []; $ids_str = implode(',', $ids); $max_tiefe = max(1, (int)$max_tiefe); $nml_name = self::NML_NAME_RE; $nml_wg = self::NML_WG_RE; // Startpunkte sind nur die nicht mehr lieferbaren Artikel: nur dort ist ein Nachfolger // eine Antwort. Bei kaufbaren Artikeln zeigt der Shop die Alternativen ohnehin selbst // auf der Artikelseite (item_description.tpl, Anker "realfriends"). $sql = << 0 THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) ELSE NULL END AS bargain_brutto, COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name FROM chain ch JOIN items i ON i.id = ch.cur AND i.active = 1 LEFT JOIN structure s ON s.id = i.structure_id LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1 LEFT JOIN tax t ON t.id = i.tax_id WHERE i.name NOT REGEXP '$nml_name' AND COALESCE(s.name, '') NOT REGEXP '$nml_wg' ORDER BY ch.start_id, ch.tiefe, ch.pos, i.id SQL; $result = mysqli_query($this->mysql_conn, $sql); if (!$result) { // Ein fehlender Nachfolger-Hinweis darf die Suche nicht abbrechen. error_log('tba-search getAlternativesBatch: ' . mysqli_error($this->mysql_conn)); return []; } $out = []; while ($row = mysqli_fetch_assoc($result)) { $start = (int)$row['start_id']; if (!isset($out[$start])) { // Erster Treffer je Artikel gewinnt: kleinste Kettentiefe, dann die im Backend // gepflegte Reihenfolge (position). unset($row['start_id']); $row['tiefe'] = (int)$row['tiefe']; $row['weitere'] = 0; $out[$start] = $row; } else { $out[$start]['weitere']++; } } return $out; } /** * Haengt an jeden Artikel der Liste seine kaufbare Alternative unter dem Schluessel 'alt'. * Ein Query fuer die ganze Liste; ohne Treffer bleibt 'alt' ungesetzt. */ private function attachAlternatives(array &$items) { if (!$items) return; $ids = []; foreach ($items as $it) { if (isset($it['id'])) $ids[] = (int)$it['id']; } $alts = $this->getAlternativesBatch($ids); if (!$alts) return; foreach ($items as &$it) { $id = (int)($it['id'] ?? 0); if (isset($alts[$id])) $it['alt'] = $alts[$id]; } unset($it); } public function __construct($connect_mysql = false) { $config_file = __DIR__ . '/config.ini'; 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, 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 * * @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, 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, 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; } $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; } public function __destruct() { if ($this->pg_conn) { pg_close($this->pg_conn); } if ($this->mysql_conn) { mysqli_close($this->mysql_conn); } } }