feat: neue PostgreSQL-Suche (tba-search) aus newmail-vhost übernommen
All checks were successful
Deploy to Dev / deploy (push) Successful in 0s

This commit is contained in:
Thomas Bartelt 2026-04-20 02:11:11 +02:00
parent 48b14a5bf7
commit afd2cedf92
8 changed files with 2014 additions and 0 deletions

13
core/postgres/.gitignore vendored Normal file
View File

@ -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

12
core/postgres/.htaccess Normal file
View File

@ -0,0 +1,12 @@
# Deny access to config files and sensitive data
<FilesMatch "\.(ini|log|py|pyc|md)$">
Require all denied
</FilesMatch>
# Alternative for older Apache versions
<IfModule !mod_authz_core.c>
<FilesMatch "\.(ini|log|py|pyc|md)$">
Order allow,deny
Deny from all
</FilesMatch>
</IfModule>

359
core/postgres/KremplDB.php Normal file
View File

@ -0,0 +1,359 @@
<?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
*/
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);
}
}
}

317
core/postgres/README.md Normal file
View File

@ -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! 🚀**

View File

@ -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 =

563
core/postgres/import.py Normal file
View File

@ -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()

242
core/postgres/schema.sql Normal file
View File

@ -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;
*/

461
tba-search.php Normal file
View File

@ -0,0 +1,461 @@
<?php
/**
* Krempl PostgreSQL Search Test Interface
* Search for devices and show matching ersatzteile
*/
require_once __DIR__ . '/core/postgres/KremplDB.php';
$search_term = isset($_GET['q']) ? trim($_GET['q']) : '';
$results = [];
$search_time = 0;
$error_message = '';
$show_shop_items = isset($_GET['shop_items']) && $_GET['shop_items'] === '1';
try {
// Connect to both PostgreSQL and MySQL if shop items are requested
$db = new KremplDB($show_shop_items);
if ($search_term !== '') {
$start_time = microtime(true);
$results = $db->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();
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krempl PostgreSQL Search Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 20px;
}
.search-box {
margin-bottom: 30px;
}
.search-input {
width: 100%;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #4CAF50;
}
.search-btn {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
white-space: nowrap;
}
.search-btn:hover {
background: #45a049;
}
#shopItemsToggle {
cursor: pointer;
width: 18px;
height: 18px;
}
.search-info {
color: #666;
margin-bottom: 20px;
font-size: 14px;
}
.device-card {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 20px;
background: #fafafa;
overflow: hidden;
}
.device-card.collapsed .ersatzteile-section {
display: none;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 15px;
margin-bottom: 0;
border-bottom: 2px solid #e0e0e0;
}
.score-badge {
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.score-badge:hover {
opacity: 0.9;
transform: scale(1.02);
}
.toggle-arrow {
display: inline-block;
margin-left: 8px;
transition: transform 0.3s;
font-size: 10px;
}
.device-card.collapsed .toggle-arrow {
transform: rotate(-90deg);
}
.device-info {
flex: 1;
}
.device-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.device-title a {
color: #333;
text-decoration: none;
transition: color 0.2s;
}
.device-title a:hover {
color: #4CAF50;
text-decoration: underline;
}
.device-details {
color: #666;
font-size: 14px;
}
.device-details span {
display: inline-block;
margin-right: 15px;
}
.score-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-weight: bold;
font-size: 14px;
}
.score-100 { background: #4CAF50; color: white; }
.score-95 { background: #66BB6A; color: white; }
.score-90 { background: #81C784; color: white; }
.score-80 { background: #FFC107; color: white; }
.score-75 { background: #FFD54F; color: #333; }
.score-60 { background: #FFE082; color: #333; }
.score-default { background: #BDBDBD; color: white; }
.ersatzteile-section {
padding: 15px;
}
.ersatzteile-title {
font-weight: bold;
color: #333;
margin-bottom: 10px;
margin-top: 10px;
font-size: 14px;
}
.ersatzteile-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 4px;
overflow: hidden;
}
.ersatzteile-table th {
background: #f0f0f0;
padding: 8px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #555;
}
.ersatzteile-table td {
padding: 8px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
}
.ersatzteile-table tr:hover {
background: #f9f9f9;
}
.navision-id {
font-weight: bold;
color: #1976D2;
}
.no-results {
text-align: center;
padding: 40px;
color: #999;
}
.match-type {
display: inline-block;
font-size: 11px;
padding: 2px 6px;
background: #e0e0e0;
border-radius: 3px;
color: #666;
margin-left: 8px;
}
.error-message {
background: #ffebee;
border: 1px solid #ef5350;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.error-message strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>Krempl PostgreSQL Search Test</h1>
<div class="search-box">
<form method="GET" action="" id="searchForm">
<input
type="text"
name="q"
class="search-input"
placeholder="Gerätesuche (z.B. AWB 921 PH, ZCS 2100B, 2100B)"
value="<?php echo htmlspecialchars($search_term); ?>"
autofocus
/>
<div style="margin-top: 10px; display: flex; align-items: center; gap: 15px;">
<button type="submit" class="search-btn">Suchen</button>
<label style="font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" name="shop_items" value="1" <?php echo $show_shop_items ? 'checked' : ''; ?> id="shopItemsToggle" onchange="document.getElementById('searchForm').submit();">
<span>Nur verfügbare Shop-Artikel (MySQL)</span>
</label>
</div>
</form>
</div>
<?php if ($error_message): ?>
<div class="error-message">
<strong>Fehler bei der Datenbankverbindung:</strong>
<?php echo htmlspecialchars($error_message); ?>
</div>
<?php endif; ?>
<?php if ($search_term !== '' && !$error_message): ?>
<div class="search-info">
<?php if (count($results) > 0): ?>
<strong><?php echo count($results); ?> Geräte</strong> gefunden für "<?php echo htmlspecialchars($search_term); ?>"
(<?php echo number_format($search_time, 0); ?> ms)
<?php else: ?>
Keine Ergebnisse für "<?php echo htmlspecialchars($search_term); ?>"
<?php endif; ?>
</div>
<?php foreach ($results as $device): ?>
<?php
$score = (int)$device['score'];
$score_class = 'score-default';
if ($score >= 100) $score_class = 'score-100';
elseif ($score >= 95) $score_class = 'score-95';
elseif ($score >= 90) $score_class = 'score-90';
elseif ($score >= 80) $score_class = 'score-80';
elseif ($score >= 75) $score_class = 'score-75';
elseif ($score >= 60) $score_class = 'score-60';
?>
<div class="device-card collapsed" id="device-<?php echo $device['geraet_id']; ?>">
<div class="device-header">
<div class="device-info">
<div class="device-title">
<a href="geraet.php?id=<?php echo $device['geraet_id']; ?>&q=<?php echo urlencode($search_term); ?><?php echo $show_shop_items ? '&shop_items=1' : ''; ?>" style="color: #333; text-decoration: none;">
<?php echo htmlspecialchars($device['modell_bezeichnung']); ?>
</a>
<span class="match-type"><?php echo htmlspecialchars($device['match_type']); ?></span>
</div>
<div class="device-details">
<span><strong>Marke:</strong> <?php echo htmlspecialchars($device['marke']); ?></span>
<span><strong>Typ:</strong> <?php echo htmlspecialchars($device['typ']); ?></span>
<span><strong>Nr:</strong> <?php echo htmlspecialchars($device['nr']); ?></span>
<span><strong>ID:</strong> <?php echo htmlspecialchars($device['geraet_id']); ?></span>
</div>
</div>
<div>
<span class="score-badge <?php echo $score_class; ?>" onclick="toggleDevice(<?php echo $device['geraet_id']; ?>)">
Score: <?php echo $score; ?>
<span class="toggle-arrow"></span>
</span>
</div>
</div>
<?php if ($show_shop_items && isset($device['shop_items'])): ?>
<!-- Show available shop items -->
<?php if (count($device['shop_items']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Verfügbare Shop-Artikel:
<span style="background: #4CAF50; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['shop_items']); ?> verfügbar
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Artikel-Nr</th>
<th>Artikelname</th>
<th>Navision-ID</th>
<th>Lagerbestand</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['shop_items'] as $item): ?>
<tr>
<td><?php echo htmlspecialchars($item['number']); ?></td>
<td><?php echo htmlspecialchars($item['name']); ?></td>
<td class="navision-id"><?php echo htmlspecialchars($item['matched_navision_id']); ?></td>
<td><?php echo (int)$item['inventory']; ?> Stück</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine verfügbaren Shop-Artikel gefunden
</div>
</div>
<?php endif; ?>
<?php else: ?>
<!-- Show all Krempl ersatzteile -->
<?php if (count($device['ersatzteile']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Passende Ersatzteile (Krempl):
<span style="background: #2196F3; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['ersatzteile']); ?> gesamt
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Navision ID</th>
<th>Krempl ID</th>
<th>Originalnummer</th>
<th>Marke</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['ersatzteile'] as $ers): ?>
<tr>
<td class="navision-id"><?php echo htmlspecialchars($ers['navision_id']); ?></td>
<td><?php echo htmlspecialchars($ers['krempl_id']); ?></td>
<td><?php echo htmlspecialchars($ers['originalnummer']); ?></td>
<td><?php echo htmlspecialchars($ers['marke']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine Ersatzteile gefunden
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (count($results) === 0): ?>
<div class="no-results">
Keine Geräte gefunden für "<?php echo htmlspecialchars($search_term); ?>"
</div>
<?php endif; ?>
<?php else: ?>
<div class="no-results">
Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "2100B")
</div>
<?php endif; ?>
</div>
<script>
function toggleDevice(deviceId) {
const card = document.getElementById('device-' + deviceId);
if (card) {
card.classList.toggle('collapsed');
}
}
</script>
</body>
</html>