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