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