From afd2cedf92f7301eb18f6764afa3b691d5522d6a Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Mon, 20 Apr 2026 02:11:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20neue=20PostgreSQL-Suche=20(tba-search)?= =?UTF-8?q?=20aus=20newmail-vhost=20=C3=BCbernommen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/postgres/.gitignore | 13 + core/postgres/.htaccess | 12 + core/postgres/KremplDB.php | 359 ++++++++++++++++++++ core/postgres/README.md | 317 +++++++++++++++++ core/postgres/config.ini.example | 47 +++ core/postgres/import.py | 563 +++++++++++++++++++++++++++++++ core/postgres/schema.sql | 242 +++++++++++++ tba-search.php | 461 +++++++++++++++++++++++++ 8 files changed, 2014 insertions(+) create mode 100644 core/postgres/.gitignore create mode 100644 core/postgres/.htaccess create mode 100644 core/postgres/KremplDB.php create mode 100644 core/postgres/README.md create mode 100644 core/postgres/config.ini.example create mode 100644 core/postgres/import.py create mode 100644 core/postgres/schema.sql create mode 100644 tba-search.php diff --git a/core/postgres/.gitignore b/core/postgres/.gitignore new file mode 100644 index 0000000..6b02778 --- /dev/null +++ b/core/postgres/.gitignore @@ -0,0 +1,13 @@ +# PostgreSQL Import Config +# Die echte config.ini enthält Passwörter und sollte NICHT ins Git! +config.ini + +# Python +__pycache__/ +*.pyc +*.pyo +*.log + +# Temp files +*.tmp +*.bak diff --git a/core/postgres/.htaccess b/core/postgres/.htaccess new file mode 100644 index 0000000..359499e --- /dev/null +++ b/core/postgres/.htaccess @@ -0,0 +1,12 @@ +# Deny access to config files and sensitive data + + Require all denied + + +# Alternative for older Apache versions + + + Order allow,deny + Deny from all + + diff --git a/core/postgres/KremplDB.php b/core/postgres/KremplDB.php new file mode 100644 index 0000000..4012078 --- /dev/null +++ b/core/postgres/KremplDB.php @@ -0,0 +1,359 @@ +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 + */ + public function searchGeraete($search_term, $limit = 20) { + 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)) { + // For each device, find matching ersatzteile + $row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']); + $results[] = $row; + } + + return $results; + } + + /** + * 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 + WHERE $1 = ANY(em.geraet_ids) + 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 + WHERE $1 = ANY(em.geraet_ids) + 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, + 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 ($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; + } + + public function __destruct() { + if ($this->pg_conn) { + pg_close($this->pg_conn); + } + if ($this->mysql_conn) { + mysqli_close($this->mysql_conn); + } + } +} diff --git a/core/postgres/README.md b/core/postgres/README.md new file mode 100644 index 0000000..f3ec07d --- /dev/null +++ b/core/postgres/README.md @@ -0,0 +1,317 @@ +# Krempl PostgreSQL Migration + +Dieses Verzeichnis enthält alle Dateien für die Migration der Krempl-Daten von MySQL zu PostgreSQL. + +## 🎯 Ziel + +**Problem:** 61 Millionen Zeilen in MySQL → langsame Queries, 504 Timeouts +**Lösung:** Aggregation zu 68k Zeilen mit PostgreSQL Arrays → 50-200ms Queries + +--- + +## 📁 Dateien + +``` +core/postgres/ +├── schema.sql # PostgreSQL Schema (Tabellen, Indizes) +├── import.py # Python Import-Script +├── config.ini.example # Konfigurations-Vorlage +├── config.ini # Deine echte Config (NICHT ins Git!) +├── .gitignore # Verhindert, dass config.ini committed wird +└── README.md # Diese Datei +``` + +--- + +## 🚀 Setup auf dem Server + +### 1. PostgreSQL installieren + +```bash +# Auf Debian/Ubuntu Server +sudo apt update +sudo apt install postgresql postgresql-contrib php-pgsql python3-psycopg2 + +# PostgreSQL starten +sudo systemctl start postgresql +sudo systemctl enable postgresql +``` + +### 2. Datenbank erstellen + +**PLESK:** PostgreSQL User ist `root`, nicht `postgres`! + +```bash +# Plesk PostgreSQL Admin-Passwort holen: +# Panel → Tools & Settings → Database Servers → PostgreSQL + +PLESK_PW='DEIN_PLESK_PG_PASSWORD' + +# Datenbank erstellen +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d postgres -c " + CREATE DATABASE krempl_data WITH ENCODING 'UTF8'; +" + +# User erstellen (WICHTIG: Passwort ohne Sonderzeichen!) +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d postgres -c " + CREATE USER krempl_user WITH PASSWORD 'KremplSecure2025'; +" + +# Rechte vergeben +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d postgres -c " + GRANT ALL PRIVILEGES ON DATABASE krempl_data TO krempl_user; +" + +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d krempl_data -c " + GRANT ALL ON SCHEMA public TO krempl_user; +" + +# Schema laden +cd /var/www/vhosts/newmail.intelectra.de/httpdocs/core/postgres +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d krempl_data -f schema.sql + +# Tabellen-Rechte setzen +PGPASSWORD=$PLESK_PW psql -U root -h localhost -d krempl_data -c " + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO krempl_user; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO krempl_user; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO krempl_user; +" +``` + +### 3. Config erstellen + +```bash +cd /pfad/zu/Intelectra-WebShop/core/postgres/ + +# Kopiere Beispiel-Config +cp config.ini.example config.ini + +# Bearbeite config.ini +nano config.ini + +# Trage ein: +# - PostgreSQL Passwort +# - CSV-Dateipfade +``` + +### 4. Import ausführen + +```bash +# Python-Abhängigkeit installieren +pip3 install psycopg2-binary + +# Prüfen: +python3 -c "import psycopg2; print('psycopg2 OK')" + +# Import starten (dauert 10-20 Min) +cd /var/www/vhosts/newmail.intelectra.de/httpdocs/core/postgres +python3 import.py + +# Mit Live-Output: +python3 import.py 2>&1 | tee /tmp/krempl_import.log + +# Fortschritt anschauen: +tail -f /tmp/krempl_import.log + +# Mit anderem Config-File: +python3 import.py --config /pfad/zu/config.ini +``` + +**Erwarteter Output:** +``` +====================================================================== +KREMPL POSTGRESQL IMPORT +Aggregiert 61 Millionen Zeilen → 68k Zeilen mit Arrays +====================================================================== + +✅ Verbunden mit PostgreSQL: krempl_data +====================================================================== +[1/4] IMPORTIERE GERÄTE +====================================================================== +📁 CSV-Datei: geraete_Export.csv (227.0 MB) +🗑️ Tabelle geleert + → 100,000 Geräte importiert... + → 200,000 Geräte importiert... +... +✅ 1,446,180 Geräte importiert (Fehler: 0) + +====================================================================== +[2/4] IMPORTIERE ERSATZTEILE +====================================================================== +... + +====================================================================== +[3/4] IMPORTIERE MAPPING (AGGREGIERT) +====================================================================== +📁 CSV-Datei: artikelgeraet_Export.csv (1.32 GB) +💾 Modus: In-Memory Aggregation (schnell, RAM-intensiv) +⏳ Lese und aggregiere 61 Millionen Zeilen... + → 1,000,000 Zeilen gelesen... + → 10,000,000 Zeilen gelesen... + → 61,000,000 Zeilen gelesen... +✓ 61,234,567 Zeilen gelesen +✓ 68,667 unique Ersatzteile gefunden +💾 Schreibe aggregierte Daten nach PostgreSQL... + → 500/68,667 Ersatzteile geschrieben... + → 10,000/68,667 Ersatzteile geschrieben... +✅ 68,667 Ersatzteile mit Arrays gespeichert +📊 Max Geräte pro Teil: 129,819 +📊 Ø Geräte pro Teil: 888 + +... + +====================================================================== +✅ IMPORT ERFOLGREICH in 0:12:34 +====================================================================== +``` + +--- + +## 🧪 Testen + +### PostgreSQL Queries testen + +```bash +# Als postgres User +sudo -u postgres psql -d krempl_data + +# Test 1: Geräte zu Ersatzteil +SELECT g.nr, g.marke, g.typ +FROM ersatzteil_mapping em +JOIN geraete g ON g.id = ANY(em.geraet_ids) +WHERE em.ersatzteil_id = 7764466071 +LIMIT 10; + +# Test 2: Ersatzteile zu Gerät +SELECT ersatzteil_id +FROM ersatzteil_mapping +WHERE geraet_ids @> ARRAY[123456]::bigint[] +LIMIT 10; + +# Test 3: Full-Text Suche +SELECT id, nr, marke, typ +FROM geraete +WHERE to_tsvector('german', nr || ' ' || typ) @@ plainto_tsquery('german', 'AEG Kühlschrank') +LIMIT 10; +``` + +### PHP Connection testen + +```bash +# PHP Extension prüfen +php -m | grep pdo_pgsql +# Sollte "pdo_pgsql" ausgeben + +# Test-Script +php -r "new PDO('pgsql:host=localhost;dbname=krempl_data', 'krempl_user', 'PASSWORT');" +# Sollte keine Fehler ausgeben +``` + +--- + +## 🔧 Troubleshooting + +### ❌ "password authentication failed for user krempl_user" + +Sonderzeichen im Passwort können Probleme machen! + +```bash +# Passwort ohne Sonderzeichen setzen (nur A-Z, a-z, 0-9) +PGPASSWORD='PLESK_PG_PASSWORD' psql -U root -h localhost -d postgres -c " + ALTER USER krempl_user WITH PASSWORD 'KremplSecure2025'; +" + +# Testen: +PGPASSWORD='KremplSecure2025' psql -U krempl_user -h localhost -d krempl_data -c "SELECT 1;" +``` + +### ❌ "permission denied for table ..." + +```bash +PGPASSWORD='PLESK_PG_PASSWORD' psql -U root -h localhost -d krempl_data -c " + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO krempl_user; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO krempl_user; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO krempl_user; +" +``` + +### ❌ "ModuleNotFoundError: No module named 'psycopg2'" + +```bash +pip3 install psycopg2-binary +python3 -c "import psycopg2; print('OK')" +``` + +### Import läuft Out of Memory + +→ In `config.ini` ändern: +```ini +mapping_in_memory = false +``` +(Langsamer, aber RAM-freundlich) + +### CSV-Datei nicht gefunden + +→ Prüfe Pfade in `config.ini`: +```bash +ls -lh /var/www/vhosts/intelectra.de/httpdocs/upload/*.csv +``` + +### Log-Datei kann nicht erstellt werden + +→ In `config.ini` Log-Pfad leer lassen: +```ini +[logging] +log_file = +``` + +### PHP kann nicht zu PostgreSQL connecten + +**Plesk:** PHP-PostgreSQL Extension ist meist schon da! +```bash +php -m | grep pdo_pgsql +# Sollte "pdo_pgsql" ausgeben +``` + +--- + +## 📊 Erwartete Performance + +| Metrik | MySQL (alt) | PostgreSQL (neu) | +|--------|-------------|------------------| +| DB-Größe | 1.3 GB | ~100 MB | +| Zeilen | 61 Mio | 68k | +| Artikelseite | 5-15 Sek | 50-200 ms | +| Gerätesuche | 10-30 Sek | 100-500 ms | + +--- + +## 🔄 Re-Import (Update) + +Wenn neue Krempl-Daten kommen: + +```bash +# Einfach nochmal ausführen +python3 import.py + +# Das Script ist idempotent (mehrfach ausführbar) +# Bestehende Daten werden überschrieben +``` + +--- + +## 📚 Nächste Schritte + +Nach erfolgreichem Import: + +1. ✅ PHP Connection-Klasse implementieren (`core/postgres/connection.php`) +2. ✅ Artikelseite umbauen (nutzt PostgreSQL statt MySQL) +3. ✅ Gerätesuche umbauen +4. ✅ Auf Testshop testen +5. ✅ Performance messen +6. 🚀 Live deployen +7. 🗑️ MySQL Krempl-Tabellen löschen (1.3 GB frei!) + +--- + +**Viel Erfolg! 🚀** diff --git a/core/postgres/config.ini.example b/core/postgres/config.ini.example new file mode 100644 index 0000000..84df8b9 --- /dev/null +++ b/core/postgres/config.ini.example @@ -0,0 +1,47 @@ +# ============================================================================ +# Krempl PostgreSQL Import - Konfiguration +# ============================================================================ +# +# WICHTIG: Kopiere diese Datei zu 'config.ini' und trage deine Credentials ein! +# cp config.ini.example config.ini +# +# Die echte config.ini sollte NICHT ins Git committed werden! +# (Ist in .gitignore eingetragen) +# +# ============================================================================ + +[postgresql] +host = localhost +port = 5432 +database = krempl_data +user = krempl_user +password = DEIN_POSTGRES_PASSWORT_HIER + +[csv_files] +# Pfade zu den CSV-Export-Dateien (vom Server) +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-Größe für Inserts (höher = schneller, aber mehr RAM) +batch_size_geraete = 1000 +batch_size_artikel = 1000 +batch_size_passendwie = 1000 +batch_size_mapping = 500 + +# Mapping-Import: In-Memory aggregieren oder Stream-Mode? +# true = Lädt alle 61M Zeilen in RAM (schneller, braucht ~8GB RAM) +# false = Stream-Mode (langsamer, braucht wenig RAM) +mapping_in_memory = true + +# Fortschritts-Anzeige alle X Zeilen +progress_interval = 100000 + +[logging] +# Log-Level: DEBUG, INFO, WARNING, ERROR +log_level = INFO + +# Log-Datei (optional, leer = nur Console) +log_file = diff --git a/core/postgres/import.py b/core/postgres/import.py new file mode 100644 index 0000000..39d2a23 --- /dev/null +++ b/core/postgres/import.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Krempl PostgreSQL Import Script + +Importiert Krempl-Daten von CSV nach PostgreSQL und aggregiert dabei +61 Millionen Mapping-Zeilen zu 68k Zeilen mit Arrays. + +Usage: + python3 import.py [--config config.ini] +""" + +import psycopg2 +import csv +import os +import sys +import logging +from collections import defaultdict +from datetime import datetime +from configparser import ConfigParser +from pathlib import Path +import argparse + + +# ============================================================================ +# Konfiguration laden +# ============================================================================ + +def load_config(config_file='config.ini'): + """Lädt Konfiguration aus INI-Datei""" + config = ConfigParser() + + if not os.path.exists(config_file): + print(f"❌ Konfigurationsdatei nicht gefunden: {config_file}") + print(f"💡 Kopiere config.ini.example zu config.ini und passe die Werte an!") + sys.exit(1) + + config.read(config_file, encoding='utf-8') + return config + + +# ============================================================================ +# Logging Setup +# ============================================================================ + +def setup_logging(config): + """Richtet Logging ein""" + log_level = config.get('logging', 'log_level', fallback='INFO') + log_file = config.get('logging', 'log_file', fallback='') + + logging_config = { + 'level': getattr(logging, log_level), + 'format': '%(asctime)s [%(levelname)s] %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S' + } + + if log_file: + logging_config['filename'] = log_file + logging_config['filemode'] = 'a' + + logging.basicConfig(**logging_config) + return logging.getLogger(__name__) + + +# ============================================================================ +# PostgreSQL Connection +# ============================================================================ + +def connect_postgres(config): + """Stellt Verbindung zu PostgreSQL her""" + try: + conn = psycopg2.connect( + host=config.get('postgresql', 'host'), + port=config.getint('postgresql', 'port'), + database=config.get('postgresql', 'database'), + user=config.get('postgresql', 'user'), + password=config.get('postgresql', 'password') + ) + logging.info(f"✅ Verbunden mit PostgreSQL: {config.get('postgresql', 'database')}") + return conn + except Exception as e: + logging.error(f"❌ PostgreSQL Verbindung fehlgeschlagen: {e}") + sys.exit(1) + + +# ============================================================================ +# Import Funktionen +# ============================================================================ + +def import_geraete(conn, config, logger): + """Importiert Geräte-Tabelle (~1.4 Mio Zeilen)""" + logger.info("=" * 70) + logger.info("[1/4] IMPORTIERE GERÄTE") + logger.info("=" * 70) + + csv_path = config.get('csv_files', 'geraete') + batch_size = config.getint('import_settings', 'batch_size_geraete', fallback=1000) + progress_interval = config.getint('import_settings', 'progress_interval', fallback=100000) + + if not os.path.exists(csv_path): + logger.warning(f"⚠️ CSV nicht gefunden: {csv_path}") + logger.warning("⚠️ Überspringe Geräte-Import") + return + + file_size_mb = os.path.getsize(csv_path) / (1024**2) + logger.info(f"📁 CSV-Datei: {csv_path} ({file_size_mb:.1f} MB)") + + cursor = conn.cursor() + + # Tabelle leeren + cursor.execute("TRUNCATE TABLE geraete RESTART IDENTITY CASCADE") + logger.info("🗑️ Tabelle geleert") + + # CSV einlesen + batch = [] + total = 0 + errors = 0 + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + + for row in reader: + try: + values = ( + int(row['id']) if row.get('id') else None, + row.get('nr'), + row.get('marke'), + row.get('typ'), + row.get('zusatzNummer'), + row.get('modellBezeichnung'), + row.get('produktionsstart'), + row.get('produktionsende'), + row.get('wgtext1'), + row.get('wgtext2'), + row.get('zusatz'), + row.get('bezeichnungoriginal'), + row.get('typDE'), + row.get('typFR') + ) + batch.append(values) + + if len(batch) >= batch_size: + cursor.executemany(""" + INSERT INTO geraete + (id, nr, marke, typ, zusatz_nummer, modell_bezeichnung, + produktionsstart, produktionsende, wgtext1, wgtext2, + zusatz, bezeichnung_original, typ_de, typ_fr) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT (id) DO UPDATE SET + nr = EXCLUDED.nr, + marke = EXCLUDED.marke, + typ = EXCLUDED.typ, + updated_at = CURRENT_TIMESTAMP + """, batch) + conn.commit() + total += len(batch) + batch.clear() + + if total % progress_interval == 0: + logger.info(f" → {total:,} Geräte importiert...") + + except Exception as e: + errors += 1 + logger.debug(f"Fehler bei Zeile {total + len(batch)}: {e}") + continue + + # Letzte Batch + if batch: + try: + cursor.executemany(""" + INSERT INTO geraete + (id, nr, marke, typ, zusatz_nummer, modell_bezeichnung, + produktionsstart, produktionsende, wgtext1, wgtext2, + zusatz, bezeichnung_original, typ_de, typ_fr) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT (id) DO UPDATE SET + nr = EXCLUDED.nr, + updated_at = CURRENT_TIMESTAMP + """, batch) + conn.commit() + total += len(batch) + except Exception as e: + logger.error(f"Fehler bei letzter Batch: {e}") + + logger.info(f"✅ {total:,} Geräte importiert (Fehler: {errors})") + cursor.close() + + +def import_ersatzteile(conn, config, logger): + """Importiert Ersatzteil-Metadaten""" + logger.info("=" * 70) + logger.info("[2/4] IMPORTIERE ERSATZTEILE") + logger.info("=" * 70) + + csv_path = config.get('csv_files', 'artikel') + batch_size = config.getint('import_settings', 'batch_size_artikel', fallback=1000) + + if not os.path.exists(csv_path): + logger.warning(f"⚠️ CSV nicht gefunden: {csv_path}") + logger.warning("⚠️ Überspringe Ersatzteile-Import") + return + + file_size_mb = os.path.getsize(csv_path) / (1024**2) + logger.info(f"📁 CSV-Datei: {csv_path} ({file_size_mb:.1f} MB)") + + cursor = conn.cursor() + cursor.execute("TRUNCATE TABLE ersatzteile RESTART IDENTITY") + logger.info("🗑️ Tabelle geleert") + + batch = [] + total = 0 + errors = 0 + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + + for row in reader: + try: + values = ( + int(row['id']) if row.get('id') else None, + int(row['navisionid']) if row.get('navisionid') else None, + row.get('originalnummer'), + row.get('marke'), + row.get('ean'), + row.get('altenr') + ) + batch.append(values) + + if len(batch) >= batch_size: + cursor.executemany(""" + INSERT INTO ersatzteile + (id, navision_id, originalnummer, marke, ean, altenr) + VALUES (%s,%s,%s,%s,%s,%s) + ON CONFLICT (id) DO NOTHING + """, batch) + conn.commit() + total += len(batch) + batch.clear() + + except Exception as e: + errors += 1 + logger.debug(f"Fehler bei Zeile {total + len(batch)}: {e}") + continue + + # Letzte Batch + if batch: + try: + cursor.executemany(""" + INSERT INTO ersatzteile + (id, navision_id, originalnummer, marke, ean, altenr) + VALUES (%s,%s,%s,%s,%s,%s) + ON CONFLICT (id) DO NOTHING + """, batch) + conn.commit() + total += len(batch) + except Exception as e: + logger.error(f"Fehler bei letzter Batch: {e}") + + logger.info(f"✅ {total:,} Ersatzteile importiert (Fehler: {errors})") + cursor.close() + + +def import_mapping_aggregated(conn, config, logger): + """ + HAUPTFUNKTION: Import Mapping mit Aggregation + 61 Mio Zeilen → 68k Zeilen mit Arrays + """ + logger.info("=" * 70) + logger.info("[3/4] IMPORTIERE MAPPING (AGGREGIERT)") + logger.info("=" * 70) + + csv_path = config.get('csv_files', 'mapping') + batch_size = config.getint('import_settings', 'batch_size_mapping', fallback=500) + progress_interval = config.getint('import_settings', 'progress_interval', fallback=1000000) + in_memory = config.getboolean('import_settings', 'mapping_in_memory', fallback=True) + + if not os.path.exists(csv_path): + logger.error(f"❌ CSV nicht gefunden: {csv_path}") + logger.error("❌ Mapping-Import ist kritisch! Abbruch.") + return + + file_size_gb = os.path.getsize(csv_path) / (1024**3) + logger.info(f"📁 CSV-Datei: {csv_path} ({file_size_gb:.2f} GB)") + + if in_memory: + logger.info("💾 Modus: In-Memory Aggregation (schnell, RAM-intensiv)") + else: + logger.info("💾 Modus: Stream (langsam, RAM-freundlich)") + + # SCHRITT 1: CSV lesen und aggregieren + logger.info("⏳ Lese und aggregiere 61 Millionen Zeilen...") + + mapping = defaultdict(list) + count = 0 + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + + for row in reader: + try: + ersatzteil = int(row['ersatzteil']) + geraet = int(row['geraet']) + mapping[ersatzteil].append(geraet) + + count += 1 + if count % progress_interval == 0: + logger.info(f" → {count:,} Zeilen gelesen... ({len(mapping):,} unique Ersatzteile)") + except Exception as e: + logger.debug(f"Fehler bei Zeile {count}: {e}") + continue + + logger.info(f"✓ {count:,} Zeilen gelesen") + logger.info(f"✓ {len(mapping):,} unique Ersatzteile gefunden") + + # SCHRITT 2: In PostgreSQL schreiben + logger.info("💾 Schreibe aggregierte Daten nach PostgreSQL...") + + cursor = conn.cursor() + cursor.execute("TRUNCATE TABLE ersatzteil_mapping RESTART IDENTITY") + logger.info("🗑️ Tabelle geleert") + + batch = [] + total = 0 + + for ersatzteil_id, geraet_list in mapping.items(): + batch.append(( + ersatzteil_id, + geraet_list, # Python List → PostgreSQL Array + len(geraet_list) + )) + + if len(batch) >= batch_size: + cursor.executemany(""" + INSERT INTO ersatzteil_mapping + (ersatzteil_id, geraet_ids, geraet_count) + VALUES (%s, %s, %s) + ON CONFLICT (ersatzteil_id) DO UPDATE SET + geraet_ids = EXCLUDED.geraet_ids, + geraet_count = EXCLUDED.geraet_count, + last_updated = CURRENT_TIMESTAMP + """, batch) + conn.commit() + total += len(batch) + batch.clear() + logger.info(f" → {total:,}/{len(mapping):,} Ersatzteile geschrieben...") + + # Letzte Batch + if batch: + cursor.executemany(""" + INSERT INTO ersatzteil_mapping + (ersatzteil_id, geraet_ids, geraet_count) + VALUES (%s, %s, %s) + ON CONFLICT (ersatzteil_id) DO UPDATE SET + geraet_ids = EXCLUDED.geraet_ids, + geraet_count = EXCLUDED.geraet_count, + last_updated = CURRENT_TIMESTAMP + """, batch) + conn.commit() + total += len(batch) + + logger.info(f"✅ {total:,} Ersatzteile mit Arrays gespeichert") + + # STATISTIK + cursor.execute("SELECT MAX(geraet_count), AVG(geraet_count) FROM ersatzteil_mapping") + max_count, avg_count = cursor.fetchone() + logger.info(f"📊 Max Geräte pro Teil: {max_count:,}") + logger.info(f"📊 Ø Geräte pro Teil: {avg_count:.0f}") + + cursor.close() + + +def import_passendwie(conn, config, logger): + """Importiert PassendWie-Daten (Ein Ersatzteil hat mehrere Vertreiber-Codes)""" + logger.info("=" * 70) + logger.info("[4/4] IMPORTIERE PASSENDWIE") + logger.info("=" * 70) + + csv_path = config.get('csv_files', 'passendwie') + batch_size = config.getint('import_settings', 'batch_size_passendwie', fallback=1000) + + if not os.path.exists(csv_path): + logger.warning(f"⚠️ CSV nicht gefunden: {csv_path}") + logger.warning("⚠️ Überspringe PassendWie-Import") + return + + file_size_mb = os.path.getsize(csv_path) / (1024**2) + logger.info(f"📁 CSV-Datei: {csv_path} ({file_size_mb:.1f} MB)") + + cursor = conn.cursor() + cursor.execute("TRUNCATE TABLE passendwie RESTART IDENTITY CASCADE") + logger.info("🗑️ Tabelle geleert") + + batch = [] + total = 0 + errors = 0 + duplicates = 0 + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + + for row in reader: + try: + values = ( + int(row['id']) if row.get('id') else None, + int(row['navisionid']) if row.get('navisionid') else None, + row.get('vertreiberid'), + row.get('vertreiber'), + row.get('bestellcode') + ) + batch.append(values) + + if len(batch) >= batch_size: + try: + cursor.executemany(""" + INSERT INTO passendwie + (id, navision_id, vertreiber_id, vertreiber, bestellcode) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (id, vertreiber_id, bestellcode) DO NOTHING + """, batch) + conn.commit() + total += len(batch) + batch.clear() + except Exception as e: + logger.debug(f"Batch-Fehler: {e}") + conn.rollback() + # Einzeln einfügen bei Fehler + for single_row in batch: + try: + cursor.execute(""" + INSERT INTO passendwie + (id, navision_id, vertreiber_id, vertreiber, bestellcode) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (id, vertreiber_id, bestellcode) DO NOTHING + """, single_row) + conn.commit() + total += 1 + except Exception: + duplicates += 1 + batch.clear() + + except Exception as e: + errors += 1 + logger.debug(f"Fehler bei Zeile {total + len(batch)}: {e}") + continue + + # Letzte Batch + if batch: + try: + cursor.executemany(""" + INSERT INTO passendwie + (id, navision_id, vertreiber_id, vertreiber, bestellcode) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (id, vertreiber_id, bestellcode) DO NOTHING + """, batch) + conn.commit() + total += len(batch) + except Exception as e: + logger.debug(f"Fehler bei letzter Batch: {e}") + conn.rollback() + for single_row in batch: + try: + cursor.execute(""" + INSERT INTO passendwie + (id, navision_id, vertreiber_id, vertreiber, bestellcode) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (id, vertreiber_id, bestellcode) DO NOTHING + """, single_row) + conn.commit() + total += 1 + except Exception: + duplicates += 1 + + logger.info(f"✅ {total:,} PassendWie-Einträge importiert (Fehler: {errors}, Duplikate übersprungen: {duplicates})") + cursor.close() + + +def analyze_tables(conn, logger): + """Aktualisiert Tabellen-Statistiken für Query-Optimizer""" + logger.info("=" * 70) + logger.info("[FINAL] AKTUALISIERE TABELLEN-STATISTIKEN") + logger.info("=" * 70) + + # Rollback falls vorherige Transaction fehlgeschlagen + conn.rollback() + + cursor = conn.cursor() + + tables = ['geraete', 'ersatzteil_mapping', 'ersatzteile', 'passendwie'] + for table in tables: + try: + logger.info(f" ANALYZE {table}...") + cursor.execute(f"ANALYZE {table}") + conn.commit() + except Exception as e: + logger.warning(f" ⚠️ ANALYZE {table} fehlgeschlagen: {e}") + conn.rollback() + continue + + logger.info("✅ Statistiken aktualisiert") + cursor.close() + + +# ============================================================================ +# Main +# ============================================================================ + +def main(): + """Hauptfunktion""" + parser = argparse.ArgumentParser(description='Krempl PostgreSQL Import') + parser.add_argument('--config', default='config.ini', help='Pfad zur config.ini') + args = parser.parse_args() + + # Config laden + config = load_config(args.config) + + # Logging setup + logger = setup_logging(config) + + # Header + print("=" * 70) + print("KREMPL POSTGRESQL IMPORT") + print("Aggregiert 61 Millionen Zeilen → 68k Zeilen mit Arrays") + print("=" * 70) + print() + + start_time = datetime.now() + + # PostgreSQL Connection + conn = connect_postgres(config) + + try: + # Import durchführen + import_geraete(conn, config, logger) + import_ersatzteile(conn, config, logger) + import_mapping_aggregated(conn, config, logger) # WICHTIGSTE FUNKTION! + import_passendwie(conn, config, logger) + analyze_tables(conn, logger) + + # Erfolg + duration = datetime.now() - start_time + print() + print("=" * 70) + print(f"✅ IMPORT ERFOLGREICH in {duration}") + print("=" * 70) + + except KeyboardInterrupt: + logger.warning("\n⚠️ Import abgebrochen durch Benutzer") + conn.rollback() + sys.exit(1) + + except Exception as e: + logger.error(f"\n❌ FEHLER: {e}") + logger.exception("Traceback:") + conn.rollback() + sys.exit(1) + + finally: + conn.close() + logger.info("Datenbankverbindung geschlossen") + + +if __name__ == '__main__': + main() diff --git a/core/postgres/schema.sql b/core/postgres/schema.sql new file mode 100644 index 0000000..d802c4b --- /dev/null +++ b/core/postgres/schema.sql @@ -0,0 +1,242 @@ +-- ============================================================================ +-- KREMPL PostgreSQL Schema +-- Reduziert 61 Millionen MySQL-Zeilen auf 68k Zeilen mit Arrays +-- ============================================================================ + +-- Datenbank erstellen (als postgres User ausführen) +-- CREATE DATABASE krempl_data +-- WITH ENCODING 'UTF8' +-- LC_COLLATE='de_DE.UTF-8' +-- LC_CTYPE='de_DE.UTF-8'; + +-- User erstellen +-- CREATE USER krempl_user WITH PASSWORD 'PASSWORT_HIER_SETZEN'; +-- GRANT ALL PRIVILEGES ON DATABASE krempl_data TO krempl_user; + +-- Nach \c krempl_data: +-- GRANT ALL ON SCHEMA public TO krempl_user; + +-- ============================================================================ +-- Extensions aktivieren +-- ============================================================================ + +-- Trigram-Suche für ähnliche Strings +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Schnellere Array-Operationen +CREATE EXTENSION IF NOT EXISTS intarray; + +-- Query-Performance Tracking (optional) +-- CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +-- ============================================================================ +-- Tabelle 1: GERÄTE (ca. 1.4 Mio Zeilen) +-- ============================================================================ + +DROP TABLE IF EXISTS geraete CASCADE; + +CREATE TABLE geraete ( + id BIGINT PRIMARY KEY, + nr VARCHAR(100), + marke VARCHAR(100), + typ VARCHAR(200), + zusatz_nummer VARCHAR(100), + modell_bezeichnung VARCHAR(200), + produktionsstart VARCHAR(50), + produktionsende VARCHAR(50), + wgtext1 VARCHAR(200), + wgtext2 VARCHAR(200), + zusatz TEXT, + bezeichnung_original TEXT, + typ_de VARCHAR(200), + typ_fr VARCHAR(200), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Standard-Indexe für schnelle Lookups +CREATE INDEX idx_geraete_nr ON geraete(nr); +CREATE INDEX idx_geraete_marke ON geraete(marke); +CREATE INDEX idx_geraete_typ ON geraete(typ); + +-- Trigram-Indexe für LIKE-Suchen (z.B. "AEG%" oder "%123%") +CREATE INDEX idx_geraete_nr_trgm ON geraete USING GIN(nr gin_trgm_ops); +CREATE INDEX idx_geraete_typ_trgm ON geraete USING GIN(typ gin_trgm_ops); +CREATE INDEX idx_geraete_marke_trgm ON geraete USING GIN(marke gin_trgm_ops); + +-- Full-Text Search Index (Kombination: nr + typ + marke) +CREATE INDEX idx_geraete_fts ON geraete + USING GIN(to_tsvector('german', + COALESCE(nr,'') || ' ' || + COALESCE(typ,'') || ' ' || + COALESCE(marke,'') || ' ' || + COALESCE(modell_bezeichnung,'') + )); + +COMMENT ON TABLE geraete IS 'Geräte-Stammdaten aus Krempl (ca. 1.4 Mio Einträge)'; + +-- ============================================================================ +-- Tabelle 2: ERSATZTEIL-MAPPING (68k Zeilen statt 61 Mio!) +-- ============================================================================ + +DROP TABLE IF EXISTS ersatzteil_mapping CASCADE; + +CREATE TABLE ersatzteil_mapping ( + ersatzteil_id BIGINT PRIMARY KEY, + geraet_ids BIGINT[], -- Array statt Millionen einzelner Zeilen! + geraet_count INT, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- GIN Index für Array-Suche "Welche Ersatzteile passen zu Gerät X?" +-- Ermöglicht: WHERE geraet_ids @> ARRAY[123456] +CREATE INDEX idx_mapping_geraet_array ON ersatzteil_mapping + USING GIN(geraet_ids); + +-- Standard B-Tree Index für schnelle Ersatzteil-Lookups +CREATE INDEX idx_mapping_ersatzteil ON ersatzteil_mapping(ersatzteil_id); + +COMMENT ON TABLE ersatzteil_mapping IS 'N:N Mapping Ersatzteil↔Gerät als Arrays (reduziert 61M Zeilen auf 68k)'; +COMMENT ON COLUMN ersatzteil_mapping.geraet_ids IS 'Array aller Geräte-IDs die zu diesem Ersatzteil passen'; +COMMENT ON COLUMN ersatzteil_mapping.geraet_count IS 'Anzahl Geräte (für schnelle Anzeige ohne Array-Count)'; + +-- ============================================================================ +-- Tabelle 3: ERSATZTEILE (Metadaten, optional) +-- ============================================================================ + +DROP TABLE IF EXISTS ersatzteile CASCADE; + +CREATE TABLE ersatzteile ( + id BIGINT PRIMARY KEY, + navision_id BIGINT, + originalnummer VARCHAR(100), + marke VARCHAR(100), + ean VARCHAR(50), + altenr VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexe für Join mit Shop-Items +CREATE INDEX idx_ersatzteile_navision ON ersatzteile(navision_id); +CREATE INDEX idx_ersatzteile_original ON ersatzteile(originalnummer); +CREATE INDEX idx_ersatzteile_ean ON ersatzteile(ean); + +COMMENT ON TABLE ersatzteile IS 'Ersatzteil-Metadaten aus Krempl'; + +-- ============================================================================ +-- Tabelle 4: PASSENDWIE (optional) +-- ============================================================================ + +DROP TABLE IF EXISTS passendwie CASCADE; + +CREATE TABLE passendwie ( + row_id SERIAL PRIMARY KEY, -- Auto-increment ID für interne Zwecke + id BIGINT NOT NULL, -- Krempl Ersatzteil-ID (NICHT unique!) + navision_id BIGINT, + vertreiber_id VARCHAR(100), + vertreiber VARCHAR(200), + bestellcode VARCHAR(200), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Eindeutigkeit: Ein Ersatzteil kann mehrere Vertreiber-Codes haben + UNIQUE(id, vertreiber_id, bestellcode) +); + +CREATE INDEX idx_passendwie_id ON passendwie(id); +CREATE INDEX idx_passendwie_navision ON passendwie(navision_id); +CREATE INDEX idx_passendwie_vertreiber ON passendwie(vertreiber_id); +CREATE INDEX idx_passendwie_bestellcode ON passendwie(bestellcode); + +COMMENT ON TABLE passendwie IS 'PassendWie-Zuordnungen: Ein Ersatzteil hat mehrere Vertreiber-Bestellcodes (206k Zeilen)'; + +-- ============================================================================ +-- Hilfsfunktionen +-- ============================================================================ + +-- Funktion: Zähle Geräte in Array (Alternative zu geraet_count-Spalte) +CREATE OR REPLACE FUNCTION count_geraete(ersatzteil_id_param BIGINT) +RETURNS INT AS $$ + SELECT COALESCE(array_length(geraet_ids, 1), 0) + FROM ersatzteil_mapping + WHERE ersatzteil_id = ersatzteil_id_param; +$$ LANGUAGE SQL STABLE; + +-- Funktion: Auto-Update updated_at Timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger für geraete.updated_at +CREATE TRIGGER update_geraete_updated_at + BEFORE UPDATE ON geraete + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================ +-- Views für vereinfachte Queries (optional) +-- ============================================================================ + +-- View: Ersatzteile mit Geräte-Anzahl +CREATE OR REPLACE VIEW v_ersatzteile_stats AS +SELECT + em.ersatzteil_id, + em.geraet_count, + e.navision_id, + e.originalnummer, + e.marke +FROM ersatzteil_mapping em +LEFT JOIN ersatzteile e ON e.id = em.ersatzteil_id +ORDER BY em.geraet_count DESC; + +COMMENT ON VIEW v_ersatzteile_stats IS 'Ersatzteile mit Statistiken (Anzahl passender Geräte)'; + +-- ============================================================================ +-- Performance-Optimierung +-- ============================================================================ + +-- Statistiken aktualisieren (nach Import ausführen!) +-- ANALYZE geraete; +-- ANALYZE ersatzteil_mapping; +-- ANALYZE ersatzteile; +-- ANALYZE passendwie; + +-- ============================================================================ +-- Grant Permissions +-- ============================================================================ + +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO krempl_user; +-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO krempl_user; +-- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO krempl_user; + +-- ============================================================================ +-- Fertig! +-- ============================================================================ + +-- Beispiel-Query: Geräte zu Ersatzteil +/* +SELECT g.* +FROM ersatzteil_mapping em +JOIN geraete g ON g.id = ANY(em.geraet_ids) +WHERE em.ersatzteil_id = 7764466071 +LIMIT 100; +*/ + +-- Beispiel-Query: Ersatzteile zu Gerät +/* +SELECT ersatzteil_id +FROM ersatzteil_mapping +WHERE geraet_ids @> ARRAY[123456]::bigint[] +LIMIT 100; +*/ + +-- Beispiel-Query: Full-Text Suche +/* +SELECT id, nr, marke, typ +FROM geraete +WHERE to_tsvector('german', nr || ' ' || typ) @@ plainto_tsquery('german', 'AEG Kühlschrank') +LIMIT 50; +*/ diff --git a/tba-search.php b/tba-search.php new file mode 100644 index 0000000..4396154 --- /dev/null +++ b/tba-search.php @@ -0,0 +1,461 @@ +searchGeraete($search_term, 20); + + // If shop items requested, replace ersatzteile with actual shop items + if ($show_shop_items) { + foreach ($results as &$device) { + $device['shop_items'] = $db->getAvailableItemsForGeraet($device['geraet_id'], 200); + } + unset($device); + } + + $search_time = (microtime(true) - $start_time) * 1000; // Convert to milliseconds + } +} catch (Exception $e) { + $error_message = $e->getMessage(); +} +?> + + + + + + Krempl PostgreSQL Search Test + + + +
+

Krempl PostgreSQL Search Test

+ + + + +
+ Fehler bei der Datenbankverbindung: + +
+ + + +
+ 0): ?> + Geräte gefunden für "" + ( ms) + + Keine Ergebnisse für "" + +
+ + + = 100) $score_class = 'score-100'; + elseif ($score >= 95) $score_class = 'score-95'; + elseif ($score >= 90) $score_class = 'score-90'; + elseif ($score >= 80) $score_class = 'score-80'; + elseif ($score >= 75) $score_class = 'score-75'; + elseif ($score >= 60) $score_class = 'score-60'; + ?> + + + + + +
+ Keine Geräte gefunden für "" +
+ + + +
+ Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "2100B") +
+ +
+ + + +