757 lines
27 KiB
PHP
757 lines
27 KiB
PHP
<?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;
|
|
|
|
/**
|
|
* "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';
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|