feat: neue PostgreSQL-Suche (tba-search) aus newmail-vhost übernommen
All checks were successful
Deploy to Dev / deploy (push) Successful in 0s
All checks were successful
Deploy to Dev / deploy (push) Successful in 0s
This commit is contained in:
parent
48b14a5bf7
commit
afd2cedf92
13
core/postgres/.gitignore
vendored
Normal file
13
core/postgres/.gitignore
vendored
Normal 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
12
core/postgres/.htaccess
Normal 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
359
core/postgres/KremplDB.php
Normal 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
317
core/postgres/README.md
Normal 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! 🚀**
|
||||
47
core/postgres/config.ini.example
Normal file
47
core/postgres/config.ini.example
Normal 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
563
core/postgres/import.py
Normal 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
242
core/postgres/schema.sql
Normal 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
461
tba-search.php
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user