Compare commits

...
2 Commits
1469 changed files with 17978 additions and 641 deletions
+7
View File
@@ -1,3 +1,10 @@
# === DEV-Server: HTTP Basic Auth (kein Kunde, kein Bot) ===
AuthType Basic
AuthName "Dev-Server intelectra"
AuthUserFile /etc/apache2/htpasswd_dev_intelectra
Require valid-user
# === ENDE Auth-Block ===
RewriteEngine On
+16 -1
View File
@@ -177,11 +177,26 @@ class CartSessionManager {
$session_key_clean = $this->db->real_escape_string($session_key);
$status_clean = $this->db->real_escape_string($status);
// 25.08.2026: 'completed' ist ein Endzustand -- Geld ist eingezogen, daran aendert
// ein spaeterer Request nichts mehr. Vorher konnte jeder Reload der Rueckkehr-URL
// eine bezahlte Bestellung auf 'abandoned' zuruecksetzen (belegt am 24.08. bei
// Order 111902 und beim Kunden EC-6PP77504DT5715642). Die Statistik zaehlte solche
// Faelle als Abbruch, obwohl bezahlt wurde.
//
// Der Riegel sitzt hier unten in der WHERE-Klausel und nicht im aufrufenden Code,
// damit er auch fuer kuenftige Aufrufer gilt -- und weil MySQL das atomar
// entscheidet: zwei gleichzeitig zurueckkehrende Requests koennen sich nicht mehr
// gegenseitig ueberholen.
$schutz = '';
if ($status_clean !== 'completed') {
$schutz = " AND status <> 'completed'";
}
$query = "
UPDATE cart_sessions
SET status = '{$status_clean}',
updated_at = CURRENT_TIMESTAMP
WHERE session_key = '{$session_key_clean}'
WHERE session_key = '{$session_key_clean}'{$schutz}
";
$result = $this->db->query($query);
+32
View File
@@ -95,6 +95,38 @@ class Config extends Main {
}
}
// === Mobile-Theme-Switch (Test-Phase) ===
// Priorität:
// 1. URL-Parameter ?theme=X (setzt Cookie für 30 Tage)
// 2. Cookie THEME_OVERRIDE
// 3. User-Agent: iOS/Android Mobile → automatisch Mobile-Theme
// Whitelist verhindert beliebige Theme-Werte (Sicherheit).
$theme_whitelist = ['theme2', 'mobile'];
$is_https = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
if (isset($_GET['theme']) && in_array($_GET['theme'], $theme_whitelist, true)) {
// iOS Safari/Chrome braucht modernes Cookie-Format mit SameSite + Secure
setcookie('THEME_OVERRIDE', $_GET['theme'], [
'expires' => time() + 30 * 86400,
'path' => '/',
'secure' => $is_https,
'httponly' => false,
'samesite' => 'Lax',
]);
$this->shopConfiguration['THEME'] = $_GET['theme'];
} elseif (isset($_COOKIE['THEME_OVERRIDE']) && in_array($_COOKIE['THEME_OVERRIDE'], $theme_whitelist, true)) {
$this->shopConfiguration['THEME'] = $_COOKIE['THEME_OVERRIDE'];
} else {
// Automatik: iOS oder Android-Mobile? → Mobile-Theme
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
if (preg_match('/iPhone|iPod|iPad|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i', $ua)) {
$this->shopConfiguration['THEME'] = 'mobile';
}
}
// === Ende Theme-Switch ===
foreach ($text_values as $obj) {
$this->shopConfiguration[$obj->name] = str_replace('{$THEME_DIR}', STANDARD_DIR.'web/'.SHOP_SYSTEM.'/themes/'.$this->shopConfiguration['THEME'], $obj->setting);
}
+2 -1
View File
@@ -404,7 +404,8 @@ class Cs_ticket extends Main {
// get agb's ...
$site_content_object = new SiteContent($this->base_object);
$var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(55);
// Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet.
$var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(10737);
$var_data['[#agb]'] = $site_content_object->get_textbox_data_by_id(56);
$var_data['[#agb_b2b]'] = $site_content_object->get_textbox_data_by_id(10632);
$var_data['[#widerruf]'] = $site_content_object->get_textbox_data_by_id(60);
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Absturzprotokoll (25.08.2026).
*
* Der Shop schaltet in index.php bewusst alles ab:
* ini_set('display_errors', 0); error_reporting(0);
* Das ist fuer die Anzeige richtig -- nur wurde damit auch das Protokollieren still
* gelegt. Ueber 130 HTTP-500 in zehn Tagen standen nirgends: nicht in error_log.txt,
* nicht im Apache-Log. Gefunden wurden sie erst, als Google sich beschwerte.
*
* DIESE DATEI AENDERT NICHTS AM VERHALTEN DES SHOPS.
* register_shutdown_function() und error_get_last() arbeiten unabhaengig von
* error_reporting -- kein ini_set, kein eigener Exception-Handler (der wuerde aus einem
* sauberen HTTP 500 eine halb gerenderte 200er-Seite machen). Ungefangene Exceptions
* erzeugen ihrerseits einen E_ERROR und landen deshalb ebenfalls hier.
*
* Geschrieben wird in eine EIGENE Datei, nicht in error_log.txt -- dort gehen zwischen
* ein paar tausend Bot-Meldungen pro Tag genau die Zeilen unter, um die es geht.
*
* Auswertung:
* tail -n 40 logs/fatal.log
* grep -c FATAL logs/fatal.log
* awk -F' | ' '{print $4}' logs/fatal.log | sort | uniq -c | sort -rn # haeufigste Stellen
*/
if (!function_exists('intelectra_fatal_log_init')) {
function intelectra_fatal_log_init() {
register_shutdown_function('intelectra_fatal_log_shutdown');
}
function intelectra_fatal_log_shutdown() {
$fehler = error_get_last();
if (!is_array($fehler)) {
return;
}
// Nur echte Abstuerze. Warnungen und Notices interessieren hier nicht -- der
// letzte error_get_last() waere sonst bei fast jedem Request gefuellt.
$fatal = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING,
E_COMPILE_ERROR, E_COMPILE_WARNING, E_USER_ERROR);
if (!in_array($fehler['type'], $fatal, true)) {
return;
}
try {
$datei = __DIR__ . '/../logs/fatal.log';
// Gleiche Stelle nicht im Sekundentakt mitschreiben: eine Bot-Welle auf eine
// kaputte Seite wuerde das Log sonst in Minuten unbrauchbar machen.
if (intelectra_fatal_log_schon_gesehen($fehler)) {
return;
}
// Bei 5 MB einmal wegrotieren. Mehr als zwei Generationen braucht es nicht --
// wer hier nachsieht, sucht den Fehler von heute.
if (@filesize($datei) > 5242880) {
@rename($datei, $datei . '.1');
}
$anfrage = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-';
$methode = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '-';
$referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '-';
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '-';
$agent = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 120) : '-';
// Die Meldung einer ungefangenen Exception bringt den Stack-Trace mit. Der ist
// das Wertvollste daran, aber mehrzeilig -- fuer eine Zeile pro Fehler wird er
// auf die ersten Ebenen eingedampft.
$meldung = preg_replace('/\s+/', ' ', trim($fehler['message']));
if (strlen($meldung) > 600) {
$meldung = substr($meldung, 0, 600) . ' …';
}
$zeile = sprintf(
"[%s] FATAL | %s | %s:%d | %s %s | ref=%s | ip=%s | ua=%s\n",
date('Y-m-d H:i:s'),
$meldung,
$fehler['file'],
$fehler['line'],
$methode,
$anfrage,
$referer,
$ip,
$agent
);
@file_put_contents($datei, $zeile, FILE_APPEND | LOCK_EX);
} catch (Throwable $e) {
// Ein Protokoll, das selbst Fehler wirft, ist schlimmer als keins.
}
}
/**
* Wurde genau dieser Fehler gerade eben schon protokolliert?
*
* Schluessel ist Datei + Zeile + Meldung, Sperrfrist 60 Sekunden. Die Merkliste liegt
* neben dem Log und wird nur im Fehlerfall angefasst -- im Normalbetrieb kostet diese
* Datei nichts.
*/
function intelectra_fatal_log_schon_gesehen($fehler) {
$merkdatei = __DIR__ . '/../logs/.fatal_seen.json';
$schluessel = md5($fehler['file'] . ':' . $fehler['line'] . ':' . $fehler['message']);
$jetzt = time();
$gesehen = array();
if (is_readable($merkdatei)) {
$roh = @file_get_contents($merkdatei);
if ($roh) {
$dekodiert = @json_decode($roh, true);
if (is_array($dekodiert)) {
$gesehen = $dekodiert;
}
}
}
if (isset($gesehen[$schluessel]) && ($jetzt - (int)$gesehen[$schluessel]) < 60) {
return true;
}
// Abgelaufene Eintraege raus, damit die Datei nicht waechst.
foreach ($gesehen as $k => $zeitpunkt) {
if (($jetzt - (int)$zeitpunkt) > 3600) {
unset($gesehen[$k]);
}
}
$gesehen[$schluessel] = $jetzt;
if (count($gesehen) > 200) {
$gesehen = array_slice($gesehen, -200, 200, true);
}
@file_put_contents($merkdatei, json_encode($gesehen), LOCK_EX);
return false;
}
}
/* EOF */
+3 -3
View File
@@ -4670,7 +4670,7 @@ private function generateImageFileName($warning) {
$obj->tax_rate = $this->tax_rates[$obj->tax_id]->steuersatz;
$obj->manufacturer_name = '';
if (isset($this->manufacturers[$obj->manufacturer_id])) {
$obj->manufacturer_name = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_name = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
}
$obj->structure_name = '';
if (isset($this->structure[$obj->structure_id])) {
@@ -4837,7 +4837,7 @@ private function generateImageFileName($warning) {
$obj->tax = $this->all_tax[$obj->tax_id]->steuersatz;
$obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.');
$obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
if ($show_taxes) {
@@ -5401,7 +5401,7 @@ private function generateImageFileName($warning) {
$obj->tax = $this->all_tax[$obj->tax_id]->steuersatz;
$obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.');
$obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
if ($show_taxes) {
+15
View File
@@ -58,6 +58,15 @@ class layout extends Smarty {
header('content-type: text/html; charset=utf-8');
$this->__construct();
// Smarty 4.5+ verlangt explizite Plugin-Registrierung für PHP-Funktionen als Modifier.
// Legacy-Templates nutzen u.a. |stristr — hier zentral registrieren statt überall ersetzen.
foreach (['stristr', 'strstr', 'str_replace', 'preg_replace', 'urlencode', 'urldecode', 'trim'] as $fn) {
if (function_exists($fn)) {
$this->registerPlugin('modifier', $fn, $fn);
}
}
$this->compile_id = SHOP_SYSTEM.'_'.$base_object->config->shopConfiguration['THEME'];
$this->compile_dir = ROOT_DIR.'/tmp/smarty_compile';
$this->cache_dir = ROOT_DIR.'/tmp/smarty_cache';
@@ -65,6 +74,12 @@ class layout extends Smarty {
$tpl_paths = array();
$tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/'.$base_object->config->shopConfiguration['THEME'].'/templates';
// Mobile-Theme: theme2 als Fallback vor dem Parent — Mobile überschreibt nur
// die Schlüssel-Templates, alles andere kommt aus theme2 bzw. easyshop_advanced.
if ($base_object->config->shopConfiguration['THEME'] === 'mobile') {
$tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/theme2/templates';
}
if ($theme->parent_theme) {
$tpl_paths[] = ROOT_DIR.'/themes/'.$theme->parent_theme.'/templates';
}
+1 -1
View File
@@ -381,7 +381,7 @@ class Manufacturer extends Main {
if ($result->num_rows > 0) {
while ($obj = $result->fetch_object()) {
$return[$obj->manufacturer_id] = $manufacturers[$obj->manufacturer_id];
$return[$obj->manufacturer_id] = isset($manufacturers[$obj->manufacturer_id]) ? $manufacturers[$obj->manufacturer_id] : null;
}
asort($return);
return $return;
@@ -15,6 +15,10 @@ include_once('core/paypal/nvp.class.php');
include_once('core/paypal/expresscheckout.class.php');
include_once('core/cart_session_manager.class.php');
// 25.08.2026: Ereignisprotokoll in PG -- liefert die Uhrzeiten, die `orders` nicht hat.
// Faellt still aus, wenn Postgres nicht erreichbar ist (siehe shop_events.php).
include_once($_SERVER['DOCUMENT_ROOT'].'/core/postgres/shop_events.php');
class PaypalPaymentMethod {
@@ -54,8 +58,13 @@ class PaypalPaymentMethod {
$this->gift_certificate = false;
$this->order_id = false;
$this->return_url = 'http://'.$_SERVER['SERVER_NAME'].'/Kasse/';
$this->cancel_url = $this->return_url.'&cancel=true';
// 24.08.2026: https statt http -- die alte URL schickte jeden PayPal-Rueckkehrer
// erst durch eine 301-Umleitung, mit dem Token im Klartext.
// Und "?cancel=true" statt "&cancel=true": ohne "?" war der Query-String kaputt,
// $_GET['cancel'] wurde nie gesetzt und der Cancel-Zweig in change_order() lief
// ins Leere -- wer bei PayPal abgebrochen hat, landete auf einer toten URL.
$this->return_url = 'https://'.$_SERVER['SERVER_NAME'].'/Kasse/';
$this->cancel_url = $this->return_url.'?cancel=true';
} // end __construct
@@ -131,6 +140,41 @@ class PaypalPaymentMethod {
// Continue anyway - fallback to original session handling
}
// 24.08.2026 -- Post/Redirect/Get.
// Bis hierher kam der Kunde per POST auf /Kasse/. Wuerde jetzt direkt das
// Template mit dem PayPal-Link folgen, bliebe dieser POST im Browser stehen:
// F5 oder "Zurueck" schicken ihn erneut ab und legen eine zweite Bestellung an.
// Genau das war der Weg in "ORDER PREVENTED: Empty cart" (der Warenkorb war zu
// dem Zeitpunkt schon geloescht). Deshalb 302 auf eine reine GET-Seite --
// ein Reload dort laedt nur noch den Link neu.
//
// Der pending-Eintrag ist der zweite Riegel: kommt trotzdem noch einmal ein
// ready2fly-POST mit demselben Warenkorb, greift website_cashdesk.php darauf
// zurueck, statt eine weitere Order anzulegen.
$_SESSION['easyshop']['paypal_pending'] = array(
'token' => $token,
'order_id' => $this->order_id,
'total' => round((float)$order_total, 2),
'time' => time()
);
if (function_exists('shop_event')) {
shop_event('payment_started', array(
'order_id' => $this->order_id,
'payment_method' => 4,
'betrag' => $order_total,
'paypal_token' => $token,
'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null
));
}
if (!headers_sent()) {
header('Location: /Kasse/?pp_open='.urlencode($token));
exit;
}
// Fallback: wurde wider Erwarten schon Output gesendet, bleibt es beim
// bisherigen Verhalten -- Template mit Link, ohne PRG.
$call = 'payment_open';
} else {
$message = 'Beim Initialisieren von PayPal ist ein Fehler aufgetreten.';
@@ -163,12 +207,57 @@ class PaypalPaymentMethod {
$order = new Order($this->base_object);
// 24.08.2026 -- Riegel VOR allen drei Zweigen: ist diese Bestellung schon bezahlt?
//
// PayPal-Tokens sind Einwegtickets. Ruft jemand die Rueckkehr-Adresse ein zweites
// Mal auf -- Zurueck-Taste, Reload, zweiter Tab -- scheitert jede weitere Nachfrage
// bei PayPal zwangslaeufig. Der Code stempelte die Bestellung daraufhin auf
// "abandoned" und zeigte eine Fehlerseite. Einem Kunden, der laengst bezahlt hat.
//
// Belegt am Testkauf 24.08.2026 (Order 111902, Token EC-2G607902TV9783051):
// 23:26:45 Updated ... to completed <- Geld eingezogen
// 23:26:56 Updated ... to abandoned <- Reload der Rueckkehr-Adresse
// 23:27:01 Updated ... to abandoned
// Dasselbe Muster am selben Tag bei einem echten Kunden (EC-6PP77504DT5715642).
// Ein Teil der vermeintlichen "Abbrueche" in der Statistik sind also bezahlte
// Bestellungen mit Fehlerseite.
//
// Deckt auch den cancel-Zweig ab: eine bezahlte Bestellung darf nicht nachtraeglich
// auf "storniert" gesetzt werden, nur weil jemand auf einem alten Tab abbricht.
$bezahlt_token = isset($_GET['token']) ? $_GET['token'] : '';
if ($bezahlt_token) {
$bezahlte_order = $order->get_order_by_paypal_token($bezahlt_token);
if (is_object($bezahlte_order) && !empty($bezahlte_order->paypal_transaction_id)) {
error_log("PayPal Fix: Rueckkehr auf bereits bezahlte Order "
. $bezahlte_order->id . " (Token {$bezahlt_token}) -- zeige Bestaetigung, "
. "kein erneuter PayPal-Aufruf");
// Status geraderuecken, falls ihn ein frueherer Fehlversuch verbogen hat.
$this->cart_session_manager->updateCartStatus($bezahlt_token, 'completed');
unset($_SESSION['easyshop']['paypal_pending']);
$this->layout_object->assign('order_number', $bezahlte_order->order_number);
$this->layout_object->assign('customer_number', $bezahlte_order->customer_number);
$this->layout_object->assign('call', 'payment_confirmed');
return $this->layout_object->_fetch('content_cash_desk/paypal.tpl');
}
}
if (isset($_GET['confirm'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerId'];
$order_data = $order->get_order_by_paypal_token($token);
// 24.08.2026: Session-ID aus dem Moment VOR dem PayPal-Sprung. Der Warenkorb
// liegt in shopping_cart_items an der session_id -- kommt der Kunde mit einer
// neuen Session zurueck (iOS/Safari verliert das Cookie gern), zeigt
// session_id() ins Leere und der alte Warenkorb bliebe stehen.
$orig_session_id = '';
// PAYPAL FIX: Restore cart session data after PayPal return
$cart_session = $this->cart_session_manager->retrieveCartSession($token);
if ($cart_session && $cart_session['cart_data']) {
@@ -184,6 +273,7 @@ class PaypalPaymentMethod {
$this->shipping_method = $cart_data['shipping_method'] ?? $this->shipping_method;
$this->gift_certificate = $cart_data['gift_certificate'] ?? $this->gift_certificate;
$this->order_id = $cart_data['order_id'] ?? $this->order_id;
$orig_session_id = $cart_data['php_session_id'] ?? '';
// Debug log
error_log("PayPal Fix: Restored order_id: {$this->order_id}, cart items: " . count($this->shoppingcart ?? []));
@@ -209,6 +299,34 @@ class PaypalPaymentMethod {
// PAYPAL FIX: Mark cart session as completed
$this->cart_session_manager->updateCartStatus($token, 'completed');
// 24.08.2026: JETZT erst den Warenkorb leeren -- Geld ist eingezogen.
// Vorher passierte das in website_cashdesk::after_saving_order(), also
// bevor PayPal ueberhaupt gefragt war. Wer dort abbrach oder neu lud,
// stand ohne Warenkorb da und musste alles neu zusammensuchen; genau
// daher stammen die Bestellpaare mit identischem Betrag.
$this->clear_cart_after_payment($orig_session_id);
unset($_SESSION['easyshop']['paypal_pending']);
// 24.08.2026: Bestellbestaetigung JETZT ausloesen, nicht schon bei der
// Order-Anlage. Vorher ging sie in website_cashdesk::save_order() raus --
// also bevor der Kunde ueberhaupt bei PayPal war. Beim Testkauf lagen
// zwischen Mail (23:25:09) und eingezogener Zahlung (23:26:45) anderthalb
// Minuten, und wer nie bezahlt hat, bekam trotzdem eine Bestaetigung.
$this->bestellbestaetigung_senden($order_data);
if (function_exists('shop_event')) {
shop_event('paid', array(
'order_id' => $order_data->id,
'order_number' => $order_data->order_number,
'payment_method' => 4,
'betrag' => $order->get_order_total_by_id($order_data->id),
'paypal_token' => $token,
'transaction_id' => isset($paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'])
? $paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'] : null,
'customer_id' => $order_data->customer_id
));
}
$call = 'payment_confirmed';
} else {
$this->layout_object->assign('message', 'Beim Bezahlabschluss ist ein Fehler aufgetreten. ');
@@ -216,6 +334,16 @@ class PaypalPaymentMethod {
// PAYPAL FIX: Mark cart session as abandoned
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
if (function_exists('shop_event')) {
shop_event('failed', array(
'order_id' => isset($order_data->id) ? $order_data->id : null,
'order_number' => isset($order_data->order_number) ? $order_data->order_number : null,
'payment_method' => 4,
'paypal_token' => $token,
'detail' => 'DoExpressCheckoutPayment abgelehnt'
));
}
$call = 'payment_error';
}
} else if (isset($_GET['cancel'])) {
@@ -224,10 +352,31 @@ class PaypalPaymentMethod {
// PAYPAL FIX: Mark cart session as abandoned on cancel
if ($token) {
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
// 24.08.2026: $order_data war in diesem Zweig nie gesetzt -- der Aufruf
// unten lief auf ein undefiniertes Objekt und setzte im Zweifel Order 0
// auf "storniert". Die Order gehoert ueber den Token geholt.
$order_data = $order->get_order_by_paypal_token($token);
if (isset($order_data->id) && (int)$order_data->id > 0) {
$order->set_order_status('6', $order_data->id);
}
}
// Der Warenkorb bleibt jetzt stehen (er wird erst nach erfolgreicher Zahlung
// geleert) -- der Kunde kann direkt eine andere Zahlart waehlen, statt alles
// neu zusammenzusuchen.
unset($_SESSION['easyshop']['paypal_pending']);
if (function_exists('shop_event')) {
shop_event('cancelled', array(
'order_id' => isset($order_data->id) ? $order_data->id : null,
'payment_method' => 4,
'paypal_token' => $token,
'detail' => 'Kunde hat bei PayPal abgebrochen'
));
}
$call = 'payment_cancelled';
$order->set_order_status('6', $order_data->id ?? 0);
} else if (isset($_GET['token'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerID'];
@@ -267,6 +416,79 @@ class PaypalPaymentMethod {
} // end change_order
/**
* Loest die Bestellbestaetigung aus -- erst nach eingezogener Zahlung (24.08.2026).
*
* Der Versand haengt am Ticket-Event 3 (Cs_ticket::event), das bisher in
* website_cashdesk::save_order() direkt nach der Order-Anlage lief. Bei PayPal war das
* zu frueh: die Bestellung existiert dort schon, bevor der Kunde zu PayPal geschickt
* wird. Jeder Abbrecher bekam eine Bestellbestaetigung fuer etwas, das er nie bezahlt
* hat. Bei den zuletzt gemessenen Zahlen sind das rund ein Dutzend falsche
* Bestaetigungen pro Woche.
*
* Fuer alle anderen Zahlarten bleibt es, wo es war -- dort ist die Bestellung mit dem
* Abschicken tatsaechlich verbindlich.
*
* Wirft der Versand einen Fehler, wird er protokolliert und geschluckt: die Zahlung ist
* an dieser Stelle bereits eingezogen, eine Ausnahme wuerde dem Kunden eine Fehlerseite
* fuer eine erfolgreiche Bestellung zeigen.
*
* @param object $order_data Datensatz aus Order::get_order_by_paypal_token()
*/
private function bestellbestaetigung_senden($order_data) {
if (!is_object($order_data) || empty($order_data->id)) {
error_log('PayPal Fix: Bestellbestaetigung uebersprungen -- keine Order-Daten');
return;
}
try {
include_once './core/cs_ticket.class.php';
$ticket_object = new Cs_ticket($this->base_object);
$ticket_object->event(3, $order_data->customer_id, $order_data->id);
error_log('PayPal Fix: Bestellbestaetigung ausgeloest fuer Order '.$order_data->id);
} catch (Throwable $e) {
error_log('PayPal Fix: Bestellbestaetigung fuer Order '.$order_data->id
. ' fehlgeschlagen: '.$e->getMessage());
}
} // end bestellbestaetigung_senden
/**
* Leert den Warenkorb -- erst nach eingezogener Zahlung (24.08.2026).
*
* Zwei Session-IDs, weil der Kunde mit einer anderen zurueckkommen kann als er
* gegangen ist (iOS/Safari verliert das Session-Cookie beim Sprung ueber PayPal
* gern): die aktuelle und die, die beim Weggang in cart_sessions mitgeschrieben
* wurde. Was trotzdem liegenbleibt, faengt der DATEDIFF > 2 in
* Shoppingcart::clear() ein.
*
* @param string $orig_session_id session_id() aus dem Moment vor dem PayPal-Sprung
*/
private function clear_cart_after_payment($orig_session_id = '') {
$db = $this->base_object->db;
$ids = array();
$current = session_id();
if ($current) {
$ids[$current] = true;
}
if ($orig_session_id) {
$ids[$orig_session_id] = true;
}
foreach (array_keys($ids) as $sid) {
$db->query("
DELETE FROM
shopping_cart_items
WHERE
session_id = '".$db->real_escape_string($sid)."'
");
}
} // end clear_cart_after_payment
} // end PaypalPaymentMethod
/* EOF */
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* Zahlungsart "PayPal Checkout (Orders v2 / REST)".
*
* Neues, eigenstaendiges Modul – laeuft parallel zum Legacy-NVP-Modul
* (PaypalPaymentMethod) und teilt sich mit ihm KEINEN Code.
*
* Aktivierung ueber einen eigenen Eintrag in `payment_methods` mit
* module = 'paypalrest'. Der Cashdesk instanziiert dann automatisch diese
* Klasse (ucfirst('paypalrest').'PaymentMethod' => PaypalrestPaymentMethod).
*
* Flow: after_order() legt die PayPal-Order an und leitet den Kunden zur
* Approve-URL weiter. Der Rueckweg laeuft NICHT ueber den Cashdesk (der ist auf
* das Legacy-Modul verdrahtet), sondern ueber /core/paypal_v2/return.php.
*/
require_once __DIR__ . '/../paypal_v2/Config.php';
require_once __DIR__ . '/../paypal_v2/PayPalClient.php';
require_once __DIR__ . '/../paypal_v2/Orders.php';
require_once __DIR__ . '/../paypal_v2/Gateway.php';
class PaypalrestPaymentMethod
{
private $base_object;
private $layout_object;
private $customer_data;
private $shipping_address;
private $billing_address;
private $shoppingcart;
private $payment_method;
private $shipping_method;
private $gift_certificate;
private $order_id;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
$this->customer_data = false;
$this->shipping_address = false;
$this->billing_address = false;
$this->shoppingcart = false;
$this->payment_method = false;
$this->shipping_method = false;
$this->gift_certificate = false;
$this->order_id = false;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->shipping_address = $shipping;
$this->billing_address = $billing;
$this->shoppingcart = $shoppingcart;
$this->payment_method = $delivery;
$this->shipping_method = $payment;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vom Cashdesk vor dem Speichern aufgerufen. Kein Fehler => leere Rueckgabe. */
public function before_order($argument = null)
{
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern des Auftrags: PayPal-Order anlegen und weiterleiten.
*
* @param float|false $order_total Bruttosumme des Auftrags
* @return string HTML-Fallback (nur, wenn keine Weiterleitung moeglich war)
*/
public function after_order($order_total = false)
{
$charged = (float) $order_total;
// Gutschein wie im Legacy-Modul vom zu zahlenden Betrag abziehen
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$charged -= (float) $this->gift_certificate->with_vat;
}
try {
$gateway = new \PaypalV2\Gateway($this->base_object);
$order_number = $this->resolve_order_number((int) $this->order_id);
$approve_url = $gateway->createOrderAndGetApprovalUrl((int) $this->order_id, $charged, $order_number);
} catch (\Throwable $e) {
\error_log('PayPal v2 after_order: ' . $e->getMessage());
return $this->render_error('Beim Initialisieren von PayPal ist ein Fehler aufgetreten.');
}
// Bevorzugt harte Weiterleitung (an dieser Stelle sind noch keine Header gesendet).
if (!\headers_sent()) {
\header('Location: ' . $approve_url);
exit;
}
// Fallback: Button/Meta-Refresh im Template
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('paypal_link', $approve_url);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
/** Wird vom Cashdesk fuer dieses Modul NICHT aufgerufen (Return laeuft ueber return.php). */
public function change_order()
{
return '';
}
// ---------------------------------------------------------------- intern
private function resolve_order_number(int $order_id): string
{
$db = $this->base_object->db;
$res = $db->query('SELECT order_number FROM orders WHERE id=' . $order_id . ' LIMIT 1');
if ($res && $res->num_rows > 0) {
return (string) $res->fetch_object()->order_number;
}
return (string) $order_id;
}
private function render_error(string $message): string
{
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('message', $message);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
}
+6 -2
View File
@@ -47,8 +47,12 @@ class ExpressCheckout extends NVP {
// ."&L_PAYMENTREQUEST_0_NAME0=Ihre+Bestellung"
// ."&L_PAYMENTREQUEST_0_QTY0=1"
// ."&L_PAYMENTREQUEST_0_AMT0=".$this->order_toal
."&RETURNURL=".$this->return_url
."&CANCELURL=".$this->cancel_url
// 24.08.2026: urlencode ergaenzt. Die NVP-Postfields sind ein
// key=value&key=value-String -- eine URL mit "?cancel=true" darin wuerde ihn
// zerlegen. Ohne das laesst sich die kaputte CANCELURL (bis heute
// ".../Kasse/&cancel=true") nicht reparieren.
."&RETURNURL=".urlencode($this->return_url)
."&CANCELURL=".urlencode($this->cancel_url)
."&LOCALECODE=".'DE'
."&PAYMENTREQUEST_0_CURRENCYCODE=".$this->currency
."&NOSHIPPING="."1"
+14
View File
@@ -0,0 +1,14 @@
# Nur die beiden Endpunkte duerfen direkt aufgerufen werden.
# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt.
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
<FilesMatch "^(return|webhook)\.php$">
Require all granted
</FilesMatch>
# Token-Cache o.ae. niemals ausliefern
<FilesMatch "^\.">
Require all denied
</FilesMatch>
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Laedt die geschuetzte PayPal-v2-Konfiguration von ausserhalb des Webroots.
*/
final class Config
{
/** Kandidaten-Pfade fuer die Config (erster lesbarer gewinnt). */
private const PATHS = [
'/var/www/vhosts/intelectra.de/import/paypal_v2/config.php',
];
private static ?array $data = null;
private static ?string $resolvedPath = null;
public static function load(): array
{
if (self::$data !== null) {
return self::$data;
}
foreach (self::PATHS as $path) {
if (is_file($path) && is_readable($path)) {
$cfg = include $path;
if (!is_array($cfg)) {
throw new \RuntimeException('PayPal v2: Config liefert kein Array: ' . $path);
}
self::$data = $cfg;
self::$resolvedPath = $path;
return self::$data;
}
}
throw new \RuntimeException('PayPal v2: Keine lesbare Config-Datei gefunden.');
}
public static function configPath(): string
{
self::load();
return (string) self::$resolvedPath;
}
/** Verzeichnis der Config – dient auch als Ablage fuer den Token-Cache. */
public static function dir(): string
{
return \dirname(self::configPath());
}
public static function mode(): string
{
$cfg = self::load();
return (($cfg['mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox';
}
/** @return array{client_id:string,secret:string,webhook_id:string} */
public static function creds(): array
{
$cfg = self::load();
$mode = self::mode();
$c = $cfg[$mode] ?? [];
$clientId = (string) ($c['client_id'] ?? '');
$secret = (string) ($c['secret'] ?? '');
if ($clientId === '' || $secret === '' || \str_starts_with($clientId, 'HIER_')) {
throw new \RuntimeException('PayPal v2: Zugangsdaten fuer Modus "' . $mode . '" fehlen oder sind Platzhalter.');
}
return [
'client_id' => $clientId,
'secret' => $secret,
'webhook_id' => (string) ($c['webhook_id'] ?? ''),
];
}
public static function apiBase(): string
{
return self::mode() === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
}
public static function webhookId(): string
{
$cfg = self::load();
$mode = self::mode();
return (string) ($cfg[$mode]['webhook_id'] ?? '');
}
/** @return mixed */
public static function get(string $key, $default = null)
{
$cfg = self::load();
return $cfg[$key] ?? $default;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Bindeglied zwischen PayPal-Orders-v2 und dem Shop-Datenmodell.
*
* Zentralisiert:
* - Order-Anlage + Ablage der PayPal-Order-ID (in orders.paypal_token wiederverwendet)
* - Capture inkl. Betrags-/Waehrungsabgleich (Manipulationsschutz)
* - idempotentes Setzen des Zahlstatus (F5, Doppel-Return, Webhook-Race)
*
* Erwartet das Shop-"base"-Objekt ($base->db = mysqli).
*/
final class Gateway
{
/** @var object Shop-base-Objekt mit ->db (mysqli) */
private object $base;
public function __construct(object $base)
{
$this->base = $base;
}
/**
* Legt eine PayPal-Order an, speichert die Order-ID am Shop-Auftrag und
* gibt die Approve-URL zurueck, zu der der Kunde weitergeleitet wird.
*/
public function createOrderAndGetApprovalUrl(int $shopOrderId, float $charged, string $orderNumber): string
{
$shopBase = \rtrim((string) Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
$returnUrl = $shopBase . '/core/paypal_v2/return.php';
$cancelUrl = $shopBase . '/core/paypal_v2/return.php?cancel=1';
$orders = new Orders();
$res = $orders->create($charged, $orderNumber, (string) $shopOrderId, $returnUrl, $cancelUrl);
// PayPal-Order-ID am Auftrag ablegen (vorhandene Spalte wiederverwendet)
$order = new \Order($this->base);
$order->set_order_paypal_token($res['id'], $shopOrderId);
// Exakt berechneten Betrag fuer den spaeteren Abgleich sichern (best effort;
// funktioniert nur, wenn die Spalte paypal_v2_expected_amount existiert).
$this->storeExpectedAmount($shopOrderId, $charged);
return $res['approve_url'];
}
/**
* Schliesst die Zahlung anhand der PayPal-Order-ID ab (Return-Flow).
*
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
public function finalizeByPaypalOrderId(string $paypalOrderId): array
{
$shopOrder = $this->findOrderByToken($paypalOrderId);
if ($shopOrder === null) {
return $this->result(false, '', 0, 'Bestellung zu dieser Zahlung nicht gefunden.');
}
$orderId = (int) $shopOrder->id;
// Bereits bezahlt? -> idempotent OK
if ((string) $shopOrder->payment_status === '2') {
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'bereits bezahlt');
}
$cap = (new Orders())->capture($paypalOrderId);
if ($cap['capture_status'] !== 'COMPLETED') {
return $this->result(
false,
(string) $shopOrder->order_number,
$orderId,
'Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ').'
);
}
if (!$this->amountMatches($shopOrder, $cap['amount'], $cap['currency'], $orderId, $paypalOrderId)) {
return $this->result(false, (string) $shopOrder->order_number, $orderId, 'Betrag stimmt nicht ueberein.');
}
$this->markPaid($orderId, $cap);
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'ok');
}
/**
* Bestaetigt eine Zahlung aus einem verifizierten Webhook-Event
* (PAYMENT.CAPTURE.COMPLETED). Idempotent.
*
* @param array<mixed> $resource Das "resource"-Objekt des Webhook-Events (die Capture)
*/
public function confirmFromWebhookCapture(array $resource): bool
{
$shopOrderId = (int) ($resource['custom_id'] ?? 0);
if ($shopOrderId <= 0) {
return false;
}
$shopOrder = $this->findOrderById($shopOrderId);
if ($shopOrder === null) {
return false;
}
if ((string) $shopOrder->payment_status === '2') {
return true; // schon bezahlt
}
if (($resource['status'] ?? '') !== 'COMPLETED') {
return false;
}
$amount = (string) ($resource['amount']['value'] ?? '');
$currency = (string) ($resource['amount']['currency_code'] ?? '');
if (!$this->amountMatches($shopOrder, $amount, $currency, $shopOrderId, 'webhook')) {
return false;
}
$this->markPaid($shopOrderId, [
'capture_id' => (string) ($resource['id'] ?? ''),
'payer_email' => '',
'payer_id' => '',
]);
return true;
}
// ----------------------------------------------------------------- intern
/** @param array{capture_id?:string,payer_email?:string,payer_id?:string} $cap */
private function markPaid(int $orderId, array $cap): void
{
$db = $this->base->db;
$order = new \Order($this->base);
// payment_status = 2 (bezahlt) – identisch zur Legacy-Semantik
$order->set_billing_status('2', $orderId);
$order->set_payment_date(\date('Y-m-d'), $orderId);
$capId = $db->real_escape_string((string) ($cap['capture_id'] ?? ''));
$email = $db->real_escape_string((string) ($cap['payer_email'] ?? ''));
$payer = $db->real_escape_string((string) ($cap['payer_id'] ?? ''));
$sql = "UPDATE orders SET paypal_transaction_id='$capId'";
if ($email !== '') {
$sql .= ", paypal_email='$email'";
}
if ($payer !== '') {
$sql .= ", paypal_payerid='$payer'";
}
$sql .= ' WHERE id=' . $orderId;
$db->query($sql);
}
/**
* Prueft, ob der gezahlte Betrag zum erwarteten Betrag des Auftrags passt.
* Erwartet wird primaer paypal_v2_expected_amount, ersatzweise orders.order_total.
*/
private function amountMatches(object $shopOrder, string $paidAmount, string $paidCurrency, int $orderId, string $ref): bool
{
$expectedRaw = $shopOrder->paypal_v2_expected_amount ?? null;
if ($expectedRaw === null || $expectedRaw === '') {
$expectedRaw = $shopOrder->order_total;
}
$expected = \number_format((float) $expectedRaw, 2, '.', '');
$paid = \number_format((float) $paidAmount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$ok = ($paid === $expected) && (\strtoupper($paidCurrency) === \strtoupper($currency));
if (!$ok) {
\error_log(
"PayPal v2 BETRAGS-MISMATCH (order $orderId, ref $ref): "
. "erwartet $expected $currency, erhalten $paid $paidCurrency"
);
}
return $ok;
}
private function findOrderByToken(string $paypalOrderId): ?object
{
$db = $this->base->db;
$token = $db->real_escape_string($paypalOrderId);
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE paypal_token = '$token' LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
private function findOrderById(int $orderId): ?object
{
$db = $this->base->db;
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE id = $orderId LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
/**
* Liefert das SELECT-Fragment fuer den erwarteten Betrag. Existiert die
* optionale Spalte nicht, wird NULL selektiert (Fallback auf order_total).
*/
private function expectedAmountSelect(): string
{
return $this->columnExists('paypal_v2_expected_amount')
? 'paypal_v2_expected_amount'
: 'NULL AS paypal_v2_expected_amount';
}
private function storeExpectedAmount(int $orderId, float $charged): void
{
if (!$this->columnExists('paypal_v2_expected_amount')) {
return;
}
$db = $this->base->db;
$val = \number_format($charged, 2, '.', '');
$db->query("UPDATE orders SET paypal_v2_expected_amount='$val' WHERE id=$orderId");
}
/** @var array<string,bool> */
private static array $colCache = [];
private function columnExists(string $column): bool
{
if (isset(self::$colCache[$column])) {
return self::$colCache[$column];
}
$db = $this->base->db;
$col = $db->real_escape_string($column);
$res = $db->query("SHOW COLUMNS FROM orders LIKE '$col'");
return self::$colCache[$column] = ($res && $res->num_rows > 0);
}
/**
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
private function result(bool $ok, string $orderNumber, int $orderId, string $message): array
{
return ['ok' => $ok, 'order_number' => $orderNumber, 'order_id' => $orderId, 'message' => $message];
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Kapselt die Orders-v2-Endpunkte: create / get / capture.
*/
final class Orders
{
private PayPalClient $client;
public function __construct(?PayPalClient $client = null)
{
$this->client = $client ?? new PayPalClient();
}
/**
* Erstellt eine PayPal-Order (intent=CAPTURE) und liefert Order-ID + Approve-URL.
*
* @return array{id:string,approve_url:string}
*/
public function create(
float $amount,
string $orderNumber,
string $shopOrderId,
string $returnUrl,
string $cancelUrl
): array {
$value = \number_format($amount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => $currency,
'value' => $value,
],
'custom_id' => $shopOrderId,
'invoice_id' => $orderNumber . '-' . $shopOrderId,
]],
'application_context' => [
'brand_name' => (string) Config::get('brand_name', 'Shop'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
'return_url' => $returnUrl,
'cancel_url' => $cancelUrl,
],
];
// Idempotenz: gleicher Request-Id => PayPal legt keine Doppel-Order an.
$requestId = 'create-' . $shopOrderId . '-' . \substr(\hash('sha256', $orderNumber . '|' . $value), 0, 16);
[$status, $body] = $this->client->request('POST', '/v2/checkout/orders', $payload, [
'PayPal-Request-Id: ' . $requestId,
]);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal v2: Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
$rel = $link['rel'] ?? '';
if ($rel === 'approve' || $rel === 'payer-action') {
$approve = (string) ($link['href'] ?? '');
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal v2: Kein Approve-Link in der Antwort.');
}
return ['id' => (string) $body['id'], 'approve_url' => $approve];
}
/** @return array<mixed> */
public function get(string $paypalOrderId): array
{
[$status, $body] = $this->client->request('GET', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId));
if ($status !== 200) {
throw new \RuntimeException('PayPal v2: Order-Abruf fehlgeschlagen (HTTP ' . $status . ').');
}
return $body;
}
/**
* Captured eine vom Kunden freigegebene Order. Idempotent:
* Ist bereits gecaptured (Doppel-Return / Webhook-Race), wird der bestehende
* Zustand aus der Order gelesen statt einen Fehler zu werfen.
*
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public function capture(string $paypalOrderId): array
{
[$status, $body] = $this->client->request(
'POST',
'/v2/checkout/orders/' . \rawurlencode($paypalOrderId) . '/capture',
null,
[
'PayPal-Request-Id: capture-' . $paypalOrderId,
'Prefer: return=representation',
]
);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
return $this->normalize($this->get($paypalOrderId));
}
throw new \RuntimeException('PayPal v2: Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
/**
* Vereinheitlicht Capture- bzw. Order-Antwort auf die Felder, die der Shop braucht.
*
* @param array<mixed> $body
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public function normalize(array $body): array
{
$cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
return [
'order_status' => (string) ($body['status'] ?? ''),
'capture_id' => (string) ($cap['id'] ?? ''),
'capture_status' => (string) ($cap['status'] ?? ''),
'amount' => (string) ($cap['amount']['value'] ?? ''),
'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
'raw' => $body,
];
}
/** @param array<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
*
* Sicherheit:
* - TLS-Verifikation ist ERZWUNGEN (kein CURLOPT_SSL_VERIFYPEER=false wie im Legacy-NVP).
* - Access-Token wird ausserhalb des Webroots zwischengespeichert (chmod 600).
*/
final class PayPalClient
{
private string $base;
public function __construct()
{
$this->base = Config::apiBase();
}
/** OAuth2 client_credentials Access-Token, gecacht bis kurz vor Ablauf. */
public function accessToken(): string
{
$cacheFile = self::tokenCacheFile();
if (\is_file($cacheFile)) {
$c = \json_decode((string) @\file_get_contents($cacheFile), true);
if (
\is_array($c)
&& ($c['mode'] ?? '') === Config::mode()
&& isset($c['token'], $c['expires'])
&& (int) $c['expires'] > \time() + 60
) {
return (string) $c['token'];
}
}
$creds = Config::creds();
[$status, $body] = $this->raw(
'POST',
'/v1/oauth2/token',
'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'],
$creds['client_id'] . ':' . $creds['secret']
);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal v2: OAuth2 fehlgeschlagen (HTTP ' . $status . ').');
}
@\file_put_contents($cacheFile, \json_encode([
'token' => (string) $data['access_token'],
'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'mode' => Config::mode(),
]), LOCK_EX);
@\chmod($cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* Authentifizierter JSON-Request.
*
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>} [HTTP-Status, dekodierter Body]
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(
[
'Content-Type: application/json',
'Authorization: Bearer ' . $this->accessToken(),
],
$extraHeaders
);
$bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
}
/**
* Roher cURL-Aufruf.
*
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
{
$ch = \curl_init($this->base . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
if ($basicAuth !== null) {
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
}
if ($body !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('PayPal v2: HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
private static function tokenCacheFile(): string
{
return Config::dir() . '/.token_cache.json';
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Verifiziert PayPal-Webhook-Signaturen serverseitig gegen die PayPal-API.
* Nur so ist ein eingehendes Event vertrauenswuerdig.
*/
final class Webhook
{
/**
* @param array<string,string> $headers Request-Header (Schluessel lowercase)
* @param string $rawBody Roher Request-Body (unveraendert!)
*/
public function verify(array $headers, string $rawBody): bool
{
$webhookId = Config::webhookId();
if ($webhookId === '') {
\error_log('PayPal v2 Webhook: keine webhook_id konfiguriert – Event abgelehnt.');
return false;
}
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return false;
}
$payload = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
'webhook_event' => $event,
];
foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
if ($payload[$k] === '') {
return false;
}
}
[$status, $body] = (new PayPalClient())->request(
'POST',
'/v1/notifications/verify-webhook-signature',
$payload
);
return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Standalone-Bootstrap fuer die PayPal-v2-Endpunkte (return.php, webhook.php).
*
* Bindet den Shop-Kern (DB + Config) ein, OHNE den normalen index.php-Router
* und ohne das Legacy-PayPal-Modul zu beruehren.
*/
declare(strict_types=1);
// Fehler nicht an den Browser ausgeben (Produktivbetrieb)
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
// Leeres/falsches DOCUMENT_ROOT (z. B. CLI) -> aus dem Dateipfad ableiten
$docroot = \dirname(__DIR__, 2);
}
// base.class.php nutzt relative Includes (./config.inc.php etc.) -> ins Docroot wechseln
\chdir($docroot);
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/PayPalClient.php';
require_once __DIR__ . '/Orders.php';
require_once __DIR__ . '/Webhook.php';
require_once __DIR__ . '/Gateway.php';
/** @var object $base_object Shop-base mit ->db (mysqli) und ->config */
$base_object = new base();
Registry::set('base', $base_object);
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================================
-- PayPal Orders v2 (REST) – Aktivierung
-- Neues Modul, parallel zum Legacy-NVP-PayPal (payment_methods.id = 4).
--
-- In Schritten ausfuehren – nichts wird an der Legacy-Zahlart geaendert,
-- bis DU es in SCHRITT 4 bewusst tust.
-- VORHER: DB-Dump ziehen.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- SCHRITT 1 – Schema: Spalte fuer den Betragsabgleich (Manipulationsschutz).
-- Additiv, nullable, keine Datenaenderung an bestehenden Auftraegen.
-- ----------------------------------------------------------------------------
ALTER TABLE `orders`
ADD COLUMN `paypal_v2_expected_amount` DECIMAL(10,2) NULL DEFAULT NULL;
-- ----------------------------------------------------------------------------
-- SCHRITT 2 – Zahlart anlegen (klont die Legacy-PayPal-Zeile id=4).
-- helperjoinkey='pp' => identischer PayPal-Aufschlag (shipping_area.add_pp).
-- active=1, ABER noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- ----------------------------------------------------------------------------
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`,
`invoice_text`, `order_text`, `order_email_text`,
`helperjoinkey`, `configuration_group_id`)
VALUES
('PayPal', 'paypalrest', '', 1, 2,
'Bezahlung erfolgte per PayPal',
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
'Der zu zahlende Betrag wird per PayPal ueberwiesen.',
'pp', 'PayPalRest');
-- Neue ID merken (fuer die folgenden Schritte):
-- SELECT id FROM payment_methods WHERE module='paypalrest';
-- ----------------------------------------------------------------------------
-- SCHRITT 3 – TESTEN (Sandbox): Zahlart NUR fuer eine Testgruppe sichtbar.
-- <NEUE_ID> ersetzen. <TEST_GRUPPE> = Kundengruppe eines Test-Accounts.
-- (Kundengruppen der Legacy-Methode: 1, 101, 103 aktiv; 100 inaktiv.)
-- ----------------------------------------------------------------------------
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (<TEST_GRUPPE>, <NEUE_ID>, 1, 1);
-- ----------------------------------------------------------------------------
-- SCHRITT 4 – GO-LIVE (erst nach erfolgreichem Sandbox-Test,
-- config.php auf mode='live' + Live-Credentials!):
-- a) Neue Methode fuer die echten Gruppen freischalten (wie Legacy):
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (1, <NEUE_ID>, 1, 1),
-- (101, <NEUE_ID>, 1, 1),
-- (103, <NEUE_ID>, 1, 1);
--
-- b) Alte NVP-PayPal-Zahlart (id=4) deaktivieren, damit sie NICHT mehr
-- auswaehlbar ist (Bestellungen/Historie bleiben unberuehrt):
-- UPDATE `payment_methods` SET `active` = 0 WHERE `id` = 4;
-- Alternativ nur die Gruppen-Sichtbarkeit abschalten:
-- UPDATE `customer_group_payment_method_configuration`
-- SET `active` = 0 WHERE `payment_method_id` = 4;
-- ----------------------------------------------------------------------------
-- ROLLBACK (falls noetig): neue Methode wieder verstecken
-- ----------------------------------------------------------------------------
-- DELETE FROM `customer_group_payment_method_configuration`
-- WHERE `payment_method_id` = (SELECT id FROM payment_methods WHERE module='paypalrest');
-- UPDATE `payment_methods` SET `active` = 0 WHERE `module` = 'paypalrest';
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Return-Endpunkt fuer den PayPal-v2-Redirect-Flow.
*
* Ablauf:
* - Erfolg: PayPal leitet mit ?token=<PayPal-Order-ID>&PayerID=... hierher zurueck.
* -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die
* bestehende Shop-Erfolgsseite /Kasse/?success=1&order=<Nr>.
* - Abbruch: PayPal leitet mit ?cancel=1 hierher -> zurueck zur Kasse.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Gateway;
$shopBase = \rtrim((string) \PaypalV2\Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
/** Weiterleitung + sauberes Ende. */
$redirect = static function (string $url): never {
\header('Location: ' . $url);
exit;
};
// --- Abbruch durch den Kunden -------------------------------------------------
if (isset($_GET['cancel'])) {
$redirect($shopBase . '/Kasse/?paypal_cancel=1');
}
$paypalOrderId = isset($_GET['token']) ? (string) $_GET['token'] : '';
if ($paypalOrderId === '') {
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
try {
$gateway = new Gateway($base_object);
$result = $gateway->finalizeByPaypalOrderId($paypalOrderId);
} catch (\Throwable $e) {
\error_log('PayPal v2 return.php: ' . $e->getMessage());
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
if ($result['ok']) {
// Bestehende Erfolgsseite des Shops wiederverwenden (completed.tpl)
$redirect($shopBase . '/Kasse/?success=1&order=' . \rawurlencode($result['order_number']));
}
\error_log('PayPal v2 return.php: Abschluss fehlgeschlagen (order ' . $result['order_id'] . '): ' . $result['message']);
$redirect($shopBase . '/Kasse/?paypal_error=1');
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Webhook-Endpunkt fuer PayPal-v2.
*
* Dient als verlaessliche Quelle des Zahlstatus (server-zu-server), unabhaengig
* davon, ob der Kunde nach der Zahlung tatsaechlich zum Shop zurueckkehrt.
*
* PayPal-Dashboard: Webhook auf diese URL anlegen, mindestens Event
* PAYMENT.CAPTURE.COMPLETED
* abonnieren und die entstehende Webhook-ID in die Config eintragen.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Webhook;
use PaypalV2\Gateway;
$rawBody = (string) \file_get_contents('php://input');
// Header einsammeln (lowercase Keys)
$headers = [];
foreach ($_SERVER as $key => $value) {
if (\strncmp($key, 'HTTP_', 5) === 0) {
$name = \strtolower(\str_replace('_', '-', \substr($key, 5)));
$headers[$name] = (string) $value;
}
}
// Signatur verifizieren – sonst sofort ablehnen
try {
if (!(new Webhook())->verify($headers, $rawBody)) {
\http_response_code(400);
echo 'invalid signature';
exit;
}
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook verify: ' . $e->getMessage());
\http_response_code(500);
exit;
}
$event = \json_decode($rawBody, true);
$type = \is_array($event) ? (string) ($event['event_type'] ?? '') : '';
try {
if ($type === 'PAYMENT.CAPTURE.COMPLETED') {
$resource = $event['resource'] ?? [];
if (\is_array($resource)) {
(new Gateway($base_object))->confirmFromWebhookCapture($resource);
}
}
// Weitere Event-Typen (Refund/Denied) koennen hier ergaenzt werden.
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook handle (' . $type . '): ' . $e->getMessage());
\http_response_code(500);
exit;
}
// PayPal erwartet 2xx, sonst wird das Event erneut zugestellt.
\http_response_code(200);
echo 'ok';
+8
View File
@@ -10,3 +10,11 @@
Deny from all
</FilesMatch>
</IfModule>
# 06.08.2026 geprueft: Ein <FilesMatch "\.php$"> waere hier WIRKUNGSLOS. Plesk laesst nginx
# PHP-Dateien direkt an PHP-FPM weiterreichen, die .htaccess wird dafuer nicht ausgewertet.
# Unkritisch: KremplDB.php und tba_render.php definieren nur Klasse bzw. Funktionen und geben
# beim Direktaufruf 0 Bytes aus. Wer sie wirklich sperren will, braucht einen location-Block
# in /var/www/vhosts/system/<domain>/conf/vhost_nginx.conf.
# Die config.ini ist ueber die Regel oben zuverlaessig gesperrt (verifiziert: HTTP 403).
+402 -5
View File
@@ -13,6 +13,153 @@ class KremplDB {
private $mysql_conn = null;
private $config = null;
/**
* "nicht mehr lieferbar" als SQL-Regex -- muss deckungsgleich mit den PHP-Pendants
* v2_nml_info() und v2_is_nml_wg() in tba_render.php bleiben.
* Zeichenklassen statt Backslash-Escapes, damit die Muster durch PHP-String und
* MySQL-Parser unveraendert durchkommen.
*/
const NML_NAME_RE = '[(]nml[e]?[)]';
const NML_WG_RE = 'lieferbar|NMLE|Nachfolge|kein Ersatz';
/**
* Aufloesung der Alternativartikel ("Nachfolger") fuer eine Liste von Shop-Artikeln.
*
* Die Zuordnung wird im Backend unter Item-Editor > Tab "Alternativartikel" gepflegt und
* landet in `item_item_assign` (type=3, im Spaltenkommentar "real friends"), geschrieben
* von modules/import/item_save_service.py. Sie steht NICHT im Artikelnamen -- der bisherige
* Namens-Regex /Nachfolger\s+(\S*\d\S*)/ erreicht 71 aktive Artikel, die Tabelle 4.150.
*
* Das "e" in "(nmle)" bedeutet "Ersatz vorhanden": 96,8 % dieser Artikel haben eine
* Zuordnung, gegenueber 7,2 % bei "(nml)" und 1,7 % in der WG "kein Ersatz".
*
* Zeigt eine Zuordnung auf einen Artikel, der selbst nicht mehr lieferbar ist, laeuft die
* Kette weiter (145 Faelle). Rekursiv gemessen: 1.607 direkt kaufbare Ziele, 1.704 mit
* Kettenverfolgung, maximale Tiefe 3. Der `pfad`-String bricht Zyklen, $max_tiefe ist der
* Backstop. Zurueck kommen nur KAUFBARE Endpunkte -- einen nml-Artikel als "Ersatz"
* anzubieten waere schlechter als gar kein Hinweis.
*
* @param int[] $item_ids Shop-Artikel-IDs (items.id)
* @return array [item_id => ['id','number','name','price_brutto','bargain_brutto',
* 'default_image_file_name','wg_name','tiefe','weitere']]
*/
public function getAlternativesBatch(array $item_ids, $max_tiefe = 4) {
if (!$item_ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$ids = array_values(array_unique(array_filter(array_map('intval', $item_ids))));
if (!$ids) return [];
$ids_str = implode(',', $ids);
$max_tiefe = max(1, (int)$max_tiefe);
$nml_name = self::NML_NAME_RE;
$nml_wg = self::NML_WG_RE;
// Startpunkte sind nur die nicht mehr lieferbaren Artikel: nur dort ist ein Nachfolger
// eine Antwort. Bei kaufbaren Artikeln zeigt der Shop die Alternativen ohnehin selbst
// auf der Artikelseite (item_description.tpl, Anker "realfriends").
$sql = <<<SQL
WITH RECURSIVE chain AS (
SELECT
a.item_parent_id AS start_id,
a.item_child_id AS cur,
1 AS tiefe,
a.position AS pos,
CAST(CONCAT(',', a.item_parent_id, ',', a.item_child_id, ',') AS CHAR(512)) AS pfad
FROM item_item_assign a
JOIN items p ON p.id = a.item_parent_id
LEFT JOIN structure ps ON ps.id = p.structure_id
WHERE a.type = 3
AND a.item_parent_id IN ($ids_str)
AND (p.name REGEXP '$nml_name' OR COALESCE(ps.name, '') REGEXP '$nml_wg')
UNION ALL
SELECT
ch.start_id,
a.item_child_id,
ch.tiefe + 1,
a.position,
CONCAT(ch.pfad, a.item_child_id, ',')
FROM chain ch
JOIN items c ON c.id = ch.cur AND c.active = 1
LEFT JOIN structure cs ON cs.id = c.structure_id
JOIN item_item_assign a ON a.item_parent_id = ch.cur AND a.type = 3
WHERE ch.tiefe < $max_tiefe
AND ch.pfad NOT LIKE CONCAT('%,', a.item_child_id, ',%')
AND (c.name REGEXP '$nml_name' OR COALESCE(cs.name, '') REGEXP '$nml_wg')
)
SELECT
ch.start_id,
ch.tiefe,
i.id,
i.number,
i.name,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name
FROM chain ch
JOIN items i ON i.id = ch.cur AND i.active = 1
LEFT JOIN structure s ON s.id = i.structure_id
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
WHERE i.name NOT REGEXP '$nml_name'
AND COALESCE(s.name, '') NOT REGEXP '$nml_wg'
ORDER BY ch.start_id, ch.tiefe, ch.pos, i.id
SQL;
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
// Ein fehlender Nachfolger-Hinweis darf die Suche nicht abbrechen.
error_log('tba-search getAlternativesBatch: ' . mysqli_error($this->mysql_conn));
return [];
}
$out = [];
while ($row = mysqli_fetch_assoc($result)) {
$start = (int)$row['start_id'];
if (!isset($out[$start])) {
// Erster Treffer je Artikel gewinnt: kleinste Kettentiefe, dann die im Backend
// gepflegte Reihenfolge (position).
unset($row['start_id']);
$row['tiefe'] = (int)$row['tiefe'];
$row['weitere'] = 0;
$out[$start] = $row;
} else {
$out[$start]['weitere']++;
}
}
return $out;
}
/**
* Haengt an jeden Artikel der Liste seine kaufbare Alternative unter dem Schluessel 'alt'.
* Ein Query fuer die ganze Liste; ohne Treffer bleibt 'alt' ungesetzt.
*/
private function attachAlternatives(array &$items) {
if (!$items) return;
$ids = [];
foreach ($items as $it) {
if (isset($it['id'])) $ids[] = (int)$it['id'];
}
$alts = $this->getAlternativesBatch($ids);
if (!$alts) return;
foreach ($items as &$it) {
$id = (int)($it['id'] ?? 0);
if (isset($alts[$id])) $it['alt'] = $alts[$id];
}
unset($it);
}
public function __construct($connect_mysql = false) {
$config_file = __DIR__ . '/config.ini';
@@ -88,7 +235,16 @@ class KremplDB {
* @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) {
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
if (empty($search_term)) {
return [];
}
@@ -104,14 +260,168 @@ class KremplDB {
}
while ($row = pg_fetch_assoc($result)) {
// For each device, find matching ersatzteile
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7, i.short_uri,
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 ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* Get all ersatzteile (spare parts) for a specific device
*
@@ -128,7 +438,10 @@ class KremplDB {
e.marke
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
ORDER BY e.navision_id
LIMIT $2
";
@@ -284,7 +597,8 @@ class KremplDB {
SELECT e.navision_id
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
AND e.navision_id IS NOT NULL
ORDER BY e.navision_id
";
@@ -319,11 +633,22 @@ class KremplDB {
i.base_price,
i.manufacturer_id,
i.structure_id,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
OR
@@ -345,6 +670,78 @@ class KremplDB {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
i.short_uri,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($result)) {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
@@ -0,0 +1,602 @@
<?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
*/
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
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)) {
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7,
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 ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* 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
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
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
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
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,
i.default_image_file_name,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
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;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($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);
}
}
}
@@ -0,0 +1,30 @@
[postgresql]
host = localhost
port = 5432
database = krempl_data
user = krempl_user
password = KremplSecure2025
[mysql]
host = localhost
user = webshopadm
password = "Je=53qi2Jey53qi2"
database = webshop-sql2022
[csv_files]
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_size_geraete = 1000
batch_size_artikel = 1000
batch_size_passendwie = 1000
batch_size_mapping = 500
mapping_in_memory = true
progress_interval = 1000000
[logging]
log_level = INFO
log_file =
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* Ereignis-Log fuer Bestellvorgaenge (25.08.2026).
*
* Warum PostgreSQL und nicht der Shop selbst:
* Die MySQL-Tabelle `orders` hat keine einzige Spalte mit Uhrzeit -- `order_date` und alle
* anderen Datumsfelder sind Typ DATE, und `order_history.timestamp` wird bei der
* Bestellanlage nicht gefuellt. Damit laesst sich weder eine Abbruchquote sauber messen
* noch belegen, ob eine Aenderung am Bezahlweg etwas gebracht hat. Genau daran ist die
* Analyse der PayPal-Abbrueche zuerst haengengeblieben.
*
* Grundregel dieser Datei: **Der Bestellvorgang darf hier NIEMALS haengenbleiben.**
* Ist Postgres weg, langsam oder die Tabelle fehlt, wird still weitergemacht. Ein
* Messwerkzeug, das den Kaufvorgang gefaehrdet, ist sein Geld nicht wert. Deshalb
* connect_timeout, deshalb ueberall @ und Rueckgabepruefung statt Exceptions.
*
* Ereignisse:
* order_created Bestellung in MySQL angelegt (bei PayPal: noch nicht bezahlt!)
* payment_started Kunde wurde zu PayPal geschickt, Token steht
* paid Geld eingezogen (DoExpressCheckoutPayment erfolgreich)
* cancelled Kunde hat bei PayPal abgebrochen
* failed PayPal hat den Abschluss abgelehnt
* duplicate_blocked zweiter Versuch desselben Vorgangs abgefangen
* mail_sent Bestellbestaetigung ausgeloest
*
* Auswertung z. B.:
* SELECT date_trunc('day', zeitpunkt) AS tag,
* count(*) FILTER (WHERE ereignis = 'payment_started') AS gestartet,
* count(*) FILTER (WHERE ereignis = 'paid') AS bezahlt
* FROM shop_order_events GROUP BY 1 ORDER BY 1;
*/
if (!function_exists('shop_event')) {
/**
* Schreibt ein Ereignis. Schlaegt das fehl, passiert nichts weiter.
*
* @param string $ereignis siehe Liste oben
* @param array $daten order_id, order_number, payment_method, betrag,
* paypal_token, transaction_id, customer_id, detail
* @return bool true wenn geschrieben, sonst false
*/
function shop_event($ereignis, $daten = array()) {
static $verbindung = null;
static $tot = false;
// Einmal gescheitert -> fuer diesen Request nicht weiter versuchen. Sonst wartet
// jeder Aufruf erneut auf einen Timeout.
if ($tot) {
return false;
}
if (!function_exists('pg_connect')) {
$tot = true;
return false;
}
if ($verbindung === null) {
$ini_pfad = dirname(__FILE__) . '/config.ini';
if (!is_readable($ini_pfad)) {
$tot = true;
return false;
}
$ini = @parse_ini_file($ini_pfad, true);
if (!$ini || empty($ini['postgresql'])) {
$tot = true;
return false;
}
$pg = $ini['postgresql'];
// connect_timeout ist der wichtigste Parameter hier: liegt Postgres,
// wartet der Checkout hoechstens zwei Sekunden statt bis zum PHP-Timeout.
$dsn = sprintf(
'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
isset($pg['host']) ? $pg['host'] : 'localhost',
isset($pg['port']) ? $pg['port'] : '5432',
isset($pg['database']) ? $pg['database'] : '',
isset($pg['user']) ? $pg['user'] : '',
isset($pg['password']) ? $pg['password'] : ''
);
$verbindung = @pg_connect($dsn);
if (!$verbindung) {
$verbindung = null;
$tot = true;
error_log('shop_event: keine PG-Verbindung -- Ereignisprotokoll uebersprungen');
return false;
}
}
$werte = array(
(string)$ereignis,
isset($daten['order_id']) && $daten['order_id'] !== '' ? (int)$daten['order_id'] : null,
isset($daten['order_number']) ? (string)$daten['order_number'] : null,
isset($daten['payment_method']) && $daten['payment_method'] !== '' ? (int)$daten['payment_method'] : null,
isset($daten['betrag']) && $daten['betrag'] !== '' ? (float)$daten['betrag'] : null,
isset($daten['paypal_token']) ? (string)$daten['paypal_token'] : null,
isset($daten['transaction_id']) ? (string)$daten['transaction_id'] : null,
isset($daten['customer_id']) && $daten['customer_id'] !== '' ? (int)$daten['customer_id'] : null,
function_exists('session_id') && session_id() ? session_id() : null,
isset($daten['detail']) ? (string)$daten['detail'] : null
);
$ergebnis = @pg_query_params(
$verbindung,
'INSERT INTO shop_order_events
(ereignis, order_id, order_number, payment_method, betrag,
paypal_token, transaction_id, customer_id, session_id, detail)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
$werte
);
if (!$ergebnis) {
// Einmal melden, dann Ruhe -- ein volllaufendes Log hilft niemandem.
$tot = true;
error_log('shop_event: INSERT fehlgeschlagen (' . $ereignis . ') -- '
. @pg_last_error($verbindung));
return false;
}
return true;
}
}
/* EOF */
+349
View File
@@ -0,0 +1,349 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
// 05.08.2026 von intelectra.de auf intelectra-shop.de umgestellt: intelectra.de leitet seit
// dem per 301 dorthin um (Domain-Konsolidierung, siehe .htaccess des Live-Docroots).
// Ohne diese Aenderung liefe jeder Artikel- und Bildlink der Suche durch die Weiterleitung.
define('TBA_SHOP_BASE', 'https://intelectra-shop.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/**
* Shop-URL zu einem Artikel -- leer, wenn kein Shop-Artikel bekannt ist.
*
* Bevorzugt die sprechende SEO-URL "/<short_uri>.html". Das Feld `items.short_uri`
* ist bei allen 16.927 aktiven Artikeln gefuellt (Format "<Name-Slug>-<Artikelnr>")
* und wird vom Shop selbst so verlinkt (item_box.tpl). Aufgeloest wird sie in der
* .htaccess ueber "RewriteRule ^([aA-zZ0-9-_,]+).(html)$ index.php?item_suri=$1".
*
* Diese Regel akzeptiert nur die Zeichen ihrer Klasse. Deshalb wird hier geprueft,
* BEVOR verlinkt wird -- eine short_uri mit abweichenden Zeichen wuerde nicht
* greifen und den Kunden auf einen 404 schicken. In dem Fall bleibt es bei der
* ID-URL, die immer funktioniert.
*
* Bewusst enger als die .htaccess: deren "aA-zZ" ist ein alter Tippfehler und deckt
* als ASCII-Bereich A(65)-z(122) auch ` [ \ ] ^ _ ab. Fuenf Artikel haben deshalb
* einen Backtick in der short_uri und funktionieren nur zufaellig; die bekommen
* hier die ID-URL. Auf den Tippfehler zu bauen waere fahrlaessig.
*/
function v2_shopurl($item_id, $short_uri = null) {
if (!$item_id) return '';
$uri = trim((string)$short_uri);
if ($uri !== '' && preg_match('/^[A-Za-z0-9_,-]+$/', $uri)) {
return tba_url($uri . '.html');
}
return tba_url('index.php?item_id=' . (int)$item_id);
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false, $short_uri = null) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id, $short_uri);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null, $it['short_uri'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
// Die aus dem Namen gelesene Nummer nur als Rueckfallebene: liegt eine
// gepflegte Zuordnung vor, steht der Ersatz gleich darunter als Zeile und
// die blosse Nummer waere doppelt.
if (empty($it['alt']) && !empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
$h .= ($url !== '' ? '</a>' : '</div>');
// Der Ersatzartikel bekommt eine EIGENE Zeile: ein Link darf nicht in einem
// Link stehen, und der Kunde soll ihn direkt anklicken koennen statt erst den
// ausgelaufenen Artikel zu oeffnen.
if ($is_nml && !empty($it['alt'])) {
$h = '<div class="v2-rowgrp">' . $h . v2_altrow($it['alt']) . '</div>';
}
return $h;
}
/**
* Zeile "Ersatz verfuegbar" unter einem nicht mehr lieferbaren Artikel.
* Datenquelle ist die im Backend gepflegte Zuordnung (item_item_assign type=3),
* aufgeloest von KremplDB::getAlternativesBatch(). Angezeigt werden nur kaufbare
* Ziele, deshalb ist die Zusage hier belastbar.
*/
function v2_altrow($alt) {
$url = v2_shopurl($alt['id'] ?? null, $alt['short_uri'] ?? null);
if ($url === '') return '';
$pb = isset($alt['price_brutto']) ? (float)$alt['price_brutto'] : 0;
$bb = !empty($alt['bargain_brutto']) ? (float)$alt['bargain_brutto'] : 0;
$h = '<a class="v2-altrow" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
. '<span class="v2-altrow-tag">→ Ersatz</span>'
. '<span class="v2-altrow-txt">'
. '<span class="v2-altrow-name">' . htmlspecialchars((string)($alt['name'] ?? '')) . '</span>'
. '<span class="v2-altrow-nr">Art-Nr ' . htmlspecialchars((string)($alt['number'] ?? ''));
// Ueber mehr als eine Station gelaufen: der direkte Nachfolger war selbst
// ausgelaufen. Das ist eine ehrliche Angabe, keine Fussnote.
if (!empty($alt['tiefe']) && (int)$alt['tiefe'] > 1) {
$h .= ' · über ' . (int)$alt['tiefe'] . ' Nachfolger ermittelt';
}
if (!empty($alt['weitere'])) {
$h .= ' · ' . (int)$alt['weitere'] . ' weitere Alternative'
. ((int)$alt['weitere'] > 1 ? 'n' : '');
}
$h .= '</span></span><span class="v2-altrow-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b>' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
return $h . '</span></a>';
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
@@ -0,0 +1,272 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
define('TBA_SHOP_BASE', 'https://intelectra.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/** Shop-URL zu einer items.id -- leer, wenn kein Shop-Artikel bekannt ist. */
function v2_shopurl($item_id) {
return $item_id ? tba_url('index.php?item_id=' . (int)$item_id) : '';
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
if (!empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
return $h . ($url !== '' ? '</a>' : '</div>');
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
+132 -40
View File
@@ -301,9 +301,7 @@ class Revocation {
}
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
$msg .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte Rückzahlung über die Warenwirtschaft '
. 'veranlassen.</strong> Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche '
. 'Bestellung erfolgen.</p>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
. '/index.php?admin_modul=admin_revocation_list">Widerrufsliste öffnen</a></p>';
@@ -324,27 +322,19 @@ class Revocation {
$order_number = $order->order_number ?? ('#'.$revocation->order_id);
$name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? ''));
$subject = 'Bestätigung Ihres Widerrufs – Bestellung '.$order_number;
$ts = strtotime($revocation->submitted_at);
$datum = date('d.m.Y', $ts);
$uhrzeit = date('H:i', $ts);
$msg = '<p>Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',</p>';
$msg .= '<p>hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung '
. '<strong>'.htmlspecialchars($order_number).'</strong> am '
. date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.</p>';
$msg .= '<h3>Nächste Schritte</h3>';
$msg .= '<p>Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:</p>';
$msg .= '<p style="background:#f4f4f4;padding:12px;border-left:4px solid #c00;">'
. '<strong>'.REVOCATION_RETURN_COMPANY.'</strong><br>'
. REVOCATION_RETURN_STREET.'<br>'
. REVOCATION_RETURN_CITY.'</p>';
$msg .= '<p>'.REVOCATION_RETURN_COSTS_NOTE.'</p>';
$msg .= '<p>Die Rückzahlung erfolgt innerhalb von 14 Tagen nach Eingang Ihres Widerrufs über '
. 'dieselbe Zahlungsart, die Sie bei der ursprünglichen Bestellung verwendet haben – '
. 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart.</p>';
$msg .= '<p>Bei Rückfragen erreichen Sie uns jederzeit unter '
. '<a href="mailto:'.REVOCATION_OWNER_EMAIL.'">'.REVOCATION_OWNER_EMAIL.'</a>.</p>';
$msg .= '<hr><p style="font-size:0.85em;color:#666;">'.REVOCATION_RETURN_COMPANY.'</p>';
$ok = $mailer->send_mail($subject, $msg, $revocation->customer_email);
$ok = $this->send_neutral_eingangsbestaetigung(
$revocation->customer_email,
$name,
$revocation->customer_email,
$order_number,
$datum,
$uhrzeit,
$revocation->reason_text ?? ''
);
if ($ok) {
$this->db->query(
@@ -487,20 +477,25 @@ class Revocation {
}
/**
* Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail.
* Wenn noch kein revocation-Record existiert, wird er inline angelegt.
* Widerruf ohne Token.
* Pflichtfelder: Name + E-Mail. Button-Klick = Erklärung.
* Bestellnummer + Artikel sind OPTIONAL (Händlerbund-Linie). Wenn Order
* gefunden → DB-Tracking. Sonst → reiner Anonym-Pfad mit Owner-Mail zur
* manuellen Zuordnung. Der Widerruf wird in jedem Fall entgegengenommen.
*/
public function submit_anonymous($name, $order_number, $email, $reason_id, $reason_text, $ip, $user_agent) {
public function submit_anonymous($name, $order_number, $email, $article, $ip, $user_agent) {
if (!REVOCATION_ENABLED) {
$this->error = 'feature_disabled';
return false;
}
$name = trim((string)$name);
$order_number = trim((string)$order_number);
$email = trim(strtolower((string)$email));
$article = trim((string)$article);
if ($order_number === '') {
$this->error = 'missing_order';
if ($name === '') {
$this->error = 'missing_name';
return false;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -508,22 +503,24 @@ class Revocation {
return false;
}
$order = $this->fetch_order_by_number_email($order_number, $email);
// Order-Lookup nur informativ. Wenn keine Order angegeben oder
// keine gefunden oder nicht widerrufsfähig → Anonym-Pfad.
$order = ($order_number !== '')
? $this->fetch_order_by_number_email($order_number, $email)
: null;
if (!$order) {
$this->error = 'order_not_found';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
if (!RevocationHelper::is_b2c($group_id)) {
$this->error = 'not_eligible';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
if (strtotime($expires_at) < time()) {
$this->error = 'expired';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
// Existing Eintrag wiederverwenden, sonst inline anlegen
@@ -563,11 +560,10 @@ class Revocation {
$stmt->close();
}
$reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null;
$reason_text = trim((string)$reason_text);
if (mb_strlen($reason_text) > 2000) {
$reason_text = mb_substr($reason_text, 0, 2000);
}
$reason_id = null;
$reason_text = ($article !== '' && mb_strlen($article) > 2000)
? mb_substr($article, 0, 2000)
: $article;
$ip_anon = RevocationHelper::anonymize_ip($ip);
$ua = $user_agent ? mb_substr($user_agent, 0, 255) : null;
@@ -623,4 +619,100 @@ class Revocation {
if (!$res || $res->num_rows === 0) return false;
return $res->fetch_object();
}
// ============================================================
// Anonym-Pfad: ohne (passende) Bestellung
// ============================================================
/**
* Widerruf ohne Order-Match.
* Kein DB-Eintrag, dafür Owner-Mail mit Hinweis auf manuelle Zuordnung
* und Eingangsbestätigung an den Kunden.
*/
private function process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent) {
$ip_anon = RevocationHelper::anonymize_ip($ip);
$ua = $user_agent ? mb_substr((string)$user_agent, 0, 255) : null;
$article = trim((string)$article);
if (mb_strlen($article) > 2000) {
$article = mb_substr($article, 0, 2000);
}
$this->send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua);
$this->send_anonymous_customer_confirmation($name, $order_number, $email, $article);
return true;
}
private function send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua) {
$mailer = new mail_tools($this->base_object);
$subject = 'Widerruf eingegangen (manuelle Zuordnung erforderlich)';
$msg = '<h2>Widerruf eingegangen</h2>';
$msg .= '<p style="background:#fff3cd;border-left:4px solid #ffa500;padding:10px;">';
$msg .= '<strong>Bitte manuell zuordnen.</strong> Der Widerruf wurde ohne (oder mit nicht gefundener) Bestellnummer eingereicht. ';
$msg .= 'Es wurde kein automatischer DB-Eintrag erzeugt.';
$msg .= '</p>';
$msg .= '<table cellpadding="4" cellspacing="0" border="0" style="font-family:Arial,sans-serif;">';
$msg .= $this->mail_row('Name', htmlspecialchars($name));
$msg .= $this->mail_row('E-Mail', htmlspecialchars($email));
$msg .= $this->mail_row('Bestellnummer', $order_number !== '' ? htmlspecialchars($order_number) : '(nicht angegeben)');
$msg .= $this->mail_row('Artikel/Bemerkung', $article !== '' ? nl2br(htmlspecialchars($article)) : '(nicht angegeben)');
$msg .= $this->mail_row('Datum/Uhrzeit', date('d.m.Y H:i:s'));
$msg .= $this->mail_row('IP (anon.)', $ip_anon ?? '–');
$msg .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
return $mailer->send_mail($subject, $msg, REVOCATION_OWNER_EMAIL);
}
private function send_anonymous_customer_confirmation($name, $order_number, $email, $article) {
return $this->send_neutral_eingangsbestaetigung(
$email,
$name,
$email,
$order_number !== '' ? $order_number : '(nicht angegeben)',
date('d.m.Y'),
date('H:i'),
$article
);
}
/**
* Neutrale Eingangsbestätigung gemäß Händlerbund-Vorlage.
*
* WICHTIG: Diese E-Mail darf NUR den Eingang der Widerrufserklärung
* bestätigen und KEINE wirksame Bestätigung des Widerrufs selbst sein.
* Keine Rücksendeadresse, keine Frist-Hinweise, keine Werbung.
*
* @param string $to Empfänger-Mail
* @param string $consumer_name Name des Verbrauchers für die Anrede
* @param string $consumer_email E-Mail aus dem Formular (für „Inhalt")
* @param string $order_number Bestellnr aus dem Formular ('' = nicht angegeben)
* @param string $date_str Eingangsdatum (dd.mm.yyyy)
* @param string $time_str Eingangsuhrzeit (HH:MM)
* @param string $article Artikel/Bemerkung (optional)
*/
private function send_neutral_eingangsbestaetigung($to, $consumer_name, $consumer_email,
$order_number, $date_str, $time_str, $article) {
$mailer = new mail_tools($this->base_object);
$subject = 'Ihr Widerruf ist bei uns eingegangen';
$name_safe = htmlspecialchars(trim((string)$consumer_name));
$anrede = $name_safe !== '' ? 'Hallo '.$name_safe.',' : 'Hallo,';
$msg = '<p>'.$anrede.'</p>';
$msg .= '<p>wir best&auml;tigen hiermit den Eingang Ihres Widerrufs mit folgendem Inhalt:</p>';
$msg .= '<ul style="line-height:1.6;">';
$msg .= '<li><strong>Name:</strong> '.$name_safe.'</li>';
$msg .= '<li><strong>E-Mail:</strong> '.htmlspecialchars($consumer_email).'</li>';
$msg .= '<li><strong>Bestellnummer:</strong> '.htmlspecialchars($order_number).'</li>';
if (!empty($article)) {
$msg .= '<li><strong>Artikel/Bemerkung:</strong> '.nl2br(htmlspecialchars($article)).'</li>';
}
$msg .= '<li><strong>Eingang am:</strong> '.htmlspecialchars($date_str).' um '.htmlspecialchars($time_str).' Uhr</li>';
$msg .= '</ul>';
$msg .= '<p>Wir pr&uuml;fen Ihr Anliegen und melden uns zeitnah bei Ihnen.</p>';
$msg .= '<p>Viele Gr&uuml;&szlig;e<br>Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'</p>';
return $mailer->send_mail($subject, $msg, $to);
}
}
+4 -1
View File
@@ -37,7 +37,10 @@ ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/error_log.txt');
// 13.09.2026: wie Live -- Log nach logs/ (per .htaccess gesperrt), Absturzprotokoll in logs/fatal.log
ini_set('error_log', __DIR__ . '/logs/shop_error.log');
include_once './core/fatal_log.php';
intelectra_fatal_log_init();
//setlocale(LC_ALL, 'de_DE.UTF-8@euro', 'de_DE', 'deu_deu', 'ge');
+4
View File
@@ -129,6 +129,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return bool
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->options[$offset]);
@@ -141,6 +142,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
if (!$this->offsetExists($offset)) {
@@ -157,6 +159,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
$this->options[$offset] = $value;
@@ -169,6 +172,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->options[$offset]);
+308 -36
View File
@@ -17,6 +17,11 @@ include_once('./core/deliverer.class.php'); // should be called shipping methods
include_once('./core/order.class.php');
include_once('./core/cs_ticket.class.php');
// 25.08.2026: Ereignisprotokoll in PG -- liefert die Uhrzeiten zu Bestellungen, die
// `orders` nicht hat (alle Datumsfelder dort sind Typ DATE). Faellt still aus, wenn
// Postgres nicht erreichbar ist.
include_once($_SERVER['DOCUMENT_ROOT'].'/core/postgres/shop_events.php');
include_once('./core/orderhelper.class.php');
include_once('./core/giftcertificatehelper.class.php');
@@ -65,6 +70,30 @@ class website_cashdesk {
return $this->layout_object->_fetch('content_cash_desk/completed.tpl');
}
// 24.08.2026 -- Zielseite des Post/Redirect/Get aus PaypalPaymentMethod::after_order().
// Reines GET: hier steht der Link zu PayPal, ein Reload laedt nur noch ihn neu und
// legt keine zweite Bestellung mehr an.
// Steht bewusst vor dem Kunden-/Login-Check: nach dem Redirect ist kein
// ?guest_order=1 mehr in der URL, ein Gast haette hier sonst keine customer_id.
if (isset($_GET['pp_open'])) {
$pp_token = trim((string)$_GET['pp_open']);
if (preg_match('/^EC-[A-Z0-9]{10,30}$/i', $pp_token)) {
$pp_sandbox = (class_exists('Config') && Config::get_value('paypal_dev_mode') == 1);
$pp_host = $pp_sandbox ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com';
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('paypal_link',
$pp_host.'/cgi-bin/webscr?cmd=_express-checkout&token='.urlencode($pp_token));
} else {
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('message',
'Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da &ndash; bitte starten Sie die Bestellung neu.');
}
return $this->layout_object->_fetch('content_cash_desk/paypal.tpl');
}
if (isset($_GET['guest_order']) && $_GET['guest_order'] == 1) {
// guest order
$this->base_object->customer->customer_id = $this->base_object->config->shopConfiguration['guest_order_customer_id'];
@@ -171,6 +200,71 @@ class website_cashdesk {
$this->base_object->customer->save($data);
}
// 25.08.2026 -- Stufe 4: ein Bestellvorgang, eine Order.
// Traegt dieser POST einen Nonce, der schon zu einer Bestellung gefuehrt
// hat, ist es derselbe Vorgang -- egal ob eine Sekunde oder eine Stunde
// vergangen ist. Dann zurueck auf das Ergebnis von damals, statt ein
// zweites Mal zu bestellen.
$erledigt = $this->bestellvorgang_bereits_erledigt();
if ($erledigt) {
$offen = isset($_SESSION['easyshop']['paypal_pending'])
? $_SESSION['easyshop']['paypal_pending'] : null;
// Laeuft zu dieser Order noch ein unbezahlter PayPal-Vorgang,
// gehoert der Kunde dorthin und nicht auf die Erfolgsseite.
if ($offen && !empty($offen['token'])
&& (int)$offen['order_id'] === (int)$erledigt['order_id']
&& !$this->paypal_order_is_paid($erledigt['order_id'])) {
error_log('Checkout: Vorgang '.$erledigt['order_number']
. ' bereits bestellt, zurueck auf offenen PayPal-Vorgang '
. $offen['token']);
header('Location: /Kasse/?pp_open='.urlencode($offen['token']));
exit;
}
error_log('Checkout: Vorgang bereits bestellt (Order '
. $erledigt['order_number'].') -- keine zweite Order angelegt');
if (function_exists('shop_event')) {
shop_event('duplicate_blocked', array(
'order_id' => $erledigt['order_id'],
'order_number' => $erledigt['order_number'],
'betrag' => $erledigt['total'],
'detail' => 'zweiter Bestell-POST desselben Vorgangs'
));
}
header('Location: /Kasse/?success=1&order='.urlencode($erledigt['order_number']));
exit;
}
// 24.08.2026 -- zweiter Riegel gegen Doppelbestellungen bei PayPal.
// Kommt trotz PRG noch einmal ein ready2fly-POST (Zurueck-Taste,
// zweiter Tab, ungeduldiges Klicken) und liegt zu dieser Session
// bereits ein PayPal-Vorgang ueber denselben Betrag offen, der noch
// nicht bezahlt ist: zurueck auf dessen Link, statt eine zweite Order
// anzulegen. Ist der Vorgang bezahlt oder aelter als eine Stunde,
// laeuft alles normal weiter -- dann ist es ein neuer Einkauf.
$pending = isset($_SESSION['easyshop']['paypal_pending'])
? $_SESSION['easyshop']['paypal_pending'] : null;
$pending_module = isset($this->payment_methods[$this->payment_method_id]->module)
? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
: '';
if ($pending && !empty($pending['token'])
&& $pending_module === 'paypal'
&& abs(round((float)$this->shoppingcart_data['order_total'], 2) - (float)$pending['total']) < 0.01
&& (time() - (int)(isset($pending['time']) ? $pending['time'] : 0)) < 3600
&& !$this->paypal_order_is_paid($pending['order_id'])) {
error_log("PayPal Fix: doppelter ready2fly-POST abgefangen, zurueck auf offenen Vorgang {$pending['token']}");
header('Location: /Kasse/?pp_open='.urlencode($pending['token']));
exit;
}
$is_saved = $this->save_order();
if ($is_saved) {
@@ -213,6 +307,10 @@ class website_cashdesk {
$this->layout_object->assign('site_type', 'full_width');
}
// 25.08.2026: Nonce fuers Kassenformular -- macht einen zweiten Klick auf
// "Bestellen" als denselben Vorgang erkennbar (siehe save_order()).
$this->layout_object->assign('checkout_nonce', $this->checkout_nonce_holen());
return $this->layout_object->_fetch('content_cash_desk.tpl');
} else if ($_GET['token']) {
@@ -497,7 +595,8 @@ class website_cashdesk {
$site_content_object = new SiteContent($this->base_object);
$agb_data = array();
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(55);
// Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet.
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(10737);
$agb_data['agbs'] = $site_content_object->get_textbox_data_by_id(56);
$agb_data['agbs_b2b'] = $site_content_object->get_textbox_data_by_id(10632);
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
@@ -611,41 +710,28 @@ class website_cashdesk {
// end before_saving_order
private function save_order() {
// 🚀 FIX 1: Verhindere Doppel-Bestellungen (PayPal Return Flow + Neuanlage)
// Prüfe SOWOHL Customer-ID ALS AUCH Session (wichtig bei Neuanlage!)
$customer_id = intval($this->base_object->customer->customer_id);
$session_id = session_id();
// Prüfe ob bereits eine Order in den letzten 5 Minuten angelegt wurde
// ENTWEDER von dieser Customer-ID ODER von dieser Session (für Neukunden!)
$sql = "SELECT id, order_number, order_total, order_date, customer_id
FROM orders
WHERE (customer_id = " . $this->base_object->db->real_escape_string($customer_id) . "
OR customer_id IN (
SELECT id FROM customers
WHERE email = '" . $this->base_object->db->real_escape_string($this->customer_data->email ?? '') . "'
AND registration_date >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
))
AND order_date >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
ORDER BY id DESC LIMIT 1";
$result = $this->base_object->db->query($sql);
if ($result && $result->num_rows > 0) {
$existing_order = $result->fetch_object();
error_log("ORDER PREVENTED: Duplicate order detected - " .
"Customer: " . $customer_id .
" (Email: " . ($this->customer_data->email ?? 'unknown') . "), " .
"Existing Order: " . $existing_order->order_number .
" (ID: " . $existing_order->id . ") from " . $existing_order->order_date .
", Context: " . (isset($_GET['token']) ? 'PayPal Return' : 'Direct'));
// Setze Order-Daten für Success-Seite
$this->order_id = $existing_order->id;
$this->order_number = $existing_order->order_number;
return true; // Gib vor, dass Order erfolgreich war (ist sie ja!)
}
// 25.08.2026 -- FIX 1 ersetzt (Stufe 4).
//
// Hier stand eine Duplikatspruefung, die auf
// order_date >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
// beruhte. `orders.order_date` ist aber Typ DATE, nicht DATETIME -- MySQL macht aus
// dem Datum '2026-08-25 00:00:00', und damit war die Bedingung fuer den GANZEN TAG
// wahr statt fuer fuenf Minuten. Wer am selben Tag ein zweites Mal bestellte, bekam
// seine ERSTE Bestellnummer als "erfolgreich" angezeigt, und der neue Warenkorb
// wurde nie bestellt. Ein stiller Verlust, der niemandem auffiel.
//
// Reparieren liess sich das nicht: die Tabelle `orders` hat ueberhaupt keine Spalte
// mit Uhrzeit (alle Datumsfelder sind DATE), und `order_history.timestamp` wird bei
// der Bestellanlage nicht gefuellt. Es gibt also keinen Zeitstempel, auf den man
// eine Fuenf-Minuten-Regel stuetzen koennte.
//
// Statt der Uhr entscheidet jetzt der Vorgang: ein checkout_nonce pro Kassenformular
// (siehe run() und checkout_nonce_holen()). Wird derselbe Nonce ein zweites Mal
// abgeschickt, ist es derselbe Bestellvorgang -- unabhaengig davon, wie viel Zeit
// vergangen ist. Als Netz fuer Absendungen ohne Nonce dient das Zeitfenster in
// $_SESSION['easyshop']['last_order'], das eine echte Uhrzeit fuehrt.
//
// Der Empty-Cart-Schutz weiter unten (FIX 2) bleibt unveraendert.
// 🚀 FIX 2: Verhindere 0€ Bestellungen ohne Items (Race Condition / Session Loss)
if (empty($this->shoppingcart_data) ||
@@ -744,9 +830,26 @@ class website_cashdesk {
unset($_SESSION['customer_info']);
if ($this->order_id) {
// 24.08.2026: Bei PayPal wandert die Bestellbestaetigung ans Ende des
// Bezahlvorgangs (PaypalPaymentMethod::bestellbestaetigung_senden). Hier waere
// sie zu frueh -- die Order existiert an dieser Stelle zwar, aber der Kunde war
// noch nicht einmal bei PayPal. Beim Testkauf am 24.08. lagen zwischen dieser
// Mail (23:25:09) und der eingezogenen Zahlung (23:26:45) anderthalb Minuten,
// und wer gar nicht bezahlt, bekam trotzdem eine Bestaetigung.
// Alle anderen Zahlarten sind mit dem Abschicken verbindlich -- dort bleibt der
// Versand genau hier.
$mail_modul = isset($this->payment_methods[$this->payment_method_id]->module)
? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
: '';
if ($mail_modul !== 'paypal') {
// send email
$ticket_object = new Cs_ticket($this->base_object);
$ticket_object->event(3, $this->customer_data->id, $this->order_id);
} else {
error_log('PayPal Fix: Bestellbestaetigung fuer Order '.$this->order_id
. ' zurueckgestellt bis zur eingezogenen Zahlung');
}
// EU-RL 2026/2673: elektronischer Widerruf (B2C) – silent solange Flag aus
try {
@@ -758,6 +861,24 @@ class website_cashdesk {
}
if ($this->order_number) {
// 25.08.2026: Vorgang als erledigt vermerken, damit ein zweiter Klick auf
// denselben Bestellbutton keine weitere Order erzeugt.
$this->bestellvorgang_vermerken();
// Ereignisprotokoll in PG -- der einzige Ort mit einer Uhrzeit zur Bestellung.
if (function_exists('shop_event')) {
shop_event('order_created', array(
'order_id' => $this->order_id,
'order_number' => $this->order_number,
'payment_method' => $this->payment_method_id,
'betrag' => isset($this->shoppingcart_data['order_total'])
? $this->shoppingcart_data['order_total'] : null,
'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null,
'detail' => $mail_modul === 'paypal'
? 'PayPal: noch nicht bezahlt' : 'sofort verbindlich'
));
}
return true;
}
}
@@ -786,8 +907,20 @@ class website_cashdesk {
}
$this->layout_object->assign('trusted_shop_payment', $trusted_shop_payment);
// 24.08.2026: Der Warenkorb wird nur noch geleert, wenn die Bestellung mit dieser
// Antwort wirklich abgeschlossen ist. Bei PayPal faengt das Bezahlen hier erst an
// -- vorher flog der Korb schon raus, und wer abbrach, neu lud oder zurueckging,
// stand ohne Warenkorb da ("ORDER PREVENTED: Empty cart"). PayPal leert jetzt
// selbst, sobald das Geld eingezogen ist (PaypalPaymentMethod::change_order).
// Bewusst nur PayPal: die anderen Module raeumen nicht selbst auf.
$payment_module = isset($this->payment_methods[$this->payment_method_id]->module)
? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
: '';
if ($payment_module !== 'paypal') {
$shoppingcart_object = new Shoppingcart($this->base_object);
$shoppingcart_object->clear(1);
}
if ($this->payment_methods[$this->payment_method_id]->module != '') {
$path = $_SERVER['DOCUMENT_ROOT'].'/core/paymentmethods/';
@@ -817,6 +950,145 @@ class website_cashdesk {
// end after_saving_order
/**
* Nonce fuer das Kassenformular (25.08.2026).
*
* Ein Wert pro Bestellvorgang, der als hidden field im Formular mitgeht. Nach einer
* erfolgreichen Bestellung wird er verbraucht und ein neuer erzeugt -- ein erneut
* abgeschickter alter Nonce ist damit erkennbar derselbe Vorgang. Das ersetzt die
* Zeitfenster-Pruefung, die auf einer DATE-Spalte nicht funktionieren konnte.
*
* @return string
*/
private function checkout_nonce_holen() {
if (empty($_SESSION['easyshop']['checkout_nonce'])) {
if (function_exists('random_bytes')) {
try {
$_SESSION['easyshop']['checkout_nonce'] = bin2hex(random_bytes(16));
} catch (Exception $e) {
$_SESSION['easyshop']['checkout_nonce'] = md5(uniqid(mt_rand(), true));
}
} else {
$_SESSION['easyshop']['checkout_nonce'] = md5(uniqid(mt_rand(), true));
}
}
return $_SESSION['easyshop']['checkout_nonce'];
}
// end checkout_nonce_holen
/**
* Wurde dieser Bestellvorgang schon abgeschlossen? (25.08.2026)
*
* @return array|false Daten der bereits angelegten Order, sonst false
*/
private function bestellvorgang_bereits_erledigt() {
$nonce = isset($_POST['checkout_nonce']) ? trim((string)$_POST['checkout_nonce']) : '';
if ($nonce !== '' && isset($_SESSION['easyshop']['checkout_done'][$nonce])) {
return $_SESSION['easyshop']['checkout_done'][$nonce];
}
// Netz fuer Absendungen ohne Nonce -- alte Seite aus dem Cache, JS abgeschaltet,
// Formular von Hand nachgebaut. Gleiche Session, gleicher Betrag, innerhalb von
// fuenf Minuten. Die Uhrzeit kommt aus der Session und ist damit eine echte
// Uhrzeit, anders als beim alten Guard auf der DATE-Spalte.
if ($nonce === '' && !empty($_SESSION['easyshop']['last_order'])) {
$letzte = $_SESSION['easyshop']['last_order'];
$summe = round((float)(isset($this->shoppingcart_data['order_total'])
? $this->shoppingcart_data['order_total'] : 0), 2);
if (abs($summe - (float)$letzte['total']) < 0.01
&& (time() - (int)$letzte['time']) < 300) {
return $letzte;
}
}
return false;
}
// end bestellvorgang_bereits_erledigt
/**
* Bestellvorgang als erledigt vermerken (25.08.2026).
*
* Laeuft direkt nach der Order-Anlage. Der verbrauchte Nonce bleibt in der Session
* stehen, damit ein spaeter nachgereichter POST mit demselben Wert erkannt wird; fuer
* die naechste Bestellung wird beim Rendern des Formulars ein frischer erzeugt.
*/
private function bestellvorgang_vermerken() {
$nonce = isset($_POST['checkout_nonce']) ? trim((string)$_POST['checkout_nonce']) : '';
$eintrag = array(
'order_id' => $this->order_id,
'order_number' => $this->order_number,
'total' => round((float)(isset($this->shoppingcart_data['order_total'])
? $this->shoppingcart_data['order_total'] : 0), 2),
'time' => time()
);
if ($nonce !== '') {
if (!isset($_SESSION['easyshop']['checkout_done'])
|| !is_array($_SESSION['easyshop']['checkout_done'])) {
$_SESSION['easyshop']['checkout_done'] = array();
}
$_SESSION['easyshop']['checkout_done'][$nonce] = $eintrag;
// Nicht unbegrenzt mitschleppen -- die letzten fuenf reichen.
if (count($_SESSION['easyshop']['checkout_done']) > 5) {
$_SESSION['easyshop']['checkout_done'] =
array_slice($_SESSION['easyshop']['checkout_done'], -5, 5, true);
}
unset($_SESSION['easyshop']['checkout_nonce']);
}
$_SESSION['easyshop']['last_order'] = $eintrag;
}
// end bestellvorgang_vermerken
/**
* Ist zu dieser Order bereits Geld eingezogen? (24.08.2026)
*
* Entscheidet, ob ein wiederholter Bestell-POST auf den offenen PayPal-Vorgang
* zurueckgefuehrt wird oder als neuer Einkauf durchlaeuft.
*
* @param int $order_id
* @return bool
*/
private function paypal_order_is_paid($order_id) {
$order_id = (int)$order_id;
if ($order_id <= 0) {
return false;
}
$result = $this->base_object->db->query("
SELECT
paypal_transaction_id
FROM
orders
WHERE
id = ".$this->base_object->db->real_escape_string($order_id)."
LIMIT 1
");
if ($result && $result->num_rows > 0) {
$row = $result->fetch_object();
return !empty($row->paypal_transaction_id);
}
// Order nicht gefunden -> nicht blockieren, normal weiterbestellen lassen.
return false;
}
// end paypal_order_is_paid
private function change_order($param) {
$path = $_SERVER['DOCUMENT_ROOT'].'/core/paymentmethods/';
$module_name = ucfirst($param).'PaymentMethod';
+2 -1
View File
@@ -229,7 +229,8 @@ class website_customer_registration {
$site_content_object = new SiteContent($this->base_object);
$agb_data = array();
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(55);
// Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet.
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(10737);
$agb_data['agbs'] = $site_content_object->get_textbox_data_by_id(56);
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
$this->layout_object->assign('agb_data', $agb_data);
+3 -1
View File
@@ -205,7 +205,9 @@ else if (isset($_GET['item_id']) && $_GET['item_id'] && (!isset($_GET['action'])
$layout_object->metaTagObject->overwriteVar('VonHersteller', 'von '.$hersteller);
$layout_object->metaTagObject->overwriteVar('Hersteller', $hersteller);
$layout_object->metaTagObject->overwriteVar('ArtikelAttribut6', str_replace(';',',',$content_object->item_data->attribute_6));
$structure_id = (isset($_GET['menu_id']) && $_GET['menu_id']) ? $_GET['menu_id'] : $content_object->structure_data[0]->id;
$structure_id = (isset($_GET['menu_id']) && $_GET['menu_id'])
? $_GET['menu_id']
: (isset($content_object->structure_data[0]->id) ? $content_object->structure_data[0]->id : 0);
$structure_object = new Structure($base_object);
$tree_ids = array_reverse($structure_object->get_all_tree_ids($structure_id));
$z = 0;
+1
View File
@@ -18,6 +18,7 @@ include_once './core/customer_group.class.php';
include_once './core/cs_ticket.class.php';
include_once './core/shop_klarna.class.php';
#[\AllowDynamicProperties]
class website_item {
private $base_object;
+883 -128
View File
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
<?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 = [];
$item_results = [];
$search_time = 0;
$item_search_time = 0;
$error_message = '';
$show_shop_items = isset($_GET['shop_items']) && $_GET['shop_items'] === '1';
try {
// MySQL wird in beiden Fällen gebraucht (rechte Spalte = Artikel-Suche)
$db = new KremplDB(true);
if ($search_term !== '') {
// Links: Geräte-Suche (PostgreSQL)
$start_time = microtime(true);
$results = $db->searchGeraete($search_term, 20);
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;
// Rechts: Artikel-/Ersatzteil-Suche (MySQL)
$start_time2 = microtime(true);
$item_results = $db->searchItems($search_term, 20);
$item_search_time = (microtime(true) - $start_time2) * 1000;
}
} 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: 1600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.split-results {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 20px;
}
.split-results > div {
min-width: 0;
}
.split-results h2 {
font-size: 16px;
color: #444;
background: #f4f4f4;
padding: 10px 14px;
border-left: 4px solid #4CAF50;
margin-bottom: 14px;
}
.split-results .right-pane h2 {
border-left-color: #1976D2;
}
.item-card {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
padding: 10px 12px;
background: #fafafa;
font-size: 13px;
}
.item-card .item-name {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.item-card .item-meta {
color: #666;
font-size: 12px;
}
.item-card .item-meta strong {color:#1976D2;}
.inventory-positive {color:#2e7d32;font-weight:bold;}
.inventory-zero {color:#aaa;}
.inventory-null {color:#aaa;font-style:italic;}
@media (max-width: 900px) {
.split-results { grid-template-columns: 1fr; }
}
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="split-results">
<!-- LINKS: Geräte-Suche (PostgreSQL) -->
<div class="left-pane">
<h2>Geräte (PostgreSQL)
— <?php echo count($results); ?> Treffer
(<?php echo number_format($search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Geräte gefunden.
</div>
<?php endif; ?>
<?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; ?>
</div><!-- /left-pane -->
<!-- RECHTS: Artikel-/Ersatzteil-Suche (MySQL) -->
<div class="right-pane">
<h2>Ersatzteile / Artikel (MySQL items)
— <?php echo count($item_results); ?> Treffer
(<?php echo number_format($item_search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($item_results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Artikel gefunden.
</div>
<?php else: ?>
<?php foreach ($item_results as $item): ?>
<div class="item-card">
<div class="item-name"><?= htmlspecialchars($item['name']) ?></div>
<div class="item-meta">
Art-Nr: <strong><?= htmlspecialchars($item['number']) ?></strong>
· Navision: <strong><?= htmlspecialchars($item['matched_navision_id']) ?></strong>
· Lager:
<?php
$inv = $item['inventory'];
if ($inv === null) {
echo '<span class="inventory-null">k.A.</span>';
} elseif ((int)$inv > 0) {
echo '<span class="inventory-positive">' . (int)$inv . ' Stk</span>';
} else {
echo '<span class="inventory-zero">0</span>';
}
?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /right-pane -->
</div><!-- /split-results -->
<?php
// ==================================================================
// A/B – NEUE DARSTELLUNG (Prototyp, geführte Ergebnis-Ansicht)
// Nutzt dieselben Daten ($results / $item_results), nur menschlich.
// ==================================================================
if (!function_exists('v2_confidence')) {
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
// Treffer in "stark" (>=75) und "schwach" (<75) teilen
$v2_strong = []; $v2_weak = [];
foreach ($results as $d) { ((int)$d['score'] >= 75) ? $v2_strong[] = $d : $v2_weak[] = $d; }
// Artikel klassifizieren: kaufbar / ausgelaufen-mit-Nachfolger / tot
$g_buy = []; $g_succ = []; $g_dead = [];
foreach ($item_results as $it) {
$it['_nml'] = v2_nml_info($it['name']);
if (!$it['_nml']['is_nml']) $g_buy[] = $it; // regulär
elseif ($it['_nml']['nachfolger']) $g_succ[] = $it; // ausgelaufen, Nachfolger da
else $g_dead[] = $it; // ausgelaufen, kein Ersatz
}
$v2_top_score = isset($results[0]) ? (int)$results[0]['score'] : 0;
// Intent-Einordnung
if ($v2_top_score >= 90) $v2_headline = 'Wir haben dein Gerät gefunden.';
elseif (count($v2_strong) > 0) $v2_headline = 'Das könnte dein Gerät sein:';
elseif (count($item_results)) $v2_headline = 'Passende Artikel gefunden:';
else $v2_headline = 'Mehrere mögliche Treffer – grenz gern ein.';
?>
<hr style="margin:40px 0 0; border:none; border-top:3px dashed #cfcfcf;">
<style>
.v2{margin-top:24px;font-size:15px}
.v2 .v2-flag{display:inline-block;background:#111;color:#fff;font-size:11px;font-weight:700;
letter-spacing:.5px;padding:3px 10px;border-radius:4px;text-transform:uppercase}
.v2 .v2-head{font-size:22px;font-weight:700;color:#222;margin:14px 0 4px}
.v2 .v2-sub{color:#777;font-size:13px;margin-bottom:22px}
.v2-dev{border:1px solid #e3e3e3;border-radius:10px;margin-bottom:16px;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.05)}
.v2-dev-top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;
padding:16px 18px;background:#fff}
.v2-dev-name{font-size:18px;font-weight:700;color:#1a1a1a;line-height:1.3}
.v2-dev-meta{color:#666;font-size:13px;margin-top:5px}
.v2-dev-meta b{color:#444}
.v2-conf{white-space:nowrap;font-size:12px;font-weight:700;padding:6px 12px;border-radius:20px}
.v2-parts{padding:14px 18px;border-top:1px solid #f0f0f0;background:#fcfcfc}
.v2-parts h4{font-size:13px;color:#555;margin-bottom:9px;font-weight:600}
.v2-chip{display:inline-block;background:#fff;border:1px solid #e0e0e0;border-radius:16px;
padding:4px 11px;margin:0 5px 6px 0;font-size:12px;color:#444}
.v2-chip b{color:#1565c0}
.v2-buy{background:#e8f5e9;border-color:#c8e6c9}
.v2-more{margin:6px 0 22px}
.v2-more summary{cursor:pointer;color:#1565c0;font-size:14px;padding:8px 0}
.v2-shop{margin-top:30px}
.v2-shop h3{font-size:16px;color:#333;margin-bottom:12px;border-left:4px solid #1976D2;padding-left:10px}
.v2-item{display:flex;justify-content:space-between;align-items:center;gap:14px;
border:1px solid #eee;border-radius:8px;padding:12px 15px;margin-bottom:8px;background:#fff}
.v2-item-name{font-weight:600;color:#222}
.v2-item-meta{color:#888;font-size:12px;margin-top:3px}
.v2-stock{white-space:nowrap;font-size:13px;font-weight:700}
.v2-in{color:#2e7d32}.v2-out{color:#c62828}.v2-na{color:#aaa;font-style:italic;font-weight:400}
.v2-cart{background:#4CAF50;color:#fff;border:none;border-radius:6px;padding:8px 14px;
font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
.v2-cart:disabled{background:#ccc;cursor:not-allowed}
.v2-hint{background:#fffdf3;border:1px dashed #e6d9a8;border-radius:8px;padding:10px 14px;
font-size:12px;color:#8a7a3a;margin-top:20px}
</style>
<div class="v2">
<span class="v2-flag">◇ Variante B – geführte Ansicht (Prototyp)</span>
<div class="v2-head"><?php echo htmlspecialchars($v2_headline); ?></div>
<div class="v2-sub">
<?php echo count($v2_strong); ?> passende Geräte
· <?php echo count($g_buy); ?> kaufbare Artikel
<?php if (count($g_succ)) echo ' · '.count($g_succ).' mit Nachfolger'; ?>
<?php if (count($g_dead)) echo ' · '.count($g_dead).' ausgelaufen'; ?>
<?php if (count($v2_weak)) echo ' · '.count($v2_weak).' weitere mögliche Geräte'; ?>
</div>
<?php foreach ($v2_strong as $i => $d):
list($clabel,$ccol,$cbg,$cicon) = v2_confidence($d['score']);
$parts = $d['ersatzteile'] ?? [];
$buy = $d['shop_items'] ?? null; // nur wenn shop_items-Toggle an
?>
<div class="v2-dev">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta">
<b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?>
· <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?>
<?php if (!empty($d['produktionsstart']) || !empty($d['produktionsende'])): ?>
· <b>Baujahr:</b> <?php echo htmlspecialchars(trim(($d['produktionsstart']??'').'–'.($d['produktionsende']??''),'–')); ?>
<?php endif; ?>
</div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;">
<?php echo $cicon.' '.htmlspecialchars($clabel); ?>
</span>
</div>
<?php if ($buy && count($buy)): ?>
<div class="v2-parts">
<h4>✓ Sofort erhältlich für dieses Gerät (<?php echo count($buy); ?>):</h4>
<?php foreach (array_slice($buy,0,8) as $b): ?>
<span class="v2-chip v2-buy"><b><?php echo htmlspecialchars($b['number']); ?></b>
· <?php echo htmlspecialchars(mb_strimwidth($b['name'],0,42,'…')); ?>
<?php if ((int)$b['inventory']>0) echo '· '.(int)$b['inventory'].' Stk'; ?></span>
<?php endforeach; ?>
</div>
<?php elseif (count($parts)): ?>
<div class="v2-parts">
<h4><?php echo count($parts); ?> passende Ersatzteile — Auswahl:</h4>
<?php foreach (array_slice($parts,0,10) as $p): ?>
<span class="v2-chip"><b><?php echo htmlspecialchars($p['originalnummer']); ?></b>
<?php if (!empty($p['marke'])) echo '· '.htmlspecialchars($p['marke']); ?></span>
<?php endforeach; ?>
<?php if (count($parts) > 10): ?>
<span class="v2-chip" style="border-style:dashed;color:#999;">+<?php echo count($parts)-10; ?> weitere</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (count($v2_weak)): ?>
<details class="v2-more">
<summary>Weitere mögliche Geräte anzeigen (<?php echo count($v2_weak); ?>) – schwächere Treffer</summary>
<?php foreach ($v2_weak as $d): list($clabel,$ccol,$cbg,$cicon)=v2_confidence($d['score']); ?>
<div class="v2-dev" style="opacity:.75">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name" style="font-size:15px"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta"><b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?> · <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?></div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;"><?php echo $cicon.' '.htmlspecialchars($clabel); ?></span>
</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php $keep_shop = $show_shop_items ? '&shop_items=1' : ''; ?>
<?php if (count($g_buy)): ?>
<div class="v2-shop">
<h3>🛒 Diese Artikel kannst du direkt kaufen</h3>
<?php foreach ($g_buy as $it):
$inv = $it['inventory'];
$can = ($inv !== null && (int)$inv > 0);
// Bilder liegen nur auf LIVE → absolute URL
$img = !empty($it['default_image_file_name'])
? 'https://intelectra.de/web/intelectra_shop/images/article/'.rawurlencode($it['default_image_file_name'])
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
?>
<div class="v2-item">
<div style="display:flex;align-items:center;gap:14px;min-width:0">
<?php if ($img): ?>
<img src="<?php echo $img; ?>" alt="" loading="lazy"
style="width:64px;height:64px;object-fit:contain;background:#fff;border:1px solid #eee;border-radius:6px;flex:0 0 64px"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width:64px;height:64px;flex:0 0 64px;background:#f5f5f5;border:1px dashed #ddd;border-radius:6px;
display:flex;align-items:center;justify-content:center;color:#bbb;font-size:11px">kein Bild</div>
<?php endif; ?>
<div style="min-width:0">
<div class="v2-item-name"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
</div>
<div style="text-align:right;flex:0 0 auto">
<div style="font-size:17px;font-weight:700;color:#1a1a1a;margin-bottom:2px">
<?php if ($bb > 0 && $pb > 0 && $bb < $pb): ?>
<span style="color:#c62828"><?php echo number_format($bb, 2, ',', '.'); ?>&nbsp;€</span>
<span style="font-size:12px;color:#999;text-decoration:line-through;font-weight:400"><?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€</span>
<?php elseif ($pb > 0): ?>
<?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€
<?php else: ?>
<span style="font-size:12px;color:#999;font-weight:400">Preis auf Anfrage</span>
<?php endif; ?>
</div>
<?php if ($pb > 0): ?><div style="font-size:10px;color:#aaa;margin-bottom:4px">inkl. MwSt.</div><?php endif; ?>
<div class="v2-stock <?php echo $inv===null?'v2-na':($can?'v2-in':'v2-out'); ?>">
<?php echo $inv===null ? 'Lager k.A.' : ($can ? '● '.(int)$inv.' ab Lager' : '○ nicht am Lager'); ?>
</div>
<button class="v2-cart" <?php echo $can?'':'disabled'; ?> style="margin-top:6px">
<?php echo $can ? 'In den Warenkorb' : 'Bestellbar'; ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_succ)): ?>
<div class="v2-shop">
<h3 style="border-left-color:#fb8c00">🔄 Ausgelaufen – aber es gibt einen Nachfolger</h3>
<?php foreach ($g_succ as $it): $nf = $it['_nml']['nachfolger']; ?>
<div class="v2-item" style="background:#fff8f0;border-color:#ffe0b2">
<div>
<div class="v2-item-name" style="color:#8a6d3b;text-decoration:line-through;text-decoration-color:#d9b38c">
<?php echo htmlspecialchars($it['_nml']['clean']); ?>
</div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?> · nicht mehr lieferbar</div>
</div>
<div style="text-align:right">
<div style="font-size:12px;color:#e65100;font-weight:600;margin-bottom:6px">Nachfolger: <?php echo htmlspecialchars($nf); ?></div>
<a href="?q=<?php echo urlencode($nf).$keep_shop; ?>"
class="v2-cart" style="background:#fb8c00;text-decoration:none;display:inline-block">
→ Nachfolger anzeigen
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_dead)): ?>
<details class="v2-more" style="margin-top:20px">
<summary>Nicht mehr lieferbar, kein Ersatz (<?php echo count($g_dead); ?>) – ausgeblendet</summary>
<?php foreach ($g_dead as $it): ?>
<div class="v2-item" style="opacity:.55">
<div>
<div class="v2-item-name" style="text-decoration:line-through"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
<div class="v2-stock v2-out" style="white-space:nowrap">✕ entfällt</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php if (!count($g_buy) && !count($g_succ) && !count($g_dead)): ?>
<!-- keine Artikel -->
<?php endif; ?>
<div class="v2-hint">
💡 <b>Prototyp-Notiz (nicht final):</b> Bild (aus <code>items.default_image_file_name</code>, geladen vom Live-Server)
+ Preis (<code>item_prices</code> Kundengruppe 1, brutto inkl. Steuersatz, Angebotspreis rot) sind jetzt drin.
<b>nml/Nachfolger ist jetzt live:</b> Der Marker <code>(nml)</code>/<code>(nmle)</code> im Artikelnamen wird erkannt —
ausgelaufene Teile <b>mit</b> „Nachfolger &lt;Nr&gt;" landen im orangen Block und verlinken per Klick auf den kaufbaren Nachfolger;
Teile <b>ohne</b> Ersatz werden ans Ende ausgeblendet. Score-Zahlen durch Vertrauens-Text ersetzt.
Tipp: oben „Nur verfügbare Shop-Artikel" aktivieren zeigt pro Gerät die kaufbaren Teile grün.
</div>
</div>
<?php else: ?>
<div class="no-results">
Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "Kohlefilter Bauknecht")
</div>
<?php endif; ?>
</div>
<script>
function toggleDevice(deviceId) {
const card = document.getElementById('device-' + deviceId);
if (card) {
card.classList.toggle('collapsed');
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,7 +21,7 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
{if $block->configuration.image_name}
{if isset($block->configuration.image_name) && $block->configuration.image_name}
{if $block->configuration.image_position == 'left'}
{$padding='padding-right:10px;'}
{elseif $block->configuration.image_position == 'right'}
@@ -38,6 +38,12 @@
<input type="hidden" id="cashdesk_action" name="cashdesk_action" value="" />
{* 25.08.2026: Kennung dieses Bestellvorgangs. Wird derselbe Wert ein zweites Mal
abgeschickt (Doppelklick, Zurueck-Taste, zweiter Tab), erkennt
website_cashdesk::bestellvorgang_bereits_erledigt() das und legt keine zweite
Bestellung an, sondern fuehrt zum Ergebnis des ersten Versuchs zurueck. *}
<input type="hidden" name="checkout_nonce" value="{$checkout_nonce|escape}" />
</form>
</div>
@@ -48,8 +48,17 @@
zu d&uuml;rfen.</p>*}
{/if}
{* 24.08.2026: Der Warenkorb bleibt beim Abbruch jetzt erhalten -- deshalb hier
ein Weg zurueck. Vorher war der Korb geleert und die Seite eine Sackgasse. *}
{if $call == 'payment_cancelled'}
<p>Ihre Bestellung wurde storniert.</p>
<p>Sie haben die Zahlung bei PayPal abgebrochen &ndash; es wurde nichts abgebucht.</p>
<p><strong>Ihr Warenkorb ist noch da.</strong> Sie k&ouml;nnen die Bestellung
erneut aufgeben oder eine andere Zahlungsart w&auml;hlen.</p>
<p>
<a class="button" style="width:200px;" href="/Kasse/">Zur&uuml;ck zur Kasse</a>
&nbsp;
<a href="/Warenkorb/">Warenkorb ansehen</a>
</p>
{/if}
{if $call == 'payment_error'}
@@ -0,0 +1,30 @@
<!-- content_cash_desk/paypalrest.tpl (PayPal Orders v2) -->
<div class="full_box">
<div class="title_wrapper">
<div class="title"><h2>PayPal</h2></div>
<div class="clear"></div>
</div>
<div class="box_content">
<div class="box_padder cashdesk_paypal_info">
{if $call == 'payment_open'}
{if $paypal_link}
<p>Vielen Dank f&uuml;r Ihre Bestellung.<br /><br />
Sie werden zu PayPal weitergeleitet. Falls das nicht automatisch
geschieht, klicken Sie bitte auf den folgenden Button.</p>
<meta http-equiv="refresh" content="0;url={$paypal_link}">
<a href="{$paypal_link}" class="button" style="width:220px;">Weiter zu PayPal</a>
<br /><br />
{else}
<p>Mit PayPal ist ein Fehler aufgetreten.</p>
{/if}
{/if}
{if $call == 'payment_error'}
<p>{$message}</p>
{/if}
</div>
</div>
</div>
@@ -190,6 +190,60 @@
<div id="footer_wrapper">
<div id="footer">
{* § 356a BGB – elektronische Widerrufsfunktion: separater, gut sichtbarer Button *}
<div id="widerruf_button_block">
<a class="widerruf_button" href="{$SUB_DIR}widerruf.php" title="Vertrag widerrufen">Vertrag widerrufen</a>
<span class="widerruf_button_hint">Elektronische Widerrufsfunktion gem.&nbsp;§&nbsp;356a&nbsp;BGB</span>
</div>
<style>
#widerruf_button_block{
position:relative !important;
display:block !important;
width:100% !important;
box-sizing:border-box !important;
padding:16px 0 18px !important;
text-align:center !important;
background:transparent !important;
border-bottom:1px solid rgba(0,0,0,.08) !important;
margin-bottom:18px !important;
z-index:5 !important;
clear:both !important;
}
#widerruf_button_block a.widerruf_button{
display:inline-block !important;
padding:12px 26px !important;
background:#c00 !important;
color:#fff !important;
font-family:Arial,Helvetica,sans-serif !important;
font-size:15px !important;
font-weight:700 !important;
letter-spacing:.3px !important;
text-transform:none !important;
text-decoration:none !important;
border:0 !important;
border-radius:6px !important;
box-shadow:0 2px 5px rgba(0,0,0,.2) !important;
transition:background .15s ease,transform .05s ease !important;
line-height:1.1 !important;
cursor:pointer !important;
}
#widerruf_button_block a.widerruf_button:hover,
#widerruf_button_block a.widerruf_button:focus{
background:#a30000 !important;
color:#fff !important;
text-decoration:none !important;
}
#widerruf_button_block a.widerruf_button:active{
transform:translateY(1px) !important;
}
#widerruf_button_block .widerruf_button_hint{
display:block !important;
margin-top:8px !important;
font-size:11px !important;
color:#666 !important;
font-weight:400 !important;
}
</style>
{foreach from=$footer_widgets item=widget}
{$widget}
{/foreach}
@@ -11,7 +11,10 @@
</span>
</a>
{/foreach}
<a title="Vertrag widerrufen" href="{$SUB_DIR}widerruf.php" class="widerruf_link">
<span>Vertrag widerrufen</span>
<a title="Widerrufsbelehrung" href="{$SUB_DIR}Widerrufsrecht/" class=" ">
<span>Widerrufsbelehrung</span>
</a>
<a title="Muster-Widerrufsformular" href="{$SUB_DIR}Widerrufsformular-Muster/" class=" ">
<span>Muster-Widerrufsformular</span>
</a>
</div>
@@ -3,6 +3,8 @@
<div class="widget_title"><span>{$template_text.main_widget_menu2.menu2_title}</span></div>
<div class="widget_content_wrapper">
{foreach from=$main_widget_menu_2.menu_items item=menu_item}
{* Widerrufsrecht (60) + Widerrufsformular (10659) leben jetzt im Footer + als Button. Hier ausblenden. *}
{if $menu_item->id != 60 && $menu_item->id != 10659}
<a title="{$menu_item->name}" href="{$SUB_DIR}{if $menu_item->short_uri}{$menu_item->short_uri}/{else}index.php?menu_id={$menu_item->id}{/if}" class=" {if isset($current_menu_id) && $menu_item->id == $current_menu_id}active{/if}"><span>&#x95;&nbsp;
{if !$user_lang || $user_lang == $system_configuration.default_system_language || !$menu_item->name_en}
{$menu_item->name}
@@ -11,6 +13,7 @@
{/if}
</span>
</a>
{/if}
{/foreach}
</div>
<div class="widget_footer"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Some files were not shown because too many files have changed in this diff Show More