Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea1daec9a | ||
|
|
91d90b6714 | ||
|
|
21977b8f41 | ||
|
|
5f3bbc2c45 | ||
|
|
b8111a1e7f | ||
|
|
d5a3727884 | ||
|
|
5844b24db4 | ||
|
|
d0b79a2820 | ||
|
|
4ef81ceec3 | ||
|
|
4c1ecb82a7 | ||
|
|
30c81cae9c | ||
|
|
def156e1c2 | ||
|
|
6cea66126a | ||
|
|
40b4774f0d | ||
|
|
85bc1d8c9a | ||
|
|
fe868641d2 | ||
|
|
0c04dc9acd | ||
|
|
f642ba7f06 | ||
|
|
8f2b69827e | ||
|
|
985e7b2e57 | ||
|
|
4c9c2c6a29 | ||
|
|
65ced5261f | ||
|
|
57a101baa8 | ||
|
|
b396c674c9 | ||
|
|
215252f9ef | ||
|
|
6361249fd0 | ||
|
|
fa836e7a10 | ||
|
|
15270289f5 | ||
|
|
33d859f454 | ||
|
|
49ac0f7e35 | ||
|
|
5c80931569 | ||
|
|
94b924fa46 | ||
|
|
7430556460 | ||
|
|
0521b4399f | ||
|
|
ae7e400645 | ||
|
|
dc57c725f6 | ||
|
|
dec101f0cb | ||
|
|
f1aef6e575 | ||
|
|
56f9a425c9 | ||
|
|
6f0036b819 | ||
|
|
f65901060f | ||
|
|
c8fc7fba2f | ||
|
|
b8bc8aa8bf | ||
|
|
7e28a5275c | ||
|
|
5e6fcb0c3d | ||
|
|
da54028a6e | ||
|
|
f33533b411 | ||
|
|
e4b3f5a1ea | ||
|
|
67af646f2e | ||
|
|
4ce09ac1b6 | ||
|
|
4248f8c148 | ||
|
|
83cf8b9808 | ||
|
|
2698d518dc | ||
|
|
37017b25dd | ||
|
|
726f897c3d | ||
|
|
397fb5944a | ||
|
|
af81e8f63a | ||
|
|
20d55f77b8 |
@@ -1,3 +1,15 @@
|
||||
# === DEV-Server: HTTP Basic Auth (kein Kunde, kein Bot) ===
|
||||
AuthType Basic
|
||||
AuthName "Dev-Server intelectra"
|
||||
AuthUserFile /etc/apache2/htpasswd_dev_intelectra
|
||||
Require valid-user
|
||||
# 13.09.2026: Webhook-Endpunkt der Zahlungsanbieter muss ohne Basic-Auth erreichbar sein
|
||||
# (PayPal stellt sonst nicht zu). Der Endpunkt prueft die Signatur selbst.
|
||||
<Files "payment_webhook.php">
|
||||
Require all granted
|
||||
</Files>
|
||||
# === ENDE Auth-Block ===
|
||||
|
||||
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
@@ -176,12 +176,27 @@ class CartSessionManager {
|
||||
public function updateCartStatus($session_key, $status) {
|
||||
$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}'
|
||||
UPDATE cart_sessions
|
||||
SET status = '{$status_clean}',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_key = '{$session_key_clean}'{$schutz}
|
||||
";
|
||||
|
||||
$result = $this->db->query($query);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -261,6 +261,14 @@ class Country extends Main {
|
||||
public static function getShippingAreaById($countryId, $customer_group_id = false) {
|
||||
$db = Registry::get('base')->db;
|
||||
|
||||
// 22.09.2026: leeres Land (Gast-Kasse per Enter abgeschickt, Land nicht gewaehlt) lief in
|
||||
// "WHERE id = " und damit in einen SQL-Fatal -> HTTP 500 mit leerer Seite in der Kasse.
|
||||
$countryId = (int) $countryId;
|
||||
$customer_group_id = (int) $customer_group_id;
|
||||
if ($countryId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = "SELECT shipping_area_id
|
||||
FROM countries
|
||||
WHERE id = $countryId";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,11 +15,21 @@ class Payment_method extends Main {
|
||||
'title' => 'Zahlungsarten',
|
||||
'db_table' => 'payment_methods',
|
||||
'list_fields' => array(
|
||||
array(
|
||||
'db_field' => 'id',
|
||||
'name' => 'ID',
|
||||
'sortable' => 1
|
||||
),
|
||||
array(
|
||||
'db_field' => 'name',
|
||||
'name' => 'Zahlungsart',
|
||||
'sortable' => 1
|
||||
),
|
||||
array(
|
||||
'db_field' => 'module',
|
||||
'name' => 'Modul',
|
||||
'sortable' => 1
|
||||
),
|
||||
array(
|
||||
'db_field' => 'active',
|
||||
'name' => 'Status',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'mollie'.
|
||||
*
|
||||
* Gilt fuer ALLE Mollie-Zahlarten (Klarna, Kreditkarte, Apple Pay, Google Pay ...): welche
|
||||
* Methode gemeint ist, steht in payment_methods.argument der gewaehlten Zahlart; der
|
||||
* Provider liest sie ueber die Bestellung. Kopie des PayPal-v2-Adapters ohne Express.
|
||||
*/
|
||||
require_once __DIR__ . '/../payments/Registry.php';
|
||||
|
||||
class MolliePaymentMethod
|
||||
{
|
||||
private const MODULE = 'mollie';
|
||||
|
||||
private $base_object;
|
||||
private $layout_object;
|
||||
private $customer_data;
|
||||
private $payment_method;
|
||||
private $gift_certificate = false;
|
||||
private $order_id = false;
|
||||
|
||||
public function __construct($base_object, $layout_object)
|
||||
{
|
||||
$this->base_object = $base_object;
|
||||
$this->layout_object = $layout_object;
|
||||
}
|
||||
|
||||
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
|
||||
{
|
||||
$this->customer_data = $customer;
|
||||
$this->payment_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;
|
||||
}
|
||||
|
||||
/** Vor dem Speichern: Anbieter einsatzbereit und Methode gesetzt? Sonst Fehlertext fuer die Kasse. */
|
||||
public function before_order($argument = null)
|
||||
{
|
||||
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
|
||||
if (!$provider) {
|
||||
return 'Diese Zahlart ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
|
||||
}
|
||||
$method = \strtolower(\trim((string) $argument));
|
||||
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
|
||||
\error_log('Mollie: Zahlart ohne Methode im Feld argument (payment_methods.id ' . ($this->payment_method->id ?? '?') . ')');
|
||||
return 'Diese Zahlart ist nicht richtig eingerichtet. Bitte waehlen Sie eine andere Zahlart.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public function during_order($argument = null)
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem Speichern: Zahlung bei Mollie anlegen, Kunde per 302 zur Checkout-Seite.
|
||||
*/
|
||||
public function after_order($order_total = false)
|
||||
{
|
||||
$amount = (float) $order_total;
|
||||
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
|
||||
$amount -= (float) $this->gift_certificate->with_vat;
|
||||
}
|
||||
$amount = \round($amount, 2);
|
||||
$label = $this->label();
|
||||
|
||||
try {
|
||||
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
|
||||
if (!$provider) {
|
||||
throw new \RuntimeException('Provider mollie nicht installiert');
|
||||
}
|
||||
$url = $provider->start((int) $this->order_id, $amount);
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('Mollie after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
|
||||
if (\function_exists('shop_event')) {
|
||||
\shop_event('failed', [
|
||||
'order_id' => $this->order_id, 'betrag' => $amount,
|
||||
'customer_id' => $this->customer_data->id ?? null,
|
||||
'detail' => 'mollie: start fehlgeschlagen -- ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->layout_object->assign('call', 'payment_error');
|
||||
$this->layout_object->assign('provider_label', $label);
|
||||
$this->layout_object->assign('message', 'Die Zahlung ueber ' . \htmlspecialchars($label) . ' konnte nicht gestartet werden. Ihr Warenkorb ist noch da – bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
|
||||
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
|
||||
}
|
||||
|
||||
if (!\headers_sent()) {
|
||||
\header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
$this->layout_object->assign('call', 'payment_open');
|
||||
$this->layout_object->assign('provider_label', $label);
|
||||
$this->layout_object->assign('payment_link', $url);
|
||||
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
|
||||
}
|
||||
|
||||
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=mollie), nicht hier. */
|
||||
public function change_order()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Anzeigename der gewaehlten Zahlart (z.B. "Klarna"), Rueckfall "Mollie". */
|
||||
private function label(): string
|
||||
{
|
||||
$name = \trim((string) ($this->payment_method->name ?? ''));
|
||||
return $name !== '' ? $name : 'Mollie';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'paypal_v2'.
|
||||
*
|
||||
* Duenn mit Absicht. Der Cashdesk erwartet diese Klasse (ucfirst(module).'PaymentMethod')
|
||||
* mit set_data/before_order/during_order/after_order; alles Fachliche liegt im Provider
|
||||
* unter core/payments/providers/paypal_v2/. Ein weiterer Anbieter bekommt eine Kopie
|
||||
* dieser Datei mit anderem Modulnamen -- mehr nicht.
|
||||
*/
|
||||
require_once __DIR__ . '/../payments/Registry.php';
|
||||
|
||||
class Paypal_v2PaymentMethod
|
||||
{
|
||||
private const MODULE = 'paypal_v2';
|
||||
|
||||
private $base_object;
|
||||
private $layout_object;
|
||||
private $customer_data;
|
||||
private $gift_certificate = false;
|
||||
private $order_id = false;
|
||||
|
||||
public function __construct($base_object, $layout_object)
|
||||
{
|
||||
$this->base_object = $base_object;
|
||||
$this->layout_object = $layout_object;
|
||||
}
|
||||
|
||||
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
|
||||
{
|
||||
$this->customer_data = $customer;
|
||||
$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;
|
||||
}
|
||||
|
||||
/** Vor dem Speichern: Anbieter einsatzbereit? Sonst Fehlertext fuer die Kasse. */
|
||||
public function before_order($argument = null)
|
||||
{
|
||||
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
|
||||
if (!$provider) {
|
||||
return 'PayPal ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public function during_order($argument = null)
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem Speichern: Zahlung anlegen, Kunde per 302 zum Anbieter.
|
||||
* Der POST bekommt damit einen Redirect als Antwort -- die Zurueck-Taste landet auf
|
||||
* dem Kassenformular, nicht auf einem erneut abschickbaren POST.
|
||||
*/
|
||||
public function after_order($order_total = false)
|
||||
{
|
||||
$amount = (float) $order_total;
|
||||
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
|
||||
$amount -= (float) $this->gift_certificate->with_vat;
|
||||
}
|
||||
$amount = \round($amount, 2);
|
||||
|
||||
try {
|
||||
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
|
||||
if (!$provider) {
|
||||
throw new \RuntimeException('Provider paypal_v2 nicht installiert');
|
||||
}
|
||||
|
||||
// Express aus dem Warenkorb: der Kunde war schon bei PayPal, Adresse liegt vor.
|
||||
// Betrag nachziehen und einziehen -- ohne zweiten PayPal-Besuch.
|
||||
$express = $_SESSION['easyshop']['payment_express'] ?? null;
|
||||
if (\is_array($express) && ($express['module'] ?? '') === self::MODULE && !empty($express['reference'])) {
|
||||
$url = $provider->finishExpress((int) $this->order_id, $amount, (string) $express['reference']);
|
||||
unset($_SESSION['easyshop']['payment_express']);
|
||||
if ($url === '') {
|
||||
\header('Location: /Kasse/?success=1&order=' . \rawurlencode($this->resolve_order_number((int) $this->order_id)));
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$url = $provider->start((int) $this->order_id, $amount);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('PayPal v2 after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
|
||||
if (\function_exists('shop_event')) {
|
||||
\shop_event('failed', [
|
||||
'order_id' => $this->order_id, 'betrag' => $amount,
|
||||
'customer_id' => $this->customer_data->id ?? null,
|
||||
'detail' => 'paypal_v2: start fehlgeschlagen -- ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->layout_object->assign('call', 'payment_error');
|
||||
$this->layout_object->assign('provider_label', 'PayPal');
|
||||
$this->layout_object->assign('message', 'PayPal konnte nicht gestartet werden. Ihr Warenkorb ist noch da – bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
|
||||
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
|
||||
}
|
||||
|
||||
if (!\headers_sent()) {
|
||||
\header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
// Es wurde schon Output gesendet: Weiterleitungsseite mit Link + Meta-Refresh.
|
||||
$this->layout_object->assign('call', 'payment_open');
|
||||
$this->layout_object->assign('provider_label', 'PayPal');
|
||||
$this->layout_object->assign('payment_link', $url);
|
||||
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
|
||||
}
|
||||
|
||||
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=paypal_v2), nicht hier. */
|
||||
public function change_order()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolve_order_number(int $orderId): string
|
||||
{
|
||||
$res = $this->base_object->db->query('SELECT order_number FROM orders WHERE id=' . $orderId . ' LIMIT 1');
|
||||
$row = $res ? $res->fetch_object() : null;
|
||||
return $row ? (string) $row->order_number : (string) $orderId;
|
||||
}
|
||||
}
|
||||
@@ -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,7 +273,8 @@ 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 ?? []));
|
||||
} else {
|
||||
@@ -209,25 +299,84 @@ 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. ');
|
||||
|
||||
|
||||
// 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'])) {
|
||||
$token = $_GET['token'] ?? '';
|
||||
|
||||
|
||||
// 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,526 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Gemeinsamer Unterbau aller Anbieter mit Weiterleitung.
|
||||
*
|
||||
* Hier liegt alles, was 2026 am v1-PayPal muehsam nachgeruestet wurde und fuer
|
||||
* jeden weiteren Anbieter genauso gelten muss:
|
||||
* - Vorgang in payment_transactions mit echter Uhrzeit, Session und Sollbetrag
|
||||
* - Bestellbestaetigung ERST nach eingezogener Zahlung
|
||||
* - Warenkorb ERST nach eingezogener Zahlung leeren (ueber die Session von damals)
|
||||
* - idempotentes "bezahlt": zweite Rueckkehr, Webhook-Race, Abgleicher -- egal wer
|
||||
* zuerst kommt, es passiert genau einmal
|
||||
* - Betragsabgleich gegen den Sollbetrag (Manipulationsschutz)
|
||||
* - Ereignisse fuer den Funnel (shop_event) und Rohprotokoll (Payments\Log)
|
||||
*
|
||||
* Einstellungen kommen aus der Konfigurationsgruppe = module() (Tabelle configuration,
|
||||
* pflegbar im Zahlart-Editor), Zugangsdaten aus Payments\Secrets.
|
||||
*/
|
||||
abstract class AbstractProvider implements ProviderInterface
|
||||
{
|
||||
protected object $base;
|
||||
protected \mysqli $db;
|
||||
|
||||
/** @var array<string,string>|null */
|
||||
private ?array $settings = null;
|
||||
|
||||
public function __construct(object $base)
|
||||
{
|
||||
$this->base = $base;
|
||||
$this->db = $base->db;
|
||||
}
|
||||
|
||||
public function paysAfterRedirect(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Einstellungen
|
||||
|
||||
/** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */
|
||||
protected function setting(string $name, string $default = ''): string
|
||||
{
|
||||
if ($this->settings === null) {
|
||||
$this->settings = [];
|
||||
$g = $this->db->real_escape_string($this->module());
|
||||
$res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'");
|
||||
while ($res && ($row = $res->fetch_assoc())) {
|
||||
$this->settings[(string) $row['name']] = (string) $row['setting'];
|
||||
}
|
||||
}
|
||||
$full = $this->module() . '_' . $name;
|
||||
$v = $this->settings[$full] ?? $this->settings[$name] ?? '';
|
||||
return $v !== '' ? $v : $default;
|
||||
}
|
||||
|
||||
protected function secret(string $name): string
|
||||
{
|
||||
$v = Secrets::get($this->db, $this->module(), $name);
|
||||
if ($v === null || $v === '') {
|
||||
throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).');
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
protected function shopBaseUrl(): string
|
||||
{
|
||||
if (\defined('MAIN_URL') && MAIN_URL !== '') {
|
||||
return \rtrim((string) MAIN_URL, '/');
|
||||
}
|
||||
return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
||||
}
|
||||
|
||||
/** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */
|
||||
protected function returnUrl(): string
|
||||
{
|
||||
return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module();
|
||||
}
|
||||
|
||||
protected function cancelUrl(): string
|
||||
{
|
||||
return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module();
|
||||
}
|
||||
|
||||
/** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */
|
||||
protected function webhookUrl(): string
|
||||
{
|
||||
return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Vorgang
|
||||
|
||||
/**
|
||||
* Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL.
|
||||
* Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an).
|
||||
*/
|
||||
protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void
|
||||
{
|
||||
$order = $this->orderById($orderId);
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO payment_transactions
|
||||
(provider, order_id, order_number, reference, expected_amount, currency, status,
|
||||
approve_url, session_id, customer_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())
|
||||
ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount),
|
||||
status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()'
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new \RuntimeException('payment_transactions: ' . $this->db->error);
|
||||
}
|
||||
$module = $this->module();
|
||||
$number = (string) ($order->order_number ?? '');
|
||||
$currency = $this->currency();
|
||||
$status = 'open';
|
||||
$sid = \function_exists('session_id') ? (string) \session_id() : '';
|
||||
$cid = (int) ($order->customer_id ?? 0);
|
||||
$stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$ref = $this->db->real_escape_string($reference);
|
||||
$this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId");
|
||||
|
||||
$this->pendingSet($orderId, $reference, $amount, $approveUrl);
|
||||
$this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid);
|
||||
Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es zu dieser Anbieter-Referenz schon einen Vorgang (mit Bestellung)?
|
||||
* Braucht die Kasse bei der Express-Rueckkehr: verlangt PayPal nach dem Betrags-PATCH
|
||||
* eine zweite Freigabe, kommt der Kunde ueber die Express-URL zurueck, obwohl die
|
||||
* Bestellung laengst existiert -- dann ist es eine normale Rueckkehr mit Capture.
|
||||
*/
|
||||
public function knownReference(string $reference): bool
|
||||
{
|
||||
return $reference !== '' && $this->transactionByReference($reference) !== null;
|
||||
}
|
||||
|
||||
protected function transactionByReference(string $reference): ?object
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1');
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
$m = $this->module();
|
||||
$stmt->bind_param('ss', $m, $reference);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_object();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
protected function transactionByOrder(int $orderId): ?object
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1');
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
$m = $this->module();
|
||||
$stmt->bind_param('si', $m, $orderId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_object();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Offene Vorgaenge fuer den Abgleicher. @return list<object> */
|
||||
protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT * FROM payment_transactions
|
||||
WHERE provider=? AND status IN ("open","pending")
|
||||
AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE)
|
||||
AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
|
||||
ORDER BY id'
|
||||
);
|
||||
if (!$stmt) {
|
||||
return [];
|
||||
}
|
||||
$m = $this->module();
|
||||
$stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
$stmt->close();
|
||||
return \array_map(static fn($r) => (object) $r, $rows);
|
||||
}
|
||||
|
||||
protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void
|
||||
{
|
||||
$stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?');
|
||||
if ($stmt) {
|
||||
$m = $this->module();
|
||||
$stmt->bind_param('ssss', $status, $detail, $m, $reference);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- bezahlt
|
||||
|
||||
/**
|
||||
* Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab.
|
||||
* Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist.
|
||||
*
|
||||
* Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid
|
||||
* schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher
|
||||
* laufen dann ins Leere statt in Doppelmails.
|
||||
*/
|
||||
protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool
|
||||
{
|
||||
$tx = $this->transactionByReference($reference);
|
||||
if (!$tx) {
|
||||
\error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')');
|
||||
return false;
|
||||
}
|
||||
$orderId = (int) $tx->order_id;
|
||||
|
||||
if ($tx->status === 'paid') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) {
|
||||
$msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency";
|
||||
\error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg");
|
||||
$this->setTransactionStatus($reference, 'mismatch', $msg);
|
||||
$this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg);
|
||||
Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Der eine Schreiber gewinnt.
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=?
|
||||
WHERE provider=? AND reference=? AND status IN ("open","pending")'
|
||||
);
|
||||
if (!$stmt) {
|
||||
return false;
|
||||
}
|
||||
$m = $this->module();
|
||||
$amt = (float) $paidAmount;
|
||||
$stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference);
|
||||
$stmt->execute();
|
||||
$won = $stmt->affected_rows === 1;
|
||||
$stmt->close();
|
||||
if (!$won) {
|
||||
return $this->transactionByReference($reference)?->status === 'paid';
|
||||
}
|
||||
|
||||
$cap = $this->db->real_escape_string($captureId);
|
||||
$eml = $this->db->real_escape_string($payerEmail);
|
||||
$pid = $this->db->real_escape_string($payerId);
|
||||
$this->db->query(
|
||||
"UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'"
|
||||
. ($payerEmail !== '' ? ", paypal_email='$eml'" : '')
|
||||
. ($payerId !== '' ? ", paypal_payerid='$pid'" : '')
|
||||
. " WHERE id=$orderId"
|
||||
);
|
||||
|
||||
$this->clearCart((string) $tx->session_id);
|
||||
$this->sendOrderConfirmation($orderId, (int) $tx->customer_id);
|
||||
$this->pendingClear();
|
||||
$this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */
|
||||
protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void
|
||||
{
|
||||
$tx = $this->transactionByReference($reference);
|
||||
if (!$tx || $tx->status === 'paid') {
|
||||
return;
|
||||
}
|
||||
// 27.09.2026: idempotent -- Rueckkehr UND Webhook melden denselben Abbruch, der
|
||||
// Stornotext wurde sonst zweimal an die Bestellung gehaengt (Mollie-Test 290617).
|
||||
if ($tx->status === 'cancelled') {
|
||||
return;
|
||||
}
|
||||
$this->setTransactionStatus($reference, 'cancelled', $why);
|
||||
$orderId = (int) $tx->order_id;
|
||||
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
|
||||
// 22.09.2026 -- dazu die Storno-Felder der Bestellung fuellen, damit im Bestell-Editor
|
||||
// steht, WARUM: bisher stand da nur "Storniert", der Grund lag allein in
|
||||
// payment_transactions.detail. Vorhandene Eintraege werden nicht ueberschrieben.
|
||||
$msg = $this->db->real_escape_string(
|
||||
$this->label() . ': ' . $why . '. Zahlung nicht abgeschlossen, kein Geld geflossen. '
|
||||
. 'Automatisch storniert (' . $source . ', ' . \date('d.m.Y H:i') . ').'
|
||||
);
|
||||
$reason = $this->cancellationReasonId();
|
||||
$this->db->query(
|
||||
"UPDATE orders SET order_status=6,
|
||||
cancellation_date = COALESCE(cancellation_date, CURDATE()),
|
||||
cancellation_status = IF(cancellation_status IS NULL OR cancellation_status=0, 2, cancellation_status),
|
||||
cancellation_reason_id = " . ($reason > 0 ? "COALESCE(NULLIF(cancellation_reason_id,0), $reason)" : 'cancellation_reason_id') . ",
|
||||
cancellation_info = IF(cancellation_info IS NULL OR cancellation_info='', '$msg', CONCAT(cancellation_info, '\n', '$msg'))
|
||||
WHERE id=$orderId AND payment_status<>2"
|
||||
);
|
||||
$this->pendingClear();
|
||||
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
|
||||
Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why);
|
||||
}
|
||||
|
||||
/** Stornogrund "Zahlung beim Anbieter nicht abgeschlossen" (install.sql), 0 wenn nicht angelegt. */
|
||||
private function cancellationReasonId(): int
|
||||
{
|
||||
$res = $this->db->query("SELECT id FROM cancellation_reasons WHERE name='Zahlung beim Anbieter nicht abgeschlossen' LIMIT 1");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
return $r ? (int) $r->id : 0;
|
||||
}
|
||||
|
||||
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
|
||||
{
|
||||
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
|
||||
&& \strtoupper($expCur) === \strtoupper($paidCur);
|
||||
}
|
||||
|
||||
protected function currency(): string
|
||||
{
|
||||
return \strtoupper($this->setting('currency', 'EUR'));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- Shop-Anbindung
|
||||
|
||||
protected function orderById(int $orderId): object
|
||||
{
|
||||
$res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1");
|
||||
$row = $res ? $res->fetch_object() : null;
|
||||
if (!$row) {
|
||||
throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */
|
||||
protected function shippingAddress(object $order): array
|
||||
{
|
||||
$iso = 'DE';
|
||||
$cid = (int) ($order->shipping_country ?? 0);
|
||||
if ($cid > 0) {
|
||||
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
if ($r && \strlen((string) $r->iso_code_2) === 2) {
|
||||
$iso = \strtoupper((string) $r->iso_code_2);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))),
|
||||
'company' => (string) ($order->shipping_company ?? ''),
|
||||
'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))),
|
||||
'zip' => (string) ($order->shipping_zip_code ?? ''),
|
||||
'city' => (string) ($order->shipping_city ?? ''),
|
||||
'country' => $iso,
|
||||
'email' => (string) ($order->customer_email ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann
|
||||
* mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine.
|
||||
*/
|
||||
protected function clearCart(string $originalSessionId): void
|
||||
{
|
||||
$ids = [];
|
||||
if ($originalSessionId !== '') {
|
||||
$ids[$originalSessionId] = true;
|
||||
}
|
||||
if (\function_exists('session_id') && \session_id() !== '') {
|
||||
$ids[\session_id()] = true;
|
||||
}
|
||||
foreach (\array_keys($ids) as $sid) {
|
||||
$s = $this->db->real_escape_string((string) $sid);
|
||||
$this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und
|
||||
* geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine
|
||||
* Fehlerseite fuer eine erfolgreiche Bestellung zeigen.
|
||||
*/
|
||||
protected function sendOrderConfirmation(int $orderId, int $customerId): void
|
||||
{
|
||||
try {
|
||||
if (!\class_exists('Cs_ticket')) {
|
||||
$docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2);
|
||||
require_once $docroot . '/core/cs_ticket.class.php';
|
||||
}
|
||||
$ticket = new \Cs_ticket($this->base);
|
||||
$ticket->event(3, $customerId, $orderId);
|
||||
Log::write($this->module(), 'mail', $orderId, null, null, 'sent');
|
||||
} catch (\Throwable $e) {
|
||||
\error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage());
|
||||
Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Session-Riegel
|
||||
|
||||
/** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */
|
||||
protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void
|
||||
{
|
||||
if (\session_status() !== PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
$_SESSION['easyshop']['payment_pending'] = [
|
||||
'module' => $this->module(),
|
||||
'reference' => $reference,
|
||||
'order_id' => $orderId,
|
||||
'total' => \round($amount, 2),
|
||||
'approve_url' => $approveUrl,
|
||||
'time' => \time(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function pendingClear(): void
|
||||
{
|
||||
if (\session_status() === PHP_SESSION_ACTIVE) {
|
||||
unset($_SESSION['easyshop']['payment_pending']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */
|
||||
public function isPaid(int $orderId): bool
|
||||
{
|
||||
$res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
return $r && (string) $r->payment_status === '2';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Events
|
||||
|
||||
protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void
|
||||
{
|
||||
if (!\function_exists('shop_event')) {
|
||||
return;
|
||||
}
|
||||
\shop_event($name, [
|
||||
'order_id' => $orderId,
|
||||
'order_number' => $orderNumber,
|
||||
'payment_method' => $this->paymentMethodId(),
|
||||
'betrag' => $amount,
|
||||
'paypal_token' => $reference,
|
||||
'transaction_id' => $transactionId,
|
||||
'customer_id' => $customerId,
|
||||
'detail' => \trim($this->module() . ': ' . (string) $detail),
|
||||
]);
|
||||
}
|
||||
|
||||
private ?int $pmId = null;
|
||||
|
||||
protected function paymentMethodId(): ?int
|
||||
{
|
||||
if ($this->pmId === null) {
|
||||
$m = $this->db->real_escape_string($this->module());
|
||||
$res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
$this->pmId = $r ? (int) $r->id : 0;
|
||||
}
|
||||
return $this->pmId ?: null;
|
||||
}
|
||||
|
||||
public function secretFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Express-Defaults
|
||||
|
||||
public function supportsExpress(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function startExpress(float $amount): string
|
||||
{
|
||||
throw new \RuntimeException($this->label() . ': kein Express-Weg');
|
||||
}
|
||||
|
||||
public function onExpressReturn(array $query): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function finishExpress(int $orderId, float $amount, string $reference): string
|
||||
{
|
||||
throw new \RuntimeException($this->label() . ': kein Express-Weg');
|
||||
}
|
||||
|
||||
/** Express-Rueckkehr laeuft ueber den Cashdesk, ohne Bestellung. */
|
||||
protected function expressReturnUrl(): string
|
||||
{
|
||||
return $this->shopBaseUrl() . '/Kasse/?payment_express=' . $this->module();
|
||||
}
|
||||
|
||||
protected function expressCancelUrl(): string
|
||||
{
|
||||
return $this->shopBaseUrl() . '/Warenkorb/?payment_express_cancel=' . $this->module();
|
||||
}
|
||||
|
||||
/** Laender, aus denen Express-Adressen angenommen werden (ISO-2, kommagetrennt). Standard: nur DE. */
|
||||
public function expressCountries(): string
|
||||
{
|
||||
return $this->setting('express_countries', 'DE');
|
||||
}
|
||||
|
||||
/** countries.id zu einem ISO-3166-Alpha-2-Code, 0 wenn das Land im Shop nicht lieferbar ist. */
|
||||
public function countryIdByIso(string $iso): int
|
||||
{
|
||||
$iso = $this->db->real_escape_string(\strtoupper($iso));
|
||||
$res = $this->db->query("SELECT id FROM countries WHERE iso_code_2='$iso' ORDER BY rang DESC, id LIMIT 1");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
return $r ? (int) $r->id : 0;
|
||||
}
|
||||
|
||||
/** "Neuer Weg 5a" -> ["Neuer Weg", "5a"]; ohne erkennbare Nummer bleibt alles Strasse. */
|
||||
public static function splitStreet(string $line): array
|
||||
{
|
||||
$line = \trim($line);
|
||||
if (\preg_match('/^(.*?)[\s,]+(\d+[\s\-\/]?[a-zA-Z0-9\-\/]*)$/u', $line, $m)) {
|
||||
return [\trim($m[1]), \trim($m[2])];
|
||||
}
|
||||
return [$line, ''];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Protokoll aller Anbieter-Antworten in PostgreSQL (krempl_data.payment_log).
|
||||
*
|
||||
* Warum PG: MySQL `orders` hat keine Uhrzeit und keinen Platz fuer JSON. Hier liegt
|
||||
* jede Anfrage/Antwort mit Zeitstempel -- die Beweiskette bei Rueckfragen von
|
||||
* Kunden oder Anbietern.
|
||||
*
|
||||
* Faellt STILL aus (wie shop_event): ein Protokoll darf den Checkout nie gefaehrden.
|
||||
* Verbindung kommt aus core/postgres/config.ini, Sektion [postgresql].
|
||||
*/
|
||||
final class Log
|
||||
{
|
||||
/** @var resource|\PgSql\Connection|null|false */
|
||||
private static $conn = null;
|
||||
private static bool $dead = false;
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $payload wird als JSON abgelegt (Secrets vorher entfernen!)
|
||||
*/
|
||||
public static function write(
|
||||
string $provider,
|
||||
string $action,
|
||||
?int $orderId = null,
|
||||
?string $orderNumber = null,
|
||||
?string $reference = null,
|
||||
?string $status = null,
|
||||
?int $httpStatus = null,
|
||||
?array $payload = null,
|
||||
?string $detail = null
|
||||
): bool {
|
||||
$c = self::conn();
|
||||
if (!$c) {
|
||||
return false;
|
||||
}
|
||||
$json = $payload === null ? null : \json_encode(self::scrub($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$ok = @\pg_query_params(
|
||||
$c,
|
||||
'INSERT INTO payment_log
|
||||
(provider, action, order_id, order_number, reference, status, http_status, payload, detail, host)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||||
[
|
||||
$provider, $action, $orderId, $orderNumber, $reference, $status, $httpStatus,
|
||||
$json, $detail, (string) ($_SERVER['SERVER_NAME'] ?? \gethostname()),
|
||||
]
|
||||
);
|
||||
if (!$ok) {
|
||||
self::$dead = true;
|
||||
\error_log('payment_log: INSERT fehlgeschlagen -- ' . @\pg_last_error($c));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf einer Bestellung fuer den Bestell-Editor, aelteste zuerst.
|
||||
* Payload bleibt draussen (Roh-JSON gehoert nicht in die Maske).
|
||||
*
|
||||
* @return list<array{zeit:string,action:string,status:?string,http_status:?string,detail:?string,reference:?string}>
|
||||
*/
|
||||
public static function forOrder(int $orderId, int $limit = 40): array
|
||||
{
|
||||
$c = self::conn();
|
||||
if (!$c || $orderId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$host = (string) ($_SERVER['SERVER_NAME'] ?? \gethostname());
|
||||
$res = @\pg_query_params(
|
||||
$c,
|
||||
"SELECT to_char(zeitpunkt, 'DD.MM.YYYY HH24:MI:SS') AS zeit, action, status, http_status, detail, reference
|
||||
FROM payment_log
|
||||
WHERE order_id = $1 AND host = $2
|
||||
ORDER BY id
|
||||
LIMIT $3",
|
||||
[$orderId, $host, $limit]
|
||||
);
|
||||
if (!$res) {
|
||||
return [];
|
||||
}
|
||||
return \pg_fetch_all($res) ?: [];
|
||||
}
|
||||
|
||||
/** @return resource|\PgSql\Connection|false */
|
||||
private static function conn()
|
||||
{
|
||||
if (self::$dead) {
|
||||
return false;
|
||||
}
|
||||
if (self::$conn !== null) {
|
||||
return self::$conn;
|
||||
}
|
||||
if (!\function_exists('pg_connect')) {
|
||||
self::$dead = true;
|
||||
return false;
|
||||
}
|
||||
$ini = @\parse_ini_file(\dirname(__DIR__) . '/postgres/config.ini', true);
|
||||
if (!$ini || empty($ini['postgresql'])) {
|
||||
self::$dead = true;
|
||||
return false;
|
||||
}
|
||||
$p = $ini['postgresql'];
|
||||
$dsn = \sprintf(
|
||||
'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
|
||||
$p['host'] ?? 'localhost', $p['port'] ?? '5432', $p['database'] ?? '', $p['user'] ?? '', $p['password'] ?? ''
|
||||
);
|
||||
$c = @\pg_connect($dsn);
|
||||
if (!$c) {
|
||||
self::$dead = true;
|
||||
\error_log('payment_log: keine PG-Verbindung -- Protokoll uebersprungen');
|
||||
return false;
|
||||
}
|
||||
return self::$conn = $c;
|
||||
}
|
||||
|
||||
/** Entfernt Schluessel, die nach Zugangsdaten aussehen, rekursiv. */
|
||||
private static function scrub(array $data): array
|
||||
{
|
||||
foreach ($data as $k => $v) {
|
||||
if (\is_string($k) && \preg_match('/secret|password|token|authorization|client_id/i', $k)) {
|
||||
$data[$k] = '***';
|
||||
} elseif (\is_array($v)) {
|
||||
$data[$k] = self::scrub($v);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Lesesicht auf einen Zahlungsvorgang fuers Backend (Bestell-Editor, Block "Zahlungsvorgang").
|
||||
*
|
||||
* Anbieterneutral: liest payment_transactions (MySQL) und den Verlauf aus payment_log (PG).
|
||||
* Kein Provider-Aufruf, keine API -- nur Anzeige. Faellt still auf null zurueck, wenn die
|
||||
* Bestellung keinen Vorgang hat (alte Zahlarten, Vorkasse, Rechnung ...).
|
||||
*/
|
||||
final class Overview
|
||||
{
|
||||
private const STATUS = [
|
||||
'open' => 'offen -- Kunde ist beim Anbieter',
|
||||
'pending' => 'in Pruefung beim Anbieter',
|
||||
'paid' => 'bezahlt',
|
||||
'cancelled' => 'abgebrochen / storniert',
|
||||
'mismatch' => 'BETRAG WEICHT AB -- pruefen!',
|
||||
];
|
||||
|
||||
private const SOURCE = [
|
||||
'return' => 'Rueckkehr des Kunden vom Anbieter',
|
||||
'express' => 'Express-Kauf aus dem Warenkorb',
|
||||
'webhook' => 'Meldung des Anbieters (Webhook)',
|
||||
'reconcile' => 'Abgleicher (Cron)',
|
||||
];
|
||||
|
||||
private const ACTION = [
|
||||
'create' => 'Vorgang beim Anbieter angelegt',
|
||||
'start' => 'Kunde zum Anbieter weitergeleitet',
|
||||
'capture' => 'Einzug (Capture)',
|
||||
'webhook' => 'Meldung vom Anbieter',
|
||||
'mail' => 'Bestellbestaetigung an Kunden',
|
||||
'cancel' => 'Storno',
|
||||
'mismatch' => 'Betragsabweichung',
|
||||
'express_create' => 'Express-Kauf gestartet',
|
||||
'express_get' => 'Express: Freigabe abgeholt',
|
||||
'express_patch' => 'Express: Betrag angepasst',
|
||||
'express_reapprove' => 'Express: zweite Freigabe noetig',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null Felder fuers Template oder null ohne Vorgang
|
||||
*/
|
||||
public static function forOrder(\mysqli $db, int $orderId, ?object $base = null): ?array
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $db->prepare('SELECT * FROM payment_transactions WHERE order_id=? ORDER BY id DESC LIMIT 1');
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
$stmt->bind_param('i', $orderId);
|
||||
$stmt->execute();
|
||||
$tx = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$tx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$module = (string) $tx['provider'];
|
||||
$label = $module;
|
||||
if ($base !== null && Registry::has($module)) {
|
||||
$p = Registry::get($module, $base);
|
||||
if ($p) {
|
||||
$label = $p->label();
|
||||
}
|
||||
}
|
||||
|
||||
$status = (string) $tx['status'];
|
||||
$detail = (string) ($tx['detail'] ?? '');
|
||||
if ($status === 'paid') {
|
||||
// markPaid() legt in detail nur die Quelle ab (return/express/webhook/reconcile).
|
||||
$detail = 'bezahlt, bestaetigt ueber: ' . (self::SOURCE[$detail] ?? $detail);
|
||||
}
|
||||
return [
|
||||
'provider' => $module,
|
||||
'provider_label' => $label,
|
||||
'status' => $status,
|
||||
'status_label' => self::STATUS[$status] ?? $status,
|
||||
'reference' => (string) $tx['reference'],
|
||||
'capture_id' => (string) ($tx['capture_id'] ?? ''),
|
||||
'expected_amount' => \number_format((float) $tx['expected_amount'], 2, ',', '.') . ' ' . $tx['currency'],
|
||||
'paid_amount' => $tx['paid_amount'] !== null ? \number_format((float) $tx['paid_amount'], 2, ',', '.') . ' ' . $tx['currency'] : '',
|
||||
'created_at' => self::de((string) $tx['created_at']),
|
||||
'paid_at' => self::de((string) ($tx['paid_at'] ?? '')),
|
||||
'updated_at' => self::de((string) $tx['updated_at']),
|
||||
'detail' => $detail,
|
||||
'history' => self::history($orderId),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array{zeit:string,aktion:string,status:string,http:string,detail:string}> */
|
||||
private static function history(int $orderId): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (Log::forOrder($orderId, 40) as $row) {
|
||||
$a = (string) $row['action'];
|
||||
$out[] = [
|
||||
'zeit' => (string) $row['zeit'],
|
||||
'aktion' => self::ACTION[$a] ?? $a,
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'http' => $row['http_status'] !== null && $row['http_status'] !== '' ? 'HTTP ' . $row['http_status'] : '',
|
||||
'detail' => (string) ($row['detail'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function de(string $dt): string
|
||||
{
|
||||
if ($dt === '' || \str_starts_with($dt, '0000')) {
|
||||
return '';
|
||||
}
|
||||
$t = \strtotime($dt);
|
||||
return $t ? \date('d.m.Y H:i:s', $t) : $dt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Vertrag fuer Zahlungsanbieter mit Weiterleitung (PayPal v2, spaeter Amazon Pay,
|
||||
* Mollie, Klarna Payments ...).
|
||||
*
|
||||
* Lebenszyklus einer Zahlung:
|
||||
* 1. Cashdesk legt die Shop-Bestellung an (Zahlstatus offen).
|
||||
* 2. start() -> Zahlung beim Anbieter anlegen, Kunde per 302 weiterleiten.
|
||||
* 3. onReturn() -> Kunde kommt zurueck: Zahlung abschliessen, Bestellung bezahlt setzen.
|
||||
* onCancel() -> Kunde hat abgebrochen: Bestellung stornieren, Warenkorb bleibt.
|
||||
* 4. onWebhook() -> Anbieter meldet serverseitig (unabhaengig vom Kunden).
|
||||
* 5. reconcile() -> Cron: haengende Vorgaenge beim Anbieter nachfragen.
|
||||
*
|
||||
* Alles, was fuer alle Anbieter gleich ist (Mail erst nach Zahlung, Warenkorb erst
|
||||
* nach Zahlung, Ereignisprotokoll, Betragsabgleich), lebt in AbstractProvider.
|
||||
*/
|
||||
interface ProviderInterface
|
||||
{
|
||||
/** Technischer Name = payment_methods.module = Konfigurationsgruppe, z.B. 'paypal_v2'. */
|
||||
public function module(): string;
|
||||
|
||||
/** Anzeigename fuers Backend und Protokoll. */
|
||||
public function label(): string;
|
||||
|
||||
/** true: die Bestellung ist erst nach Rueckkehr vom Anbieter bezahlt (Mail/Warenkorb warten). */
|
||||
public function paysAfterRedirect(): bool;
|
||||
|
||||
/**
|
||||
* Zahlung anlegen und Weiterleitungs-URL liefern.
|
||||
*
|
||||
* @param int $orderId Shop-Bestellung (orders.id)
|
||||
* @param float $amount zu zahlender Bruttobetrag (nach Gutschein)
|
||||
* @return string URL, zu der der Kunde geschickt wird
|
||||
* @throws \RuntimeException wenn der Anbieter nicht erreichbar ist oder ablehnt
|
||||
*/
|
||||
public function start(int $orderId, float $amount): string;
|
||||
|
||||
/**
|
||||
* Rueckkehr des Kunden vom Anbieter.
|
||||
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
|
||||
*/
|
||||
public function onReturn(array $query): Result;
|
||||
|
||||
/** @param array<string,mixed> $query */
|
||||
public function onCancel(array $query): Result;
|
||||
|
||||
/**
|
||||
* Server-zu-Server-Nachricht des Anbieters.
|
||||
* @param array<string,string> $headers Request-Header, Schluessel kleingeschrieben
|
||||
* @return int HTTP-Status, den der Endpunkt zurueckgeben soll (2xx = angenommen)
|
||||
*/
|
||||
public function onWebhook(array $headers, string $rawBody): int;
|
||||
|
||||
/**
|
||||
* Haengende Vorgaenge nachziehen (Cron). Liefert eine kurze Zusammenfassung je Vorgang.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function reconcile(bool $dryRun = true): array;
|
||||
|
||||
// ------------------------------------------------------------ Express
|
||||
// "Direkt bezahlen" aus dem Warenkorb: der Kunde geht OHNE Adresse zum Anbieter,
|
||||
// der liefert Adresse + E-Mail, die Kasse ist damit vorbelegt, ein Klick auf
|
||||
// "zahlungspflichtig bestellen" zieht das Geld ein -- kein zweiter Besuch beim Anbieter.
|
||||
|
||||
/** Bietet der Anbieter den Express-Weg an (und ist er eingeschaltet)? */
|
||||
public function supportsExpress(): bool;
|
||||
|
||||
/**
|
||||
* Express-Vorgang fuer den aktuellen Warenkorb anlegen (noch keine Bestellung!).
|
||||
* @param float $amount Warenwert brutto ohne Versand (Land noch unbekannt)
|
||||
* @return string URL zum Anbieter
|
||||
*/
|
||||
public function startExpress(float $amount): string;
|
||||
|
||||
/**
|
||||
* Rueckkehr vom Anbieter im Express-Weg: Adresse und E-Mail des Kunden abholen.
|
||||
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
|
||||
* @return array<string,mixed>|null Kundendaten fuer die Kasse (Schluessel: reference,
|
||||
* email, firstname, surname, company, street, house_number, zip_code, city,
|
||||
* country_iso, approve_url) oder null, wenn der Vorgang nicht zugeordnet werden kann
|
||||
*/
|
||||
public function onExpressReturn(array $query): ?array;
|
||||
|
||||
/**
|
||||
* Express abschliessen, nachdem die Bestellung angelegt ist: Betrag beim Anbieter auf
|
||||
* den Endbetrag (inkl. Versand) setzen und einziehen.
|
||||
* @return string '' = bezahlt; sonst eine URL, zu der der Kunde noch einmal muss
|
||||
* (Anbieter verlangt erneute Freigabe, z.B. nach Betragsaenderung)
|
||||
*/
|
||||
public function finishExpress(int $orderId, float $amount, string $reference): string;
|
||||
|
||||
/**
|
||||
* Felder fuer den Zahlart-Editor im Backend, die NICHT in `configuration` liegen,
|
||||
* sondern verschluesselt (Secrets). name => Beschriftung.
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function secretFields(): array;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Findet den Anbieter zu einem payment_methods.module.
|
||||
*
|
||||
* Konvention: core/payments/providers/<module>/Provider.php mit Klasse
|
||||
* Payments\Providers\<Module in StudlyCase>\Provider
|
||||
* Beispiel: module 'paypal_v2' -> Payments\Providers\PaypalV2\Provider
|
||||
*
|
||||
* Neuer Anbieter = neues Verzeichnis, sonst nichts.
|
||||
*/
|
||||
final class Registry
|
||||
{
|
||||
/** @var array<string,ProviderInterface|null> */
|
||||
private static array $cache = [];
|
||||
|
||||
public static function has(string $module): bool
|
||||
{
|
||||
$module = self::clean($module);
|
||||
return $module !== '' && \is_file(self::dir($module) . '/Provider.php');
|
||||
}
|
||||
|
||||
public static function get(string $module, object $base): ?ProviderInterface
|
||||
{
|
||||
$module = self::clean($module);
|
||||
if ($module === '') {
|
||||
return null;
|
||||
}
|
||||
if (\array_key_exists($module, self::$cache)) {
|
||||
return self::$cache[$module];
|
||||
}
|
||||
$file = self::dir($module) . '/Provider.php';
|
||||
if (!\is_file($file)) {
|
||||
return self::$cache[$module] = null;
|
||||
}
|
||||
self::requireCore();
|
||||
require_once $file;
|
||||
$class = 'Payments\\Providers\\' . self::studly($module) . '\\Provider';
|
||||
if (!\class_exists($class)) {
|
||||
\error_log('Payments: Provider-Klasse fehlt: ' . $class);
|
||||
return self::$cache[$module] = null;
|
||||
}
|
||||
$p = new $class($base);
|
||||
return self::$cache[$module] = ($p instanceof ProviderInterface ? $p : null);
|
||||
}
|
||||
|
||||
/** Alle installierten Anbieter (fuer Backend-Liste und Abgleicher). @return list<string> */
|
||||
public static function all(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (\glob(__DIR__ . '/providers/*/Provider.php') ?: [] as $f) {
|
||||
$out[] = \basename(\dirname($f));
|
||||
}
|
||||
\sort($out);
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function requireCore(): void
|
||||
{
|
||||
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'Overview', 'AbstractProvider'] as $c) {
|
||||
require_once __DIR__ . '/' . $c . '.php';
|
||||
}
|
||||
}
|
||||
|
||||
private static function dir(string $module): string
|
||||
{
|
||||
return __DIR__ . '/providers/' . $module;
|
||||
}
|
||||
|
||||
private static function clean(string $module): string
|
||||
{
|
||||
return \preg_match('/^[a-z0-9_]+$/', $module) ? $module : '';
|
||||
}
|
||||
|
||||
private static function studly(string $module): string
|
||||
{
|
||||
return \str_replace('_', '', \ucwords($module, '_'));
|
||||
}
|
||||
}
|
||||
|
||||
// Wer die Registry laedt, bekommt die Kernklassen gleich mit (Secrets, Log, Result ...),
|
||||
// nicht erst beim ersten Provider. Sonst faellt z.B. der Zahlart-Editor um, der nur
|
||||
// Secrets braucht.
|
||||
Registry::requireCore();
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Ergebnis von onReturn()/onCancel(): sagt dem Cashdesk, was er dem Kunden zeigt.
|
||||
*/
|
||||
final class Result
|
||||
{
|
||||
public const PAID = 'paid'; // bezahlt -> Erfolgsseite
|
||||
public const CANCELLED = 'cancelled'; // Kunde hat abgebrochen -> Kasse mit Hinweis
|
||||
public const PENDING = 'pending'; // Anbieter noch nicht fertig -> Hinweis, Abgleicher zieht nach
|
||||
public const ERROR = 'error'; // Fehler -> Hinweis, Warenkorb bleibt
|
||||
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly int $orderId = 0,
|
||||
public readonly string $orderNumber = '',
|
||||
public readonly string $message = ''
|
||||
) {
|
||||
}
|
||||
|
||||
public static function paid(int $orderId, string $orderNumber): self
|
||||
{
|
||||
return new self(self::PAID, $orderId, $orderNumber);
|
||||
}
|
||||
|
||||
public static function cancelled(int $orderId = 0, string $orderNumber = ''): self
|
||||
{
|
||||
return new self(self::CANCELLED, $orderId, $orderNumber);
|
||||
}
|
||||
|
||||
public static function pending(int $orderId, string $orderNumber, string $message): self
|
||||
{
|
||||
return new self(self::PENDING, $orderId, $orderNumber, $message);
|
||||
}
|
||||
|
||||
public static function error(string $message, int $orderId = 0, string $orderNumber = ''): self
|
||||
{
|
||||
return new self(self::ERROR, $orderId, $orderNumber, $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments;
|
||||
|
||||
/**
|
||||
* Verschluesselte Zugangsdaten der Zahlungsanbieter.
|
||||
*
|
||||
* Werte liegen in MySQL `payment_provider_secrets` (libsodium secretbox), der
|
||||
* Schluessel liegt als Datei AUSSERHALB des Docroots:
|
||||
* <vhost>/payments/secrets.key (chmod 600, nur der Site-User)
|
||||
* Ein DB-Dump ohne die Datei ist damit wertlos, und die Datei ohne DB auch.
|
||||
*
|
||||
* Im Backend werden Secrets nie angezeigt, nur "hinterlegt seit ..." und ein Feld
|
||||
* zum Ueberschreiben.
|
||||
*/
|
||||
final class Secrets
|
||||
{
|
||||
private static ?string $key = null;
|
||||
|
||||
public static function keyPath(): string
|
||||
{
|
||||
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
|
||||
if ($docroot === '' || !\is_dir($docroot)) {
|
||||
$docroot = \dirname(__DIR__, 2);
|
||||
}
|
||||
return \dirname(\rtrim($docroot, '/')) . '/payments/secrets.key';
|
||||
}
|
||||
|
||||
/** Legt den Schluessel an, falls er fehlt (nur per CLI/Installation gedacht). */
|
||||
public static function ensureKey(): string
|
||||
{
|
||||
$path = self::keyPath();
|
||||
if (\is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
$dir = \dirname($path);
|
||||
if (!\is_dir($dir) && !@\mkdir($dir, 0700, true)) {
|
||||
throw new \RuntimeException('Secrets: Verzeichnis nicht anlegbar: ' . $dir);
|
||||
}
|
||||
$key = \sodium_crypto_secretbox_keygen();
|
||||
if (@\file_put_contents($path, \sodium_bin2hex($key), LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Secrets: Schluessel nicht schreibbar: ' . $path);
|
||||
}
|
||||
@\chmod($path, 0600);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
if (self::$key !== null) {
|
||||
return self::$key;
|
||||
}
|
||||
$path = self::keyPath();
|
||||
if (!\is_readable($path)) {
|
||||
throw new \RuntimeException('Secrets: Schluesseldatei fehlt oder nicht lesbar: ' . $path);
|
||||
}
|
||||
$hex = \trim((string) \file_get_contents($path));
|
||||
$key = \sodium_hex2bin($hex);
|
||||
if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
|
||||
throw new \RuntimeException('Secrets: Schluesseldatei hat falsche Laenge.');
|
||||
}
|
||||
return self::$key = $key;
|
||||
}
|
||||
|
||||
public static function keyAvailable(): bool
|
||||
{
|
||||
return \is_readable(self::keyPath());
|
||||
}
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
$nonce = \random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$box = \sodium_crypto_secretbox($plain, $nonce, self::key());
|
||||
return 'v1:' . \sodium_bin2base64($nonce . $box, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
}
|
||||
|
||||
public static function decrypt(string $stored): string
|
||||
{
|
||||
if (\strncmp($stored, 'v1:', 3) !== 0) {
|
||||
throw new \RuntimeException('Secrets: unbekanntes Format.');
|
||||
}
|
||||
$raw = \sodium_base642bin(\substr($stored, 3), SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$nonce = \substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$box = \substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$plain = \sodium_crypto_secretbox_open($box, $nonce, self::key());
|
||||
if ($plain === false) {
|
||||
throw new \RuntimeException('Secrets: Entschluesselung fehlgeschlagen (falscher Schluessel?).');
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ablage
|
||||
|
||||
/** @return string|null Klartext oder null, wenn nicht hinterlegt */
|
||||
public static function get(\mysqli $db, string $provider, string $name): ?string
|
||||
{
|
||||
$stmt = $db->prepare('SELECT value_enc FROM payment_provider_secrets WHERE provider=? AND name=?');
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
$stmt->bind_param('ss', $provider, $name);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$row || $row['value_enc'] === '') {
|
||||
return null;
|
||||
}
|
||||
return self::decrypt((string) $row['value_enc']);
|
||||
}
|
||||
|
||||
public static function set(\mysqli $db, string $provider, string $name, string $plain): void
|
||||
{
|
||||
$enc = self::encrypt($plain);
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO payment_provider_secrets (provider, name, value_enc, updated_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_at = NOW()'
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new \RuntimeException('Secrets: ' . $db->error);
|
||||
}
|
||||
$stmt->bind_param('sss', $provider, $name, $enc);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
public static function delete(\mysqli $db, string $provider, string $name): void
|
||||
{
|
||||
$stmt = $db->prepare('DELETE FROM payment_provider_secrets WHERE provider=? AND name=?');
|
||||
if ($stmt) {
|
||||
$stmt->bind_param('ss', $provider, $name);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuers Backend: welche Secrets liegen vor und seit wann (ohne Klartext).
|
||||
* @return array<string,string> name => updated_at
|
||||
*/
|
||||
public static function status(\mysqli $db, string $provider): array
|
||||
{
|
||||
$out = [];
|
||||
$stmt = $db->prepare('SELECT name, updated_at FROM payment_provider_secrets WHERE provider=? AND value_enc<>""');
|
||||
if (!$stmt) {
|
||||
return $out;
|
||||
}
|
||||
$stmt->bind_param('s', $provider);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$out[(string) $row['name']] = (string) $row['updated_at'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Standalone-Bootstrap fuer Endpunkte OHNE Shop-Router und OHNE Session:
|
||||
* payment_webhook.php (Docroot) und core/payments/cli.php.
|
||||
*
|
||||
* Bindet den Shop-Kern (DB + Config, Konstanten ROOT_DIR/MAIN_URL) ein und stellt
|
||||
* $base_object bereit. Session wird bewusst nicht gestartet.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
\ini_set('display_errors', '0');
|
||||
\error_reporting(E_ALL);
|
||||
|
||||
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
|
||||
if ($docroot === '' || !\is_dir($docroot . '/core')) {
|
||||
$docroot = \dirname(__DIR__, 2);
|
||||
$_SERVER['DOCUMENT_ROOT'] = $docroot;
|
||||
}
|
||||
\chdir($docroot);
|
||||
|
||||
// base.class.php nutzt relative Includes und liest config.inc.php
|
||||
require_once $docroot . '/core/base.class.php';
|
||||
require_once $docroot . '/core/registry.class.php';
|
||||
require_once $docroot . '/core/order.class.php';
|
||||
if (\is_file($docroot . '/core/postgres/shop_events.php')) {
|
||||
require_once $docroot . '/core/postgres/shop_events.php';
|
||||
}
|
||||
require_once __DIR__ . '/Registry.php';
|
||||
\Payments\Registry::requireCore();
|
||||
|
||||
$base_object = new base();
|
||||
Registry::set('base', $base_object);
|
||||
|
||||
// 27.09.2026 -- Layout-Objekt (Smarty) auch hier: die Bestellbestaetigung rendert die
|
||||
// Positionen ueber DeliveryItemsHTML, das Registry::get('layout_object') holt. Ohne das
|
||||
// scheiterte die Mail, wenn der WEBHOOK (nicht die Rueckkehr) die Zahlung abschloss
|
||||
// ("Call to a member function assign() on null", Mollie-Test 290616). Der Kunde
|
||||
// bekam dann keine Bestaetigung. start() braucht SERVER_NAME (CLI: aus MAIN_URL).
|
||||
if (empty($_SERVER['SERVER_NAME']) && \defined('MAIN_URL')) {
|
||||
$_SERVER['SERVER_NAME'] = (string) \parse_url((string) MAIN_URL, PHP_URL_HOST);
|
||||
}
|
||||
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; // layout.class.php liest sie beim Start
|
||||
try {
|
||||
require_once $docroot . '/libs/smarty/libs/Smarty.class.php';
|
||||
require_once $docroot . '/core/layout.class.php';
|
||||
$layout_object = new layout();
|
||||
$layout_object->start($base_object);
|
||||
Registry::set('layout_object', $layout_object);
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('payments bootstrap: Layout nicht initialisiert -- ' . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* Kommandozeile fuer die Zahlungsanbieter. Immer mit dem PHP des Shops aufrufen:
|
||||
*
|
||||
* /opt/plesk/php/8.2/bin/php core/payments/cli.php reconcile [modul] [--apply]
|
||||
* Haengende Vorgaenge nachziehen. Ohne --apply nur Bericht.
|
||||
* Cron-Vorschlag (alle 15 Minuten): ... cli.php reconcile --apply >> logs/payments_reconcile.log
|
||||
*
|
||||
* ... cli.php init-key
|
||||
* Schluesseldatei fuer die Secrets anlegen (einmalig je Server).
|
||||
*
|
||||
* ... cli.php secret <modul> <name> [wert]
|
||||
* Secret setzen (wert leer = von STDIN lesen, damit es nicht in der Shell-History landet).
|
||||
*
|
||||
* ... cli.php status
|
||||
* Installierte Anbieter, Modus, hinterlegte Secrets, offene Vorgaenge.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
\http_response_code(403);
|
||||
exit('cli only');
|
||||
}
|
||||
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
use Payments\Registry as PR;
|
||||
use Payments\Secrets;
|
||||
|
||||
$args = $argv;
|
||||
\array_shift($args);
|
||||
$cmd = $args[0] ?? 'status';
|
||||
|
||||
switch ($cmd) {
|
||||
case 'init-key':
|
||||
$path = Secrets::ensureKey();
|
||||
echo "Schluessel: $path\n";
|
||||
break;
|
||||
|
||||
case 'secret':
|
||||
[$module, $name] = [$args[1] ?? '', $args[2] ?? ''];
|
||||
if ($module === '' || $name === '') {
|
||||
exit("usage: secret <modul> <name> [wert]\n");
|
||||
}
|
||||
$value = $args[3] ?? '';
|
||||
if ($value === '') {
|
||||
echo "Wert (Eingabe unsichtbar, Enter zum Beenden): ";
|
||||
\system('stty -echo');
|
||||
$value = \trim((string) \fgets(STDIN));
|
||||
\system('stty echo');
|
||||
echo "\n";
|
||||
}
|
||||
Secrets::set($base_object->db, $module, $name, $value);
|
||||
echo "gespeichert: $module/$name (" . \strlen($value) . " Zeichen)\n";
|
||||
break;
|
||||
|
||||
case 'reconcile':
|
||||
$apply = \in_array('--apply', $args, true);
|
||||
$modules = \array_values(\array_filter(\array_slice($args, 1), static fn($a) => $a !== '--apply'));
|
||||
if (!$modules) {
|
||||
$modules = PR::all();
|
||||
}
|
||||
foreach ($modules as $m) {
|
||||
$p = PR::get($m, $base_object);
|
||||
if (!$p) {
|
||||
echo "[$m] nicht installiert\n";
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$lines = $p->reconcile(!$apply);
|
||||
} catch (\Throwable $e) {
|
||||
echo '[' . \date('Y-m-d H:i:s') . "] [$m] FEHLER " . $e->getMessage() . "\n";
|
||||
continue;
|
||||
}
|
||||
foreach ($lines as $l) {
|
||||
echo '[' . \date('Y-m-d H:i:s') . "] [$m] $l\n";
|
||||
}
|
||||
if (!$lines) {
|
||||
echo '[' . \date('Y-m-d H:i:s') . "] [$m] nichts offen\n";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
default:
|
||||
echo 'Schluesseldatei: ' . Secrets::keyPath() . ' -> ' . (Secrets::keyAvailable() ? 'vorhanden' : 'FEHLT') . "\n";
|
||||
foreach (PR::all() as $m) {
|
||||
$p = PR::get($m, $base_object);
|
||||
if (!$p) {
|
||||
continue;
|
||||
}
|
||||
$secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : [];
|
||||
$res = $base_object->db->query("SELECT id, name, active, argument FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'");
|
||||
// 27.09.2026: ein Anbieter kann mehrere Zahlarten tragen (Mollie: eine Zeile je Methode)
|
||||
$zahlarten = [];
|
||||
while ($res && ($pm = $res->fetch_object())) {
|
||||
$zahlarten[] = "id {$pm->id} '{$pm->name}'" . ($pm->argument !== '' ? " [{$pm->argument}]" : '') . ' ' . ($pm->active ? 'aktiv' : 'inaktiv');
|
||||
}
|
||||
echo "\n[$m] " . $p->label() . (\method_exists($p, 'mode') ? ' Modus ' . $p->mode() : '') . "\n";
|
||||
echo ' Zahlart: ' . ($zahlarten ? \implode(', ', $zahlarten) : 'NICHT angelegt') . "\n";
|
||||
foreach ($p->secretFields() as $name => $label) {
|
||||
echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n";
|
||||
}
|
||||
$res = $base_object->db->query("SELECT status, COUNT(*) c FROM payment_transactions WHERE provider='" . $base_object->db->real_escape_string($m) . "' GROUP BY status");
|
||||
while ($res && ($r = $res->fetch_object())) {
|
||||
echo " Vorgaenge {$r->status}: {$r->c}\n";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
-- ============================================================================
|
||||
-- core/payments -- Installation (MySQL, Shop-DB)
|
||||
-- Additiv: bestehende Zahlarten und Bestellungen werden nicht angefasst.
|
||||
-- Reihenfolge: 1) Tabellen 2) Zahlart + Einstellungen 3) Kundengruppen (Test)
|
||||
-- ============================================================================
|
||||
|
||||
-- 1a) Vorgaenge je Anbieter -- mit echter Uhrzeit, Session (Warenkorb) und Sollbetrag.
|
||||
CREATE TABLE IF NOT EXISTS `payment_transactions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`provider` VARCHAR(40) NOT NULL,
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`order_number` VARCHAR(50) NULL,
|
||||
`reference` VARCHAR(191) NOT NULL COMMENT 'ID beim Anbieter (PayPal Order-ID)',
|
||||
`expected_amount` DECIMAL(13,2) NOT NULL,
|
||||
`currency` CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
`status` ENUM('open','pending','paid','cancelled','mismatch') NOT NULL DEFAULT 'open',
|
||||
`approve_url` TEXT NULL,
|
||||
`session_id` VARCHAR(191) NULL,
|
||||
`customer_id` INT UNSIGNED NULL,
|
||||
`capture_id` VARCHAR(191) NULL,
|
||||
`paid_amount` DECIMAL(13,2) NULL,
|
||||
`paid_at` DATETIME NULL,
|
||||
`detail` VARCHAR(500) NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_provider_reference` (`provider`,`reference`),
|
||||
KEY `idx_order` (`order_id`),
|
||||
KEY `idx_status_created` (`provider`,`status`,`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 1b) Verschluesselte Zugangsdaten (Schluessel liegt ausserhalb des Docroots).
|
||||
CREATE TABLE IF NOT EXISTS `payment_provider_secrets` (
|
||||
`provider` VARCHAR(40) NOT NULL,
|
||||
`name` VARCHAR(60) NOT NULL,
|
||||
`value_enc` TEXT NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`provider`,`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 2a) Einstellungen der Zahlart (erscheinen im Zahlart-Editor unter "Einstellungen").
|
||||
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
|
||||
('paypal_v2_mode', '', 'paypal_v2', 'sandbox', 1, 'sandbox oder live'),
|
||||
('paypal_v2_brand_name', '', 'paypal_v2', 'intelectra', 1, 'Anzeigename auf der PayPal-Seite'),
|
||||
('paypal_v2_send_address', '', 'paypal_v2', '1', 1, '1 = Lieferadresse an PayPal uebergeben (Verkaeuferschutz), 0 = nicht'),
|
||||
('paypal_v2_currency', '', 'paypal_v2', 'EUR', 1, 'Waehrung (ISO 4217)'),
|
||||
('paypal_v2_express', '', 'paypal_v2', '1', 1, '1 = Button "Direkt mit PayPal bezahlen" im Warenkorb, 0 = aus'),
|
||||
('paypal_v2_express_countries', '', 'paypal_v2', 'DE', 1, 'Laender (ISO-2, kommagetrennt), deren PayPal-Adresse im Express-Kauf angenommen wird');
|
||||
|
||||
-- 2a2) Stornogrund fuer automatische Stornos (Kunde hat beim Anbieter nicht bezahlt).
|
||||
-- type 0 = Kunden Storno. markCancelled() sucht ihn ueber den Namen.
|
||||
INSERT INTO `cancellation_reasons` (`name`, `type`, `active`)
|
||||
SELECT 'Zahlung beim Anbieter nicht abgeschlossen', 0, 1
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `cancellation_reasons` WHERE `name` = 'Zahlung beim Anbieter nicht abgeschlossen');
|
||||
|
||||
-- 2b) Zahlart anlegen. active=1, aber noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
|
||||
-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp).
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'PayPal', 'paypal_v2', '', 1, 2,
|
||||
'Bezahlung erfolgte per PayPal',
|
||||
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
|
||||
'Der zu zahlende Betrag wurde per PayPal beglichen.',
|
||||
'pp', 'paypal_v2'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module` = 'paypal_v2');
|
||||
|
||||
-- 3) TEST: Zahlart nur fuer eine Kundengruppe sichtbar machen (Gruppen: 1 Endverbraucher,
|
||||
-- 100 Besucher, 101 Haendler, 103 Gast). Beispiel Endverbraucher:
|
||||
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
|
||||
-- SELECT 1, id, 1, 1 FROM payment_methods WHERE module='paypal_v2';
|
||||
|
||||
-- GO-LIVE (spaeter): alle Gruppen wie bei id=4 freischalten und die alte Zahlart verstecken:
|
||||
-- UPDATE `customer_group_payment_method_configuration` SET active=0 WHERE payment_method_id=4;
|
||||
-- UPDATE `payment_methods` SET active=0 WHERE id=4;
|
||||
@@ -0,0 +1,108 @@
|
||||
-- ============================================================================
|
||||
-- core/payments -- Mollie (MySQL, Shop-DB). Additiv, mehrfach ausfuehrbar.
|
||||
-- Voraussetzung: install.sql (payment_transactions, payment_provider_secrets) ist drin.
|
||||
--
|
||||
-- Getrennte Gateways je Zahlungsart (Tom, 27.09.2026): je Mollie-Methode EINE Zeile in
|
||||
-- payment_methods mit module='mollie' und argument=<Methode>. Mollie zeigt dann nur
|
||||
-- diese Methode, keine Auswahl bei Mollie, kein abweichendes Warenkorb-Verhalten.
|
||||
--
|
||||
-- Reihenfolge: 1) Versandgebiet-Spalten 2) Einstellungen 3) Zahlarten 4) Freischaltung (manuell)
|
||||
-- ============================================================================
|
||||
|
||||
-- 1) Die Kasse haengt jede Zahlart ueber helperjoinkey an vier Spalten in shipping_area
|
||||
-- (active_/add_/account_type_/free_payment_<key>). Neue Keys = neue Spalten.
|
||||
-- Aufschlaege sind bei Karten/Klarna/Wallets ohnehin nicht zulaessig -> add=0, aktiv=1.
|
||||
ALTER TABLE `shipping_area`
|
||||
ADD COLUMN IF NOT EXISTS `active_mollie_klarna` TINYINT(2) NOT NULL DEFAULT 2,
|
||||
ADD COLUMN IF NOT EXISTS `add_mollie_klarna` DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `account_type_mollie_klarna` TINYINT(4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `free_payment_mollie_klarna` DECIMAL(6,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `active_mollie_creditcard` TINYINT(2) NOT NULL DEFAULT 2,
|
||||
ADD COLUMN IF NOT EXISTS `add_mollie_creditcard` DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `account_type_mollie_creditcard` TINYINT(4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `free_payment_mollie_creditcard` DECIMAL(6,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `active_mollie_applepay` TINYINT(2) NOT NULL DEFAULT 2,
|
||||
ADD COLUMN IF NOT EXISTS `add_mollie_applepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `account_type_mollie_applepay` TINYINT(4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `free_payment_mollie_applepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `active_mollie_googlepay` TINYINT(2) NOT NULL DEFAULT 2,
|
||||
ADD COLUMN IF NOT EXISTS `add_mollie_googlepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `account_type_mollie_googlepay` TINYINT(4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `free_payment_mollie_googlepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `active_mollie_paybybank` TINYINT(2) NOT NULL DEFAULT 2,
|
||||
ADD COLUMN IF NOT EXISTS `add_mollie_paybybank` DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `account_type_mollie_paybybank` TINYINT(4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `free_payment_mollie_paybybank` DECIMAL(6,2) NOT NULL DEFAULT 0;
|
||||
|
||||
-- 2) Einstellungen (Zahlart-Editor, Reiter "Einstellungen", Gruppe mollie). API-Keys sind
|
||||
-- Secrets (verschluesselt), nicht hier: test_api_key, live_api_key.
|
||||
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
|
||||
('mollie_mode', '', 'mollie', 'test', 1, 'test oder live (waehlt den API-Key)'),
|
||||
('mollie_locale', '', 'mollie', 'de_DE', 1, 'Sprache der Mollie-Seite (xx_XX)'),
|
||||
('mollie_currency', '', 'mollie', 'EUR', 1, 'Waehrung (ISO 4217)'),
|
||||
('mollie_description', '', 'mollie', 'Bestellung [#order_number]', 1, 'Verwendungszweck auf dem Kontoauszug des Kunden');
|
||||
|
||||
-- 3) Zahlarten: eine Zeile je Methode. active=1, aber noch KEINER Kundengruppe zugeordnet
|
||||
-- => im Shop unsichtbar, bis Schritt 4 laeuft. argument = Mollie-Methode (Pflicht).
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'Klarna', 'mollie', 'klarna', 1, 3,
|
||||
'Bezahlung erfolgte per Klarna',
|
||||
'Sie werden zur Bezahlung Ihrer Bestellung zu Klarna weitergeleitet (Rechnung, Ratenkauf oder Sofort bezahlen).',
|
||||
'Der zu zahlende Betrag wird ueber Klarna abgewickelt.',
|
||||
'mollie_klarna', 'mollie'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='klarna');
|
||||
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'Kreditkarte', 'mollie', 'creditcard', 1, 4,
|
||||
'Bezahlung erfolgte per Kreditkarte',
|
||||
'Sie werden zur sicheren Eingabe Ihrer Kartendaten weitergeleitet (Visa, Mastercard, American Express).',
|
||||
'Der zu zahlende Betrag wurde per Kreditkarte beglichen.',
|
||||
'mollie_creditcard', 'mollie'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='creditcard');
|
||||
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'Apple Pay', 'mollie', 'applepay', 1, 5,
|
||||
'Bezahlung erfolgte per Apple Pay',
|
||||
'Sie werden zur Bezahlung mit Apple Pay weitergeleitet.',
|
||||
'Der zu zahlende Betrag wurde per Apple Pay beglichen.',
|
||||
'mollie_applepay', 'mollie'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='applepay');
|
||||
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'Google Pay', 'mollie', 'googlepay', 1, 6,
|
||||
'Bezahlung erfolgte per Google Pay',
|
||||
'Sie werden zur Bezahlung mit Google Pay weitergeleitet.',
|
||||
'Der zu zahlende Betrag wurde per Google Pay beglichen.',
|
||||
'mollie_googlepay', 'mollie'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='googlepay');
|
||||
|
||||
-- Google Pay: Mollie lehnt method=googlepay im Redirect-Verfahren ab (HTTP 500, 27.09.2026) --
|
||||
-- gibt es nur als Direkt-Integration mit Token. Zeile bleibt angelegt, aber inaktiv.
|
||||
UPDATE `payment_methods` SET active=0 WHERE module='mollie' AND argument='googlepay';
|
||||
|
||||
-- "Online-Banking" = Mollie Pay by Bank (Open Banking, Nachfolger von Sofortueberweisung).
|
||||
-- NICHT "Sofortueberweisung" nennen: eingetragene Marke der Sofort GmbH (Klarna).
|
||||
-- Status nach Freigabe erst pending, paid kommt per Webhook (Echtzeit: Minuten, sonst 2-3 Werktage).
|
||||
INSERT INTO `payment_methods`
|
||||
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
|
||||
SELECT 'Online-Banking', 'mollie', 'paybybank', 1, 7,
|
||||
'Bezahlung erfolgte per Online-Banking (Pay by Bank)',
|
||||
'Sie zahlen direkt von Ihrem Bankkonto: Bank waehlen, im Online-Banking freigeben, fertig.',
|
||||
'Der zu zahlende Betrag wurde per Online-Banking ueberwiesen.',
|
||||
'mollie_paybybank', 'mollie'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='paybybank');
|
||||
|
||||
-- 4) FREISCHALTUNG (manuell, nach Test): Kundengruppen 1 Endverbraucher, 103 Gast
|
||||
-- (Haendler 101 bewusst nicht -- die zahlen kundenspezifisch), Versandgebiet 1.
|
||||
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
|
||||
-- SELECT g.id, pm.id, 1, 1 FROM payment_methods pm JOIN customer_groups g ON g.id IN (1,103)
|
||||
-- WHERE pm.module='mollie'
|
||||
-- AND NOT EXISTS (SELECT 1 FROM customer_group_payment_method_configuration c WHERE c.group_id=g.id AND c.payment_method_id=pm.id AND c.shipping_area_id=1);
|
||||
|
||||
-- 5) LEICHEN (nach Go-Live): alte Klarna-/paymorrow-/BILLPAY-/computop-Zahlarten verstecken.
|
||||
-- Nicht loeschen -- Bestellungen von frueher zeigen noch auf die ids.
|
||||
-- UPDATE `payment_methods` SET active=0 WHERE id IN (8,9,10,11,12,13);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- core/payments -- Rohprotokoll in PostgreSQL (krempl_data)
|
||||
-- Jede Anfrage/Antwort eines Anbieters mit Zeitstempel. Die Beweiskette.
|
||||
CREATE TABLE IF NOT EXISTS payment_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- start, create, capture, get, webhook, mail, cancel, mismatch
|
||||
order_id INTEGER,
|
||||
order_number TEXT,
|
||||
reference TEXT, -- ID beim Anbieter
|
||||
status TEXT,
|
||||
http_status INTEGER,
|
||||
payload JSONB,
|
||||
detail TEXT,
|
||||
host TEXT, -- dev.intelectra.de / intelectra-shop.de
|
||||
zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_log_order ON payment_log (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_log_reference ON payment_log (reference);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_log_zeit ON payment_log (zeitpunkt);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_log_provider ON payment_log (provider, action, zeitpunkt);
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments\Providers\Mollie;
|
||||
|
||||
/**
|
||||
* Duenner HTTP-Client fuer die Mollie-API v2 (https://api.mollie.com/v2/...).
|
||||
*
|
||||
* Auth ist ein einzelner API-Key als Bearer-Token (test_... oder live_...), kein OAuth.
|
||||
* TLS-Pruefung erzwungen. POSTs bekommen einen Idempotency-Key, damit ein Retry
|
||||
* innerhalb einer Stunde dieselbe Zahlung liefert statt einer zweiten.
|
||||
*/
|
||||
final class Client
|
||||
{
|
||||
private const BASE = 'https://api.mollie.com';
|
||||
|
||||
public function __construct(private readonly string $apiKey)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $payload
|
||||
* @param list<string> $extraHeaders
|
||||
* @return array{0:int,1:array<mixed>}
|
||||
*/
|
||||
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
|
||||
{
|
||||
$headers = \array_merge([
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'User-Agent: intelectra-shop/1.0 core-payments-mollie',
|
||||
], $extraHeaders);
|
||||
|
||||
$ch = \curl_init(self::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 ($payload !== null) {
|
||||
\curl_setopt($ch, CURLOPT_POSTFIELDS, (string) \json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
$resp = \curl_exec($ch);
|
||||
if ($resp === false) {
|
||||
$err = \curl_error($ch);
|
||||
\curl_close($ch);
|
||||
throw new \RuntimeException('Mollie HTTP-Transportfehler: ' . $err);
|
||||
}
|
||||
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
\curl_close($ch);
|
||||
|
||||
$decoded = $resp === '' ? [] : \json_decode((string) $resp, true);
|
||||
return [$status, \is_array($decoded) ? $decoded : ['raw' => (string) $resp]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments\Providers\Mollie;
|
||||
|
||||
use Payments\AbstractProvider;
|
||||
use Payments\Log;
|
||||
use Payments\Result;
|
||||
|
||||
require_once __DIR__ . '/Client.php';
|
||||
|
||||
/**
|
||||
* Mollie, Payments API v2 -- EIN Provider, mehrere Zahlarten.
|
||||
*
|
||||
* Toms Vorgabe (27.09.2026): getrennte Gateways je Zahlungsart, nicht "Mollie waehlt".
|
||||
* Darum steht in payment_methods je Zahlart EINE Zeile mit module='mollie' und
|
||||
* argument=<Mollie-Methode> (klarna, creditcard, applepay, googlepay, ...). Beim Anlegen
|
||||
* der Zahlung wird `method` fest gesetzt, die Mollie-Seite zeigt nur diese Methode.
|
||||
*
|
||||
* Ablauf: start() legt die Zahlung an (POST /v2/payments) und leitet zur Checkout-URL.
|
||||
* Die Rueckkehr-URL traegt unsere Bestellnummer (o=<orders.id>), weil Mollie beim
|
||||
* Redirect nichts anhaengt. onReturn()/onCancel()/onWebhook()/reconcile() holen den
|
||||
* Zahlungsstatus IMMER per GET bei Mollie -- der Webhook selbst traegt nur die ID und
|
||||
* ist nicht signiert; die Abfrage ist die Verifikation.
|
||||
*
|
||||
* Status bei Mollie: open, pending, authorized (nicht final) / paid, canceled, expired,
|
||||
* failed (final). Klarna verlangt lines + billingAddress; Betrag = Summe der lines.
|
||||
*
|
||||
* Einstellungen (configuration, Gruppe mollie): mode (test|live), locale, currency, description
|
||||
* Secrets (payment_provider_secrets): test_api_key, live_api_key
|
||||
*/
|
||||
final class Provider extends AbstractProvider
|
||||
{
|
||||
private ?Client $client = null;
|
||||
|
||||
/** Anzeigenamen je Mollie-Methode (fuer Protokoll und Hinweistexte). */
|
||||
private const LABELS = [
|
||||
'klarna' => 'Klarna',
|
||||
'creditcard' => 'Kreditkarte',
|
||||
'applepay' => 'Apple Pay',
|
||||
'googlepay' => 'Google Pay',
|
||||
'paypal' => 'PayPal',
|
||||
'banktransfer' => 'Ueberweisung',
|
||||
'ideal' => 'iDEAL',
|
||||
'bancontact' => 'Bancontact',
|
||||
'eps' => 'EPS',
|
||||
'twint' => 'TWINT',
|
||||
'trustly' => 'Trustly',
|
||||
'paybybank' => 'Pay by Bank',
|
||||
'riverty' => 'Riverty',
|
||||
'in3' => 'in3',
|
||||
'billie' => 'Billie',
|
||||
];
|
||||
|
||||
/** Methoden, fuer die Mollie Positionen (lines) und volle Adressen verlangt. */
|
||||
private const NEEDS_LINES = ['klarna', 'billie', 'in3', 'riverty', 'billink', 'voucher'];
|
||||
|
||||
public function module(): string
|
||||
{
|
||||
return 'mollie';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Mollie';
|
||||
}
|
||||
|
||||
public function secretFields(): array
|
||||
{
|
||||
return [
|
||||
'test_api_key' => 'Test-API-Key (test_...)',
|
||||
'live_api_key' => 'Live-API-Key (live_...)',
|
||||
'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)',
|
||||
];
|
||||
}
|
||||
|
||||
public function mode(): string
|
||||
{
|
||||
return $this->setting('mode', 'test') === 'live' ? 'live' : 'test';
|
||||
}
|
||||
|
||||
private function client(): Client
|
||||
{
|
||||
if ($this->client === null) {
|
||||
$this->client = new Client($this->secret($this->mode() . '_api_key'));
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public static function methodLabel(string $method): string
|
||||
{
|
||||
return self::LABELS[$method] ?? \ucfirst($method);
|
||||
}
|
||||
|
||||
/** Mollie-Methode der Zahlart, die an der Bestellung haengt (payment_methods.argument). */
|
||||
public function methodForOrder(object $order): string
|
||||
{
|
||||
$pmId = (int) ($order->payment_method ?? 0);
|
||||
$res = $this->db->query("SELECT module, argument FROM payment_methods WHERE id=$pmId LIMIT 1");
|
||||
$row = $res ? $res->fetch_object() : null;
|
||||
if (!$row || (string) $row->module !== $this->module()) {
|
||||
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' der Bestellung ist keine Mollie-Zahlart.');
|
||||
}
|
||||
$method = \strtolower(\trim((string) $row->argument));
|
||||
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
|
||||
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' hat keine Mollie-Methode im Feld "argument" (z.B. klarna, creditcard, applepay).');
|
||||
}
|
||||
return $method;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ start
|
||||
|
||||
public function start(int $orderId, float $amount): string
|
||||
{
|
||||
$order = $this->orderById($orderId);
|
||||
$number = (string) $order->order_number;
|
||||
$method = $this->methodForOrder($order);
|
||||
$payload = $this->buildPayload($order, $amount, $method);
|
||||
|
||||
// Deterministischer Idempotency-Key je (Bestellung, Betrag): ein zweiter Klick oder
|
||||
// Retry innerhalb einer Stunde bekommt dieselbe Zahlung zurueck.
|
||||
$idem = 'shop-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $payload['amount']['value'] . '|' . $method), 0, 24);
|
||||
|
||||
[$status, $body] = $this->client()->request('POST', '/v2/payments', $payload, ['Idempotency-Key: ' . $idem]);
|
||||
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status,
|
||||
['request' => $payload, 'response' => $body]);
|
||||
|
||||
if ($status !== 201 || empty($body['id'])) {
|
||||
throw new \RuntimeException('Mollie: Zahlung konnte nicht angelegt werden (HTTP ' . $status . '): '
|
||||
. (string) ($body['detail'] ?? \json_encode($body)) . (isset($body['field']) ? ' [Feld ' . $body['field'] . ']' : ''));
|
||||
}
|
||||
$checkout = (string) ($body['_links']['checkout']['href'] ?? '');
|
||||
if ($checkout === '') {
|
||||
throw new \RuntimeException('Mollie: keine Checkout-URL in der Antwort.');
|
||||
}
|
||||
|
||||
$this->openTransaction($orderId, (string) $body['id'], $amount, $checkout);
|
||||
$this->setTransactionStatus((string) $body['id'], 'open', 'method ' . $method);
|
||||
return $checkout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anfrage an Mollie zusammenbauen. Oeffentlich, damit sie sich ohne API-Key auf der
|
||||
* Kommandozeile pruefen laesst (Betrag = Summe der lines usw.).
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function buildPayload(object $order, float $amount, string $method): array
|
||||
{
|
||||
$orderId = (int) $order->id;
|
||||
$number = (string) $order->order_number;
|
||||
$value = \number_format($amount, 2, '.', '');
|
||||
|
||||
$description = \str_replace('[#order_number]', $number, $this->setting('description', 'Bestellung [#order_number]'));
|
||||
|
||||
$payload = [
|
||||
'amount' => ['currency' => $this->currency(), 'value' => $value],
|
||||
'description' => \mb_substr($description, 0, 255),
|
||||
'redirectUrl' => $this->returnUrl() . '&o=' . $orderId,
|
||||
'cancelUrl' => $this->cancelUrl() . '&o=' . $orderId,
|
||||
'webhookUrl' => $this->webhookUrl(),
|
||||
'method' => $method,
|
||||
'locale' => $this->setting('locale', 'de_DE'),
|
||||
'metadata' => ['order_id' => $orderId, 'order_number' => $number, 'shop' => $this->shopBaseUrl()],
|
||||
];
|
||||
|
||||
$billing = $this->address($order, 'billing');
|
||||
if ($billing !== null) {
|
||||
$payload['billingAddress'] = $billing;
|
||||
}
|
||||
$shipping = $this->address($order, 'shipping');
|
||||
if ($shipping !== null) {
|
||||
$payload['shippingAddress'] = $shipping;
|
||||
}
|
||||
|
||||
if (\in_array($method, self::NEEDS_LINES, true)) {
|
||||
$payload['lines'] = $this->lines($order, $amount);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- return
|
||||
|
||||
public function onReturn(array $query): Result
|
||||
{
|
||||
return $this->afterRedirect($query, false);
|
||||
}
|
||||
|
||||
public function onCancel(array $query): Result
|
||||
{
|
||||
return $this->afterRedirect($query, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rueckkehr des Kunden (Erfolg oder Abbruch-URL): Vorgang ueber unsere Bestellnummer
|
||||
* finden, Status bei Mollie holen, danach handeln. Der Abbruch-Link ist keine
|
||||
* Wahrheit -- auch dort wird nachgefragt.
|
||||
*/
|
||||
private function afterRedirect(array $query, bool $viaCancelUrl): Result
|
||||
{
|
||||
$orderId = (int) ($query['o'] ?? 0);
|
||||
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
|
||||
if (!$tx) {
|
||||
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
|
||||
if (\is_array($p) && ($p['module'] ?? '') === $this->module() && !empty($p['reference'])) {
|
||||
$tx = $this->transactionByReference((string) $p['reference']);
|
||||
}
|
||||
}
|
||||
if (!$tx) {
|
||||
$this->pendingClear();
|
||||
return $viaCancelUrl
|
||||
? Result::cancelled()
|
||||
: Result::error('Der Zahlungsvorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
|
||||
}
|
||||
if ($tx->status === 'paid') {
|
||||
$this->pendingClear();
|
||||
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
|
||||
}
|
||||
|
||||
try {
|
||||
$payment = $this->fetch((string) $tx->reference, (int) $tx->order_id, (string) $tx->order_number);
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('Mollie return: ' . $e->getMessage());
|
||||
return Result::error('Die Rueckmeldung des Zahlungsanbieters ist gerade nicht erreichbar. Ihr Warenkorb ist noch da – bitte versuchen Sie es in einer Minute erneut.',
|
||||
(int) $tx->order_id, (string) $tx->order_number);
|
||||
}
|
||||
return $this->apply($tx, $payment, $viaCancelUrl ? 'cancel' : 'return');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlungsstatus von Mollie auf den Vorgang anwenden -- eine Stelle fuer Rueckkehr,
|
||||
* Webhook und Abgleicher.
|
||||
* @param array<mixed> $payment
|
||||
*/
|
||||
private function apply(object $tx, array $payment, string $source): Result
|
||||
{
|
||||
$ref = (string) $tx->reference;
|
||||
$orderId = (int) $tx->order_id;
|
||||
$number = (string) $tx->order_number;
|
||||
$status = (string) ($payment['status'] ?? '');
|
||||
$method = (string) ($payment['method'] ?? '');
|
||||
$label = $method !== '' ? self::methodLabel($method) : 'Mollie';
|
||||
|
||||
switch ($status) {
|
||||
case 'paid':
|
||||
$ok = $this->markPaid($ref, (string) ($payment['amount']['value'] ?? ''), (string) ($payment['amount']['currency'] ?? ''),
|
||||
$ref, $this->payerEmail($payment), (string) ($payment['details']['consumerAccount'] ?? ''), $source . ' ' . $method);
|
||||
if ($ok) {
|
||||
return Result::paid($orderId, $number);
|
||||
}
|
||||
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
|
||||
|
||||
case 'authorized':
|
||||
case 'pending':
|
||||
$this->setTransactionStatus($ref, 'pending', $label . ': ' . $status . ' (' . $source . ')');
|
||||
return Result::pending($orderId, $number, $label . ' hat die Zahlung angenommen und prueft sie noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
|
||||
|
||||
case 'canceled':
|
||||
$this->markCancelled($ref, 'Kunde hat bei ' . $label . ' abgebrochen', $source);
|
||||
return Result::cancelled($orderId, $number);
|
||||
|
||||
case 'expired':
|
||||
$this->markCancelled($ref, $label . ': Zahlung abgelaufen (expired)', $source);
|
||||
return Result::cancelled($orderId, $number);
|
||||
|
||||
case 'failed':
|
||||
$this->markCancelled($ref, $label . ': Zahlung fehlgeschlagen (failed)', $source);
|
||||
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht angenommen. Ihr Warenkorb ist noch da – bitte waehlen Sie eine andere Zahlart.', $orderId, $number);
|
||||
|
||||
case 'open':
|
||||
default:
|
||||
// Kunde ist ohne Abschluss zurueck (Fenster zu, Zurueck-Taste). Der Vorgang
|
||||
// bleibt offen: Mollie laesst ihn verfallen, der Abgleicher raeumt auf.
|
||||
// Der Session-Riegel schickt einen zweiten "Bestellen"-Klick auf dieselbe Checkout-URL.
|
||||
$this->setTransactionStatus($ref, 'open', $label . ': ' . ($status ?: 'unbekannt') . ' bei Rueckkehr (' . $source . ')');
|
||||
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht abgeschlossen. Ihr Warenkorb ist noch da – Sie koennen es erneut versuchen oder eine andere Zahlart waehlen.', $orderId, $number);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- webhook
|
||||
|
||||
/**
|
||||
* Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new):
|
||||
*
|
||||
* klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert.
|
||||
* Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer
|
||||
* Zahlungsstatus weiterhin.
|
||||
* next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert
|
||||
* mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature
|
||||
* ("sha256=<hex>"), Schluessel = Signing-Secret der Subscription (Dashboard >
|
||||
* Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen;
|
||||
* ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen).
|
||||
*
|
||||
* In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der
|
||||
* Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h.
|
||||
*/
|
||||
public function onWebhook(array $headers, string $rawBody): int
|
||||
{
|
||||
$id = '';
|
||||
$body = \ltrim($rawBody);
|
||||
if ($body !== '' && $body[0] === '{') {
|
||||
$event = \json_decode($body, true);
|
||||
if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') {
|
||||
Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event');
|
||||
return 400;
|
||||
}
|
||||
$check = $this->verifySignature($headers, $rawBody);
|
||||
if ($check !== 'ok') {
|
||||
Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null,
|
||||
['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check);
|
||||
return 400;
|
||||
}
|
||||
$type = (string) ($event['type'] ?? '');
|
||||
$id = (string) ($event['entityId'] ?? '');
|
||||
if (!\str_starts_with($type, 'payment.')) {
|
||||
// andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren
|
||||
Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis');
|
||||
return 200;
|
||||
}
|
||||
} else {
|
||||
$params = [];
|
||||
\parse_str($rawBody, $params);
|
||||
$id = (string) ($params['id'] ?? ($_POST['id'] ?? ''));
|
||||
}
|
||||
if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) {
|
||||
Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID');
|
||||
return 400;
|
||||
}
|
||||
$tx = $this->transactionByReference($id);
|
||||
if (!$tx) {
|
||||
// Unbekannt (z.B. Test aus dem Mollie-Dashboard): protokollieren, 200 damit Mollie nicht 26 h lang wiederholt.
|
||||
Log::write($this->module(), 'webhook', null, null, $id, 'unknown', null, null, 'kein Vorgang zu dieser ID');
|
||||
return 200;
|
||||
}
|
||||
try {
|
||||
$payment = $this->fetch($id, (int) $tx->order_id, (string) $tx->order_number, 'webhook');
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('Mollie webhook ' . $id . ': ' . $e->getMessage());
|
||||
return 500; // Mollie versucht es spaeter noch einmal
|
||||
}
|
||||
if ($tx->status === 'paid') {
|
||||
return 200;
|
||||
}
|
||||
$this->apply($tx, $payment, 'webhook');
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation
|
||||
* schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen.
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
private function verifySignature(array $headers, string $rawBody): string
|
||||
{
|
||||
$secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? '';
|
||||
if ($secret === '') {
|
||||
return 'kein webhook_secret hinterlegt';
|
||||
}
|
||||
$header = (string) ($headers['x-mollie-signature'] ?? '');
|
||||
if ($header === '') {
|
||||
return 'Header X-Mollie-Signature fehlt';
|
||||
}
|
||||
$expected = \hash_hmac('sha256', $rawBody, $secret);
|
||||
foreach (\explode(',', $header) as $sig) {
|
||||
$sig = \strtolower(\trim($sig));
|
||||
if (\str_starts_with($sig, 'sha256=')) {
|
||||
$sig = \substr($sig, 7);
|
||||
}
|
||||
if ($sig !== '' && \hash_equals($expected, $sig)) {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
return 'Signatur passt nicht';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- reconcile
|
||||
|
||||
public function reconcile(bool $dryRun = true): array
|
||||
{
|
||||
$out = [];
|
||||
// Nach 10 Minuten nachsehen, nach 72 Stunden aufgeben.
|
||||
foreach ($this->openTransactions(10, 72) as $tx) {
|
||||
$ref = (string) $tx->reference;
|
||||
try {
|
||||
$payment = $this->fetch($ref, (int) $tx->order_id, (string) $tx->order_number, 'reconcile');
|
||||
} catch (\Throwable $e) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
$status = (string) ($payment['status'] ?? '');
|
||||
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
|
||||
$tag = $dryRun ? ' [dry-run]' : '';
|
||||
|
||||
if ($status === 'paid') {
|
||||
$out[] = "$ref (Order {$tx->order_id}): bei Mollie bezahlt, im Shop offen -> bezahlt setzen$tag";
|
||||
} elseif (\in_array($status, ['canceled', 'expired', 'failed'], true)) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): Mollie $status -> storniert$tag";
|
||||
} elseif ($age >= 24) {
|
||||
// Nach 24 h ohne Abschluss: bei Mollie abbrechen (wenn noch moeglich) und stornieren.
|
||||
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $status -> abbrechen + storniert$tag";
|
||||
if (!$dryRun) {
|
||||
if (!empty($payment['isCancelable'])) {
|
||||
[$s, $b] = $this->client()->request('DELETE', '/v2/payments/' . \rawurlencode($ref));
|
||||
Log::write($this->module(), 'cancel_api', (int) $tx->order_id, (string) $tx->order_number, $ref, (string) ($b['status'] ?? ''), $s, $b);
|
||||
}
|
||||
$this->markCancelled($ref, 'Mollie ' . $status . ' nach ' . \round($age) . ' h ohne Abschluss', 'reconcile');
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
$out[] = "$ref (Order {$tx->order_id}): $status, " . \round($age, 1) . " h alt -> warten";
|
||||
continue;
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$this->apply($tx, $payment, 'reconcile');
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- intern
|
||||
|
||||
/** GET /v2/payments/{id} mit Protokoll. @return array<mixed> */
|
||||
private function fetch(string $id, int $orderId, string $number, string $action = 'get'): array
|
||||
{
|
||||
[$status, $body] = $this->client()->request('GET', '/v2/payments/' . \rawurlencode($id));
|
||||
Log::write($this->module(), $action, $orderId, $number, $id, (string) ($body['status'] ?? ''), $status, $body);
|
||||
if ($status !== 200 || !isset($body['status'])) {
|
||||
throw new \RuntimeException('Mollie GET payment ' . $id . ' fehlgeschlagen (HTTP ' . $status . '): ' . (string) ($body['detail'] ?? \json_encode($body)));
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/** @param array<mixed> $payment */
|
||||
private function payerEmail(array $payment): string
|
||||
{
|
||||
$d = $payment['details'] ?? [];
|
||||
foreach (['consumerAccount', 'payerEmail', 'email'] as $k) {
|
||||
$v = (string) ($d[$k] ?? '');
|
||||
if ($v !== '' && \str_contains($v, '@')) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
return (string) ($payment['billingAddress']['email'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Bestellung im Mollie-Format. null, wenn keine Strasse da ist (dann lieber
|
||||
* gar keine Adresse als eine, die Mollie ablehnt).
|
||||
* @return array<string,string>|null
|
||||
*/
|
||||
private function address(object $order, string $kind): ?array
|
||||
{
|
||||
$p = static fn(string $f) => \trim((string) ($order->{$kind . '_' . $f} ?? ''));
|
||||
$street = \trim($p('street') . ' ' . $p('house_number'));
|
||||
if ($street === '' || $p('zip_code') === '' || $p('city') === '') {
|
||||
return null;
|
||||
}
|
||||
$iso = 'DE';
|
||||
$cid = (int) ($order->{$kind . '_country'} ?? 0);
|
||||
if ($cid > 0) {
|
||||
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
if ($r && \strlen((string) $r->iso_code_2) === 2) {
|
||||
$iso = \strtoupper((string) $r->iso_code_2);
|
||||
}
|
||||
}
|
||||
$first = $p('firstname');
|
||||
$last = $p('surname');
|
||||
$company = $p('company');
|
||||
// Klarna verlangt Vor- und Nachname mit je >= 2 Zeichen; Firmenkunden ohne Person: Firma als Name.
|
||||
if (\mb_strlen($first) < 2 && $company !== '') {
|
||||
$first = $company;
|
||||
}
|
||||
if (\mb_strlen($last) < 2) {
|
||||
$last = $company !== '' ? $company : $first;
|
||||
}
|
||||
$a = [
|
||||
'givenName' => \mb_substr($first, 0, 100),
|
||||
'familyName' => \mb_substr($last, 0, 100),
|
||||
'streetAndNumber' => \mb_substr($street, 0, 100),
|
||||
'postalCode' => $p('zip_code'),
|
||||
'city' => $p('city'),
|
||||
'country' => $iso,
|
||||
];
|
||||
if ($company !== '') {
|
||||
$a['organizationName'] = \mb_substr($company, 0, 100);
|
||||
}
|
||||
$email = $this->customerEmail($order);
|
||||
if ($email !== '') {
|
||||
$a['email'] = $email;
|
||||
}
|
||||
$phone = $this->phoneE164((string) ($order->customer_phone ?? ''), $iso);
|
||||
if ($phone !== '') {
|
||||
$a['phone'] = $phone;
|
||||
}
|
||||
return $a;
|
||||
}
|
||||
|
||||
private function customerEmail(object $order): string
|
||||
{
|
||||
$e = \trim((string) ($order->customer_email ?? ''));
|
||||
if ($e === '') {
|
||||
$cid = (int) ($order->customer_id ?? 0);
|
||||
$res = $cid > 0 ? $this->db->query("SELECT email FROM customers WHERE id=$cid") : null;
|
||||
$r = $res ? $res->fetch_object() : null;
|
||||
$e = $r ? \trim((string) $r->email) : '';
|
||||
}
|
||||
return \filter_var($e, FILTER_VALIDATE_EMAIL) ? $e : '';
|
||||
}
|
||||
|
||||
/** Telefon nach E.164 (nur Ziffern, Landesvorwahl); unbrauchbare Eingaben fallen weg. */
|
||||
private function phoneE164(string $raw, string $iso): string
|
||||
{
|
||||
$d = \preg_replace('/[^0-9+]/', '', $raw) ?? '';
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
if (\str_starts_with($d, '00')) {
|
||||
$d = '+' . \substr($d, 2);
|
||||
} elseif (\str_starts_with($d, '0') && $iso === 'DE') {
|
||||
$d = '+49' . \substr($d, 1);
|
||||
} elseif (!\str_starts_with($d, '+')) {
|
||||
return '';
|
||||
}
|
||||
return \preg_match('/^\+[1-9][0-9]{6,14}$/', $d) ? $d : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionen fuer Klarna & Co.: Artikel (brutto je Stueck aus Netto + Steuersatz),
|
||||
* Versand, Zahlartaufschlag, Gutschein, ggf. Rundungsausgleich -- die Summe der
|
||||
* totalAmount MUSS dem Zahlbetrag entsprechen, sonst lehnt Mollie ab.
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function lines(object $order, float $amount): array
|
||||
{
|
||||
$cur = $this->currency();
|
||||
$money = static fn(float $v) => ['currency' => $cur, 'value' => \number_format($v, 2, '.', '')];
|
||||
$lines = [];
|
||||
$sum = 0.0;
|
||||
$maxRate = 0.0;
|
||||
|
||||
$orderId = (int) $order->id;
|
||||
$res = $this->db->query("SELECT item_id, item_number, item_name, amount, price, tax_rate FROM order_item WHERE order_id=$orderId AND (parent_item_id IS NULL OR parent_item_id=0) ORDER BY id");
|
||||
while ($res && ($it = $res->fetch_object())) {
|
||||
$qty = \max(1, (int) $it->amount);
|
||||
$rate = (float) $it->tax_rate;
|
||||
$maxRate = \max($maxRate, $rate);
|
||||
$unit = \round((float) $it->price * (1 + $rate / 100), 2);
|
||||
$total = \round($unit * $qty, 2);
|
||||
$lines[] = [
|
||||
'type' => 'physical',
|
||||
'description' => \mb_substr(\trim((string) $it->item_name) !== '' ? (string) $it->item_name : ('Artikel ' . $it->item_number), 0, 255),
|
||||
'sku' => \mb_substr((string) $it->item_number, 0, 64),
|
||||
'quantity' => $qty,
|
||||
'quantityUnit'=> 'pcs',
|
||||
'unitPrice' => $money($unit),
|
||||
'totalAmount' => $money($total),
|
||||
'vatRate' => \number_format($rate, 2, '.', ''),
|
||||
'vatAmount' => $money(\round($total * $rate / (100 + $rate), 2)),
|
||||
];
|
||||
$sum += $total;
|
||||
}
|
||||
if (!$lines) {
|
||||
throw new \RuntimeException('Mollie: Bestellung ' . $orderId . ' hat keine Positionen.');
|
||||
}
|
||||
$rate = $maxRate > 0 ? $maxRate : 19.0;
|
||||
|
||||
$ship = \round((float) ($order->shipping_charges ?? 0), 2);
|
||||
if ($ship > 0) {
|
||||
$lines[] = ['type' => 'shipping_fee', 'description' => 'Versand', 'quantity' => 1,
|
||||
'unitPrice' => $money($ship), 'totalAmount' => $money($ship),
|
||||
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($ship * $rate / (100 + $rate), 2))];
|
||||
$sum += $ship;
|
||||
}
|
||||
$fee = \round((float) ($order->payment_method_charges ?? 0), 2);
|
||||
if ($fee > 0) {
|
||||
$lines[] = ['type' => 'surcharge', 'description' => 'Zahlartgebuehr', 'quantity' => 1,
|
||||
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
|
||||
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
|
||||
$sum += $fee;
|
||||
} elseif ($fee < 0) {
|
||||
$lines[] = ['type' => 'discount', 'description' => 'Zahlartrabatt', 'quantity' => 1,
|
||||
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
|
||||
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
|
||||
$sum += $fee;
|
||||
}
|
||||
$gift = \round((float) ($order->gift_certificate_total ?? 0), 2);
|
||||
if ($gift > 0) {
|
||||
$lines[] = ['type' => 'gift_card', 'description' => 'Gutschein', 'quantity' => 1,
|
||||
'unitPrice' => $money(-$gift), 'totalAmount' => $money(-$gift), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
|
||||
$sum -= $gift;
|
||||
}
|
||||
$disc = \round((float) ($order->discount_total ?? 0), 2) + \round((float) ($order->rebate_total ?? 0), 2);
|
||||
if ($disc > 0) {
|
||||
$lines[] = ['type' => 'discount', 'description' => 'Rabatt', 'quantity' => 1,
|
||||
'unitPrice' => $money(-$disc), 'totalAmount' => $money(-$disc),
|
||||
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round(-$disc * $rate / (100 + $rate), 2))];
|
||||
$sum -= $disc;
|
||||
}
|
||||
|
||||
// Rundung: Rechenwege des Shops (Netto-Summen, dann Steuer) und der Zeilen (brutto je Stueck)
|
||||
// koennen um Cents auseinanderliegen. Ausgleich als eigene Zeile ohne Steuer.
|
||||
$diff = \round($amount - $sum, 2);
|
||||
if (\abs($diff) >= 0.01) {
|
||||
$lines[] = ['type' => $diff > 0 ? 'surcharge' : 'discount', 'description' => 'Rundungsausgleich', 'quantity' => 1,
|
||||
'unitPrice' => $money($diff), 'totalAmount' => $money($diff), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments\Providers\PaypalV2;
|
||||
|
||||
/**
|
||||
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
|
||||
* TLS-Pruefung erzwungen. Access-Token wird pro Modus im Cache-Verzeichnis neben dem
|
||||
* Secrets-Schluessel abgelegt (ausserhalb des Docroots).
|
||||
*/
|
||||
final class Client
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $apiBase,
|
||||
private readonly string $clientId,
|
||||
private readonly string $secret,
|
||||
private readonly string $cacheFile
|
||||
) {
|
||||
}
|
||||
|
||||
public function accessToken(): string
|
||||
{
|
||||
if (\is_file($this->cacheFile)) {
|
||||
$c = \json_decode((string) @\file_get_contents($this->cacheFile), true);
|
||||
if (\is_array($c) && ($c['base'] ?? '') === $this->apiBase && ($c['cid'] ?? '') === \substr($this->clientId, 0, 8)
|
||||
&& isset($c['token'], $c['expires']) && (int) $c['expires'] > \time() + 60) {
|
||||
return (string) $c['token'];
|
||||
}
|
||||
}
|
||||
[$status, $body] = $this->raw('POST', '/v1/oauth2/token', 'grant_type=client_credentials',
|
||||
['Content-Type: application/x-www-form-urlencoded'], $this->clientId . ':' . $this->secret);
|
||||
$data = \json_decode($body, true);
|
||||
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
|
||||
throw new \RuntimeException('PayPal OAuth2 fehlgeschlagen (HTTP ' . $status . ')');
|
||||
}
|
||||
@\file_put_contents($this->cacheFile, \json_encode([
|
||||
'token' => $data['access_token'], 'expires' => \time() + (int) ($data['expires_in'] ?? 300),
|
||||
'base' => $this->apiBase, 'cid' => \substr($this->clientId, 0, 8),
|
||||
]), LOCK_EX);
|
||||
@\chmod($this->cacheFile, 0600);
|
||||
return (string) $data['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $payload
|
||||
* @param list<string> $extraHeaders
|
||||
* @return array{0:int,1:array<mixed>}
|
||||
*/
|
||||
public function request(string $method, string $path, array|string|null $payload = null, array $extraHeaders = []): array
|
||||
{
|
||||
$headers = \array_merge(['Content-Type: application/json', 'Authorization: Bearer ' . $this->accessToken()], $extraHeaders);
|
||||
// string = fertiges JSON (z.B. mit dem rohen Webhook-Body eingebettet), wird unveraendert gesendet
|
||||
$bodyStr = $payload === null ? null : (\is_string($payload) ? $payload : (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]];
|
||||
}
|
||||
|
||||
/** @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->apiBase . $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 HTTP-Transportfehler: ' . $err);
|
||||
}
|
||||
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
\curl_close($ch);
|
||||
return [$status, (string) $resp];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Payments\Providers\PaypalV2;
|
||||
|
||||
use Payments\AbstractProvider;
|
||||
use Payments\Log;
|
||||
use Payments\Result;
|
||||
use Payments\Secrets;
|
||||
|
||||
require_once __DIR__ . '/Client.php';
|
||||
|
||||
/**
|
||||
* PayPal Checkout, Orders API v2 (REST). Ersetzt das NVP-Express-Checkout von 2010.
|
||||
*
|
||||
* Ablauf: start() legt eine PayPal-Order (intent CAPTURE) an und leitet zur
|
||||
* Approve-URL. onReturn() captured und setzt bezahlt. onWebhook() bestaetigt
|
||||
* PAYMENT.CAPTURE.COMPLETED serverseitig. reconcile() zieht Vorgaenge nach, deren
|
||||
* Rueckkehr verloren ging (APPROVED aber nie gecaptured).
|
||||
*
|
||||
* Einstellungen (configuration, Gruppe paypal_v2): mode, brand_name, send_address, currency
|
||||
* Secrets (payment_provider_secrets): sandbox_client_id, sandbox_secret,
|
||||
* live_client_id, live_secret, webhook_id
|
||||
*/
|
||||
final class Provider extends AbstractProvider
|
||||
{
|
||||
private ?Client $client = null;
|
||||
|
||||
public function module(): string
|
||||
{
|
||||
return 'paypal_v2';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'PayPal';
|
||||
}
|
||||
|
||||
public function secretFields(): array
|
||||
{
|
||||
return [
|
||||
'sandbox_client_id' => 'Sandbox Client-ID',
|
||||
'sandbox_secret' => 'Sandbox Secret',
|
||||
'live_client_id' => 'Live Client-ID',
|
||||
'live_secret' => 'Live Secret',
|
||||
'webhook_id' => 'Webhook-ID (aus dem PayPal-Dashboard, fuer den aktiven Modus)',
|
||||
];
|
||||
}
|
||||
|
||||
public function mode(): string
|
||||
{
|
||||
return $this->setting('mode', 'sandbox') === 'live' ? 'live' : 'sandbox';
|
||||
}
|
||||
|
||||
private function apiBase(): string
|
||||
{
|
||||
return $this->mode() === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com';
|
||||
}
|
||||
|
||||
private function client(): Client
|
||||
{
|
||||
if ($this->client === null) {
|
||||
$m = $this->mode();
|
||||
$this->client = new Client(
|
||||
$this->apiBase(),
|
||||
$this->secret($m . '_client_id'),
|
||||
$this->secret($m . '_secret'),
|
||||
\dirname(Secrets::keyPath()) . '/paypal_v2_' . $m . '.token'
|
||||
);
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ start
|
||||
|
||||
public function start(int $orderId, float $amount): string
|
||||
{
|
||||
$order = $this->orderById($orderId);
|
||||
$number = (string) $order->order_number;
|
||||
$value = \number_format($amount, 2, '.', '');
|
||||
|
||||
$unit = [
|
||||
'reference_id' => (string) $orderId,
|
||||
'custom_id' => (string) $orderId,
|
||||
'invoice_id' => $number . '-' . $orderId,
|
||||
'description' => 'Bestellung ' . $number,
|
||||
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
|
||||
];
|
||||
|
||||
$context = [
|
||||
'brand_name' => $this->setting('brand_name', 'intelectra'),
|
||||
'locale' => 'de-DE',
|
||||
'landing_page' => 'NO_PREFERENCE',
|
||||
'user_action' => 'PAY_NOW',
|
||||
'return_url' => $this->returnUrl(),
|
||||
'cancel_url' => $this->cancelUrl(),
|
||||
'shipping_preference' => 'NO_SHIPPING',
|
||||
];
|
||||
|
||||
// Lieferadresse mitgeben: Voraussetzung fuer PayPal-Verkaeuferschutz bei Warenversand.
|
||||
if ($this->setting('send_address', '1') === '1') {
|
||||
$a = $this->shippingAddress($order);
|
||||
if ($a['street'] !== '' && $a['zip'] !== '' && $a['city'] !== '') {
|
||||
$context['shipping_preference'] = 'SET_PROVIDED_ADDRESS';
|
||||
$unit['shipping'] = [
|
||||
'name' => ['full_name' => $a['name'] !== '' ? $a['name'] : $a['company']],
|
||||
'address' => [
|
||||
'address_line_1' => $a['street'],
|
||||
'address_line_2' => $a['company'],
|
||||
'postal_code' => $a['zip'],
|
||||
'admin_area_2' => $a['city'],
|
||||
'country_code' => $a['country'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$payload = ['intent' => 'CAPTURE', 'purchase_units' => [$unit], 'application_context' => $context];
|
||||
$requestId = 'create-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $value), 0, 16);
|
||||
|
||||
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload, ['PayPal-Request-Id: ' . $requestId]);
|
||||
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
|
||||
|
||||
if ($status !== 201 || empty($body['id'])) {
|
||||
throw new \RuntimeException('PayPal Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
|
||||
}
|
||||
$approve = '';
|
||||
foreach (($body['links'] ?? []) as $link) {
|
||||
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
|
||||
$approve = (string) $link['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($approve === '') {
|
||||
throw new \RuntimeException('PayPal: kein Approve-Link in der Antwort.');
|
||||
}
|
||||
|
||||
$this->openTransaction($orderId, (string) $body['id'], $amount, $approve);
|
||||
return $approve;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- return
|
||||
|
||||
public function onReturn(array $query): Result
|
||||
{
|
||||
$ref = (string) ($query['token'] ?? '');
|
||||
if ($ref === '') {
|
||||
return Result::error('Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
|
||||
}
|
||||
$tx = $this->transactionByReference($ref);
|
||||
if (!$tx) {
|
||||
return Result::error('Der PayPal-Vorgang ist unbekannt. Ihr Warenkorb ist noch da.');
|
||||
}
|
||||
$orderId = (int) $tx->order_id;
|
||||
$number = (string) $tx->order_number;
|
||||
|
||||
if ($tx->status === 'paid') {
|
||||
$this->pendingClear();
|
||||
return Result::paid($orderId, $number);
|
||||
}
|
||||
|
||||
try {
|
||||
$cap = $this->capture($ref, $orderId, $number);
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('PayPal v2 onReturn: ' . $e->getMessage());
|
||||
$this->event('failed', $orderId, $number, null, $ref, null, (int) $tx->customer_id, $e->getMessage());
|
||||
return Result::error('Beim Abschluss der PayPal-Zahlung ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da, bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.', $orderId, $number);
|
||||
}
|
||||
|
||||
if ($cap['capture_status'] === 'COMPLETED') {
|
||||
if ($this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'return')) {
|
||||
return Result::paid($orderId, $number);
|
||||
}
|
||||
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
|
||||
}
|
||||
if ($cap['capture_status'] === 'PENDING') {
|
||||
$this->setTransactionStatus($ref, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
|
||||
return Result::pending($orderId, $number, 'PayPal prueft die Zahlung noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
|
||||
}
|
||||
$this->event('failed', $orderId, $number, null, $ref, $cap['capture_id'], (int) $tx->customer_id, 'capture ' . $cap['capture_status']);
|
||||
return Result::error('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . '). Ihr Warenkorb ist noch da.', $orderId, $number);
|
||||
}
|
||||
|
||||
public function onCancel(array $query): Result
|
||||
{
|
||||
$ref = (string) ($query['token'] ?? '');
|
||||
if ($ref === '') {
|
||||
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
|
||||
if (\is_array($p) && ($p['module'] ?? '') === $this->module()) {
|
||||
$ref = (string) $p['reference'];
|
||||
}
|
||||
}
|
||||
if ($ref !== '') {
|
||||
$tx = $this->transactionByReference($ref);
|
||||
if ($tx && $tx->status === 'paid') {
|
||||
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
|
||||
}
|
||||
$this->markCancelled($ref, 'Kunde hat bei PayPal abgebrochen');
|
||||
return Result::cancelled((int) ($tx->order_id ?? 0), (string) ($tx->order_number ?? ''));
|
||||
}
|
||||
$this->pendingClear();
|
||||
return Result::cancelled();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- webhook
|
||||
|
||||
public function onWebhook(array $headers, string $rawBody): int
|
||||
{
|
||||
$event = \json_decode($rawBody, true);
|
||||
if (!\is_array($event)) {
|
||||
return 400;
|
||||
}
|
||||
$type = (string) ($event['event_type'] ?? '');
|
||||
|
||||
$verify = $this->verifyWebhook($headers, $rawBody);
|
||||
if ($verify !== 'SUCCESS') {
|
||||
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null,
|
||||
['event_type' => $type, 'header_keys' => \array_keys(\array_filter($headers, static fn($k) => \str_starts_with($k, 'paypal-'), ARRAY_FILTER_USE_KEY))],
|
||||
'Signatur ungueltig: ' . $verify);
|
||||
return 400;
|
||||
}
|
||||
|
||||
$resource = \is_array($event['resource'] ?? null) ? $event['resource'] : [];
|
||||
$orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0));
|
||||
if ($orderId <= 0) {
|
||||
// 18.09.2026 -- Express ohne PATCH traegt custom_id 'express': Bestellung ueber die
|
||||
// PayPal-Order-ID finden (Capture: supplementary_data, Order-Events: resource.id).
|
||||
$ref = (string) ($resource['supplementary_data']['related_ids']['order_id']
|
||||
?? (\str_starts_with($type, 'CHECKOUT.ORDER.') ? ($resource['id'] ?? '') : ''));
|
||||
$tx = $ref !== '' ? $this->transactionByReference($ref) : null;
|
||||
$orderId = $tx ? (int) $tx->order_id : 0;
|
||||
}
|
||||
Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event);
|
||||
|
||||
switch ($type) {
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
|
||||
if ($tx && ($resource['status'] ?? '') === 'COMPLETED') {
|
||||
$this->markPaid((string) $tx->reference, (string) ($resource['amount']['value'] ?? ''),
|
||||
(string) ($resource['amount']['currency_code'] ?? ''), (string) ($resource['id'] ?? ''), '', '', 'webhook');
|
||||
}
|
||||
break;
|
||||
case 'CHECKOUT.ORDER.APPROVED':
|
||||
// Kunde hat freigegeben, Capture macht die Rueckkehr oder der Abgleicher.
|
||||
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
|
||||
if ($tx && $tx->status === 'open') {
|
||||
$this->setTransactionStatus((string) $tx->reference, 'open', 'APPROVED laut Webhook');
|
||||
}
|
||||
break;
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
case 'PAYMENT.CAPTURE.DECLINED':
|
||||
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
|
||||
if ($tx) {
|
||||
$this->markCancelled((string) $tx->reference, 'PayPal: ' . $type, 'webhook');
|
||||
}
|
||||
break;
|
||||
case 'PAYMENT.CAPTURE.REFUNDED':
|
||||
case 'PAYMENT.CAPTURE.REVERSED':
|
||||
// Nur protokolliert; Erstattungen werden im Backend gepflegt.
|
||||
break;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signaturpruefung gegen PayPal. Der rohe Body wird UNVERAENDERT als webhook_event
|
||||
* eingebettet -- PayPal hat die Signatur ueber genau diesen Text gebildet; ein
|
||||
* json_decode/encode in PHP aendert Escaping und Reihenfolge und laesst die
|
||||
* Pruefung scheitern.
|
||||
*
|
||||
* @param array<string,string> $headers
|
||||
* @return string 'SUCCESS' oder Grund der Ablehnung
|
||||
*/
|
||||
private function verifyWebhook(array $headers, string $rawBody): string
|
||||
{
|
||||
$webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? '';
|
||||
if ($webhookId === '') {
|
||||
return 'keine webhook_id hinterlegt';
|
||||
}
|
||||
$fields = [
|
||||
'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,
|
||||
];
|
||||
foreach ($fields as $k => $v) {
|
||||
if ($v === '') {
|
||||
return 'Header fehlt: ' . $k;
|
||||
}
|
||||
}
|
||||
// 1. Lokale Pruefung (PayPal "self-verification"): Signatur ueber
|
||||
// transmission_id|transmission_time|webhook_id|crc32(body), SHA256withRSA,
|
||||
// mit dem Zertifikat von cert_url (nur *.paypal.com, https).
|
||||
// Am 13.09.2026 in der Sandbox belegt: lokal GUELTIG, waehrend der
|
||||
// API-Endpunkt verify-webhook-signature dieselbe Zustellung mit FAILURE beantwortet.
|
||||
$local = $this->verifyLocally($fields, $rawBody);
|
||||
if ($local === 'SUCCESS') {
|
||||
return 'SUCCESS';
|
||||
}
|
||||
|
||||
// 2. Rueckfall: PayPal fragen (roher Body eingebettet).
|
||||
$json = \json_encode($fields, JSON_UNESCAPED_SLASHES);
|
||||
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
|
||||
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
|
||||
$vs = (string) ($body['verification_status'] ?? '');
|
||||
if ($status === 200 && $vs === 'SUCCESS') {
|
||||
return 'SUCCESS';
|
||||
}
|
||||
return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
|
||||
}
|
||||
|
||||
/** @param array<string,string> $f */
|
||||
private function verifyLocally(array $f, string $rawBody): string
|
||||
{
|
||||
if (\strtoupper($f['auth_algo']) !== 'SHA256WITHRSA') {
|
||||
return 'Algorithmus ' . $f['auth_algo'];
|
||||
}
|
||||
$u = \parse_url($f['cert_url']);
|
||||
$host = \strtolower((string) ($u['host'] ?? ''));
|
||||
if (($u['scheme'] ?? '') !== 'https' || !($host === 'paypal.com' || \str_ends_with($host, '.paypal.com'))) {
|
||||
return 'cert_url nicht von paypal.com';
|
||||
}
|
||||
$cert = $this->certificate($f['cert_url']);
|
||||
if ($cert === '') {
|
||||
return 'Zertifikat nicht ladbar';
|
||||
}
|
||||
$pub = \openssl_pkey_get_public($cert);
|
||||
if ($pub === false) {
|
||||
return 'Zertifikat unlesbar';
|
||||
}
|
||||
$msg = $f['transmission_id'] . '|' . $f['transmission_time'] . '|' . $f['webhook_id'] . '|' . \sprintf('%u', \crc32($rawBody));
|
||||
$sig = \base64_decode($f['transmission_sig'], true);
|
||||
if ($sig === false) {
|
||||
return 'Signatur kein Base64';
|
||||
}
|
||||
return \openssl_verify($msg, $sig, $pub, OPENSSL_ALGO_SHA256) === 1 ? 'SUCCESS' : 'Signatur passt nicht';
|
||||
}
|
||||
|
||||
/** Zertifikat von PayPal, 24 h neben dem Secrets-Schluessel zwischengespeichert. */
|
||||
private function certificate(string $url): string
|
||||
{
|
||||
$file = \dirname(Secrets::keyPath()) . '/paypal_cert_' . \md5($url) . '.pem';
|
||||
if (\is_file($file) && \filemtime($file) > \time() - 86400) {
|
||||
return (string) \file_get_contents($file);
|
||||
}
|
||||
$ch = \curl_init($url);
|
||||
\curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10]);
|
||||
$pem = (string) \curl_exec($ch);
|
||||
$code = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
\curl_close($ch);
|
||||
if ($code !== 200 || !\str_contains($pem, 'BEGIN CERTIFICATE')) {
|
||||
return \is_file($file) ? (string) \file_get_contents($file) : '';
|
||||
}
|
||||
@\file_put_contents($file, $pem, LOCK_EX);
|
||||
@\chmod($file, 0600);
|
||||
return $pem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- express
|
||||
|
||||
public function supportsExpress(): bool
|
||||
{
|
||||
return $this->setting('express', '1') === '1';
|
||||
}
|
||||
|
||||
/** Order ohne Adresse anlegen; PayPal liefert sie bei der Rueckkehr (GET_FROM_FILE). */
|
||||
public function startExpress(float $amount): string
|
||||
{
|
||||
$value = \number_format($amount, 2, '.', '');
|
||||
$payload = [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [[
|
||||
'reference_id' => 'default',
|
||||
'custom_id' => 'express',
|
||||
'description' => 'Warenkorb ' . $this->setting('brand_name', 'intelectra'),
|
||||
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
|
||||
]],
|
||||
'application_context' => [
|
||||
'brand_name' => $this->setting('brand_name', 'intelectra'),
|
||||
'locale' => 'de-DE',
|
||||
'landing_page' => 'NO_PREFERENCE',
|
||||
'user_action' => 'CONTINUE', // "Weiter" statt "Jetzt bezahlen": eingezogen wird erst in der Kasse
|
||||
'shipping_preference' => 'GET_FROM_FILE',
|
||||
'return_url' => $this->expressReturnUrl(),
|
||||
'cancel_url' => $this->expressCancelUrl(),
|
||||
],
|
||||
];
|
||||
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload,
|
||||
['PayPal-Request-Id: express-' . \session_id() . '-' . \substr(\hash('sha256', $value . '|' . \time()), 0, 12)]);
|
||||
Log::write($this->module(), 'express_create', null, null, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
|
||||
if ($status !== 201 || empty($body['id'])) {
|
||||
throw new \RuntimeException('PayPal Express: Order-Anlage fehlgeschlagen (HTTP ' . $status . ')');
|
||||
}
|
||||
$approve = '';
|
||||
foreach (($body['links'] ?? []) as $link) {
|
||||
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
|
||||
$approve = (string) $link['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($approve === '') {
|
||||
throw new \RuntimeException('PayPal Express: kein Approve-Link.');
|
||||
}
|
||||
$this->event('payment_started', null, null, $amount, (string) $body['id'], null, null, 'express aus dem Warenkorb');
|
||||
return $approve;
|
||||
}
|
||||
|
||||
public function onExpressReturn(array $query): ?array
|
||||
{
|
||||
$ref = (string) ($query['token'] ?? '');
|
||||
if ($ref === '') {
|
||||
return null;
|
||||
}
|
||||
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
|
||||
Log::write($this->module(), 'express_get', null, null, $ref, (string) ($body['status'] ?? ''), $status, $body);
|
||||
if ($status !== 200 || ($body['purchase_units'][0]['custom_id'] ?? '') !== 'express') {
|
||||
return null;
|
||||
}
|
||||
if (($body['status'] ?? '') !== 'APPROVED') {
|
||||
return null; // Kunde hat nicht freigegeben
|
||||
}
|
||||
$payer = $body['payer'] ?? [];
|
||||
$ship = $body['purchase_units'][0]['shipping'] ?? [];
|
||||
$addr = $ship['address'] ?? [];
|
||||
$full = \trim((string) ($ship['name']['full_name'] ?? ''));
|
||||
$first = (string) ($payer['name']['given_name'] ?? '');
|
||||
$last = (string) ($payer['name']['surname'] ?? '');
|
||||
if ($full !== '' && $full !== \trim($first . ' ' . $last)) {
|
||||
// Lieferadresse lautet auf einen anderen Namen (z.B. Packstation, Partner)
|
||||
$parts = \explode(' ', $full);
|
||||
$last = \array_pop($parts);
|
||||
$first = \implode(' ', $parts);
|
||||
}
|
||||
[$street, $number] = self::splitStreet((string) ($addr['address_line_1'] ?? ''));
|
||||
$approve = '';
|
||||
foreach (($body['links'] ?? []) as $link) {
|
||||
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
|
||||
$approve = (string) $link['href'];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'reference' => $ref,
|
||||
'email' => (string) ($payer['email_address'] ?? ''),
|
||||
'firstname' => $first,
|
||||
'surname' => $last,
|
||||
'company' => (string) ($addr['address_line_2'] ?? ''),
|
||||
'street' => $street,
|
||||
'house_number' => $number,
|
||||
'zip_code' => (string) ($addr['postal_code'] ?? ''),
|
||||
'city' => (string) ($addr['admin_area_2'] ?? ''),
|
||||
'country_iso' => (string) ($addr['country_code'] ?? ''),
|
||||
'approve_url' => $approve,
|
||||
'payer_id' => (string) ($payer['payer_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Betrag auf den Endbetrag setzen (PATCH ist im Status APPROVED erlaubt), Vorgang an
|
||||
* die Bestellung haengen, einziehen. Verlangt PayPal nach der Aenderung eine erneute
|
||||
* Freigabe, kommt die Approve-URL zurueck und der Kunde geht noch einmal hin.
|
||||
*/
|
||||
public function finishExpress(int $orderId, float $amount, string $reference): string
|
||||
{
|
||||
$order = $this->orderById($orderId);
|
||||
$number = (string) $order->order_number;
|
||||
$value = \number_format($amount, 2, '.', '');
|
||||
|
||||
// 18.09.2026 -- erst nachsehen, was bei PayPal steht. Stimmt der Betrag schon (die Kasse
|
||||
// hat Versand und Aufschlag beim Express-Start mitgegeben), wird NICHT gepatcht: jede
|
||||
// Aenderung nach der Freigabe kostet auf Live eine zweite Freigabe (422
|
||||
// PAYER_ACTION_REQUIRED, Sandbox winkt das durch). custom_id bleibt dann 'express',
|
||||
// die Zuordnung laeuft ueber die Referenz (payment_transactions).
|
||||
[$s0, $b0] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
|
||||
$ppValue = (string) ($b0['purchase_units'][0]['amount']['value'] ?? '');
|
||||
$ppCur = (string) ($b0['purchase_units'][0]['amount']['currency_code'] ?? '');
|
||||
if ($s0 === 200 && $ppValue === $value && $ppCur === $this->currency()) {
|
||||
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, 'skipped', $s0, ['amount' => $value, 'detail' => 'Betrag stimmt bereits, kein PATCH']);
|
||||
$b2 = $b0;
|
||||
} else {
|
||||
$patch = [
|
||||
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/amount",
|
||||
'value' => ['currency_code' => $this->currency(), 'value' => $value]],
|
||||
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => (string) $orderId],
|
||||
['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/invoice_id", 'value' => $number . '-' . $orderId],
|
||||
];
|
||||
[$status, $body] = $this->client()->request('PATCH', '/v2/checkout/orders/' . \rawurlencode($reference), $patch);
|
||||
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, (string) $status, $status, $body ?: ['amount' => $value, 'vorher' => $ppValue]);
|
||||
if ($status !== 204) {
|
||||
throw new \RuntimeException('PayPal Express: Betrag konnte nicht gesetzt werden (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
|
||||
}
|
||||
// Approve-URL fuer den Fall, dass PayPal eine erneute Freigabe verlangt
|
||||
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
|
||||
}
|
||||
$approveUrl = '';
|
||||
foreach (($b2['links'] ?? []) as $link) {
|
||||
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
|
||||
$approveUrl = (string) $link['href'];
|
||||
}
|
||||
}
|
||||
$this->openTransaction($orderId, $reference, $amount, $approveUrl);
|
||||
|
||||
try {
|
||||
$cap = $this->capture($reference, $orderId, $number);
|
||||
} catch (\RuntimeException $e) {
|
||||
if (\str_contains($e->getMessage(), 'ORDER_NOT_APPROVED') || \str_contains($e->getMessage(), 'PAYER_ACTION_REQUIRED')) {
|
||||
Log::write($this->module(), 'express_reapprove', $orderId, $number, $reference, 'needs approval');
|
||||
return $approveUrl !== '' ? $approveUrl : 'https://www.' . ($this->mode() === 'live' ? '' : 'sandbox.') . 'paypal.com/checkoutnow?token=' . \rawurlencode($reference);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
if ($cap['capture_status'] === 'COMPLETED') {
|
||||
if ($this->markPaid($reference, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'express')) {
|
||||
return '';
|
||||
}
|
||||
throw new \RuntimeException('Zahlung eingezogen, aber Zuordnung fehlgeschlagen (Bestellung ' . $number . ')');
|
||||
}
|
||||
if ($cap['capture_status'] === 'PENDING') {
|
||||
$this->setTransactionStatus($reference, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
|
||||
return '';
|
||||
}
|
||||
throw new \RuntimeException('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ')');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- reconcile
|
||||
|
||||
public function reconcile(bool $dryRun = true): array
|
||||
{
|
||||
$out = [];
|
||||
// Nach 10 Minuten nachschauen, nach 72 Stunden aufgeben (PayPal-Orders verfallen nach 3 Tagen).
|
||||
foreach ($this->openTransactions(10, 72) as $tx) {
|
||||
$ref = (string) $tx->reference;
|
||||
try {
|
||||
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
|
||||
} catch (\Throwable $e) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
if ($status === 404) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
|
||||
if (!$dryRun) {
|
||||
// Beobachtet auf Live 19.-22.09.2026: nie freigegebene Orders (CREATED) liefern
|
||||
// nach ~6 h 404 -- der Kunde ist bei PayPal nicht bis zur Freigabe gekommen.
|
||||
$age = \round((\time() - \strtotime((string) $tx->created_at)) / 3600, 1);
|
||||
$this->markCancelled($ref, "Kunde hat den Vorgang bei PayPal nicht abgeschlossen (nach $age h bei PayPal unbekannt, 404)", 'reconcile');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$pstatus = (string) ($body['status'] ?? '');
|
||||
$cap = $this->normalize($body);
|
||||
|
||||
if ($pstatus === 'COMPLETED' && $cap['capture_status'] === 'COMPLETED') {
|
||||
$out[] = "$ref (Order {$tx->order_id}): bei PayPal bezahlt, im Shop offen -> bezahlt setzen" . ($dryRun ? ' [dry-run]' : '');
|
||||
if (!$dryRun) {
|
||||
$this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'reconcile');
|
||||
}
|
||||
} elseif ($pstatus === 'APPROVED') {
|
||||
$out[] = "$ref (Order {$tx->order_id}): Kunde hat freigegeben, Rueckkehr verloren -> Capture nachholen" . ($dryRun ? ' [dry-run]' : '');
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
$c = $this->capture($ref, (int) $tx->order_id, (string) $tx->order_number);
|
||||
if ($c['capture_status'] === 'COMPLETED') {
|
||||
$this->markPaid($ref, $c['amount'], $c['currency'], $c['capture_id'], $c['payer_email'], $c['payer_id'], 'reconcile');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$out[] = " Capture fehlgeschlagen: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
} elseif (\in_array($pstatus, ['VOIDED', 'EXPIRED'], true)) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): PayPal $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
|
||||
if (!$dryRun) {
|
||||
$this->markCancelled($ref, 'PayPal: ' . $pstatus, 'reconcile');
|
||||
}
|
||||
} else {
|
||||
// CREATED / SAVED / PAYER_ACTION_REQUIRED: Kunde ist noch unterwegs oder hat das Fenster zugemacht.
|
||||
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
|
||||
if ($age >= 24) {
|
||||
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
|
||||
if (!$dryRun) {
|
||||
$this->markCancelled($ref, 'PayPal ' . $pstatus . ' nach ' . \round($age) . ' h', 'reconcile');
|
||||
}
|
||||
} else {
|
||||
$out[] = "$ref (Order {$tx->order_id}): $pstatus, " . \round($age, 1) . " h alt -> warten";
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- intern
|
||||
|
||||
/** @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,pending_reason:string} */
|
||||
private function capture(string $ref, int $orderId, string $number): array
|
||||
{
|
||||
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders/' . \rawurlencode($ref) . '/capture', null,
|
||||
['PayPal-Request-Id: capture-' . $ref, 'Prefer: return=representation']);
|
||||
Log::write($this->module(), 'capture', $orderId, $number, $ref, (string) ($body['status'] ?? ''), $status, $body);
|
||||
|
||||
if ($status === 201 || $status === 200) {
|
||||
return $this->normalize($body);
|
||||
}
|
||||
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
|
||||
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
|
||||
Log::write($this->module(), 'get', $orderId, $number, $ref, (string) ($b2['status'] ?? ''), $s2, $b2);
|
||||
return $this->normalize($b2);
|
||||
}
|
||||
throw new \RuntimeException('Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
|
||||
}
|
||||
|
||||
/** @param array<mixed> $body */
|
||||
private 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'] ?? ''),
|
||||
'pending_reason' => (string) ($cap['status_details']['reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
|
||||
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 =
|
||||
@@ -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 */
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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ä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üfen Ihr Anliegen und melden uns zeitnah bei Ihnen.</p>';
|
||||
$msg .= '<p>Viele Grüße<br>Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'</p>';
|
||||
|
||||
return $mailer->send_mail($subject, $msg, $to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +155,12 @@ class website {
|
||||
function main_widget_menu_2() {
|
||||
$structure_object = new Structure($this->base_object);
|
||||
$selected_ids = array();
|
||||
if (isset($_GET['menu_id'])) {
|
||||
// 22.09.2026: !empty statt isset -- das Kundencenter postet auf "...&parent_id=" (leer),
|
||||
// isset war dann wahr und get_all_tree() baute "parent_id in(10636,)" -> SQL-Fatal.
|
||||
if (!empty($_GET['menu_id'])) {
|
||||
$selected_ids[] = $_GET['menu_id'];
|
||||
}
|
||||
if (isset($_GET['parent_id'])) {
|
||||
if (!empty($_GET['parent_id'])) {
|
||||
$selected_ids[] = $_GET['parent_id'];
|
||||
}
|
||||
$data = $structure_object->get_all_tree(5, $selected_ids, $this->base_object->customer_group->id);
|
||||
|
||||
@@ -31,13 +31,19 @@ if (isset($_GET["garda"]) || isset($_SESSION["garda"])) {
|
||||
header("location: https://www.intelectra-shop.de");
|
||||
}*/
|
||||
|
||||
// DEV DEBUG TEMPORARY
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
// DEV: Fehler NICHT auf den Bildschirm. 13.09.2026: display_errors=1 schrieb PHP-Warnungen
|
||||
// als HTML in die value="" der Kassenfelder, der naechste POST trug damit "<br /><b>Warning"
|
||||
// in shipping[firstname] & Co. und die WAF blockte die Kasse mit 403 (Regel 941320).
|
||||
// Alles landet weiter in logs/shop_error.log und logs/fatal.log.
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('display_startup_errors', 0);
|
||||
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');
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -152,6 +152,11 @@ class admin_order_editor {
|
||||
$this->layout_object->assign('order', $order_data);
|
||||
$this->layout_object->assign('order_items', $order_items);
|
||||
$this->layout_object->assign('footer', $order_footer);
|
||||
|
||||
// 22.09.2026 -- Zahlungsvorgang aus core/payments (PayPal v2, Mollie ...):
|
||||
// Status, Referenz, Transaktions-ID, Grund und Verlauf. Null bei alten Zahlarten.
|
||||
include_once './core/payments/Registry.php';
|
||||
$this->layout_object->assign('payment_tx', \Payments\Overview::forOrder($this->base_object->db, (int)$id, $this->base_object));
|
||||
} catch (Exception $e) {
|
||||
$this->layout_object->assign('error_message', $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ class admin_payment_method_editor {
|
||||
$is_submitted = false;
|
||||
}
|
||||
|
||||
// 13.09.2026: Reiter "Einstellungen" speichern (Konfigurationsgruppe + Secrets).
|
||||
// Vorher hatte dieses Formular keinen Empfaenger -- es postete ins Leere.
|
||||
if (isset($_POST['payment_config_save']) && $has_id) {
|
||||
$this->save_provider_config((int)$_POST['id']);
|
||||
$is_submitted = false;
|
||||
$_GET['id'] = (int)$_POST['id'];
|
||||
}
|
||||
|
||||
// decide what CRUD method to call and call it in ListAndEdit
|
||||
// create
|
||||
$payment_method_object = new Payment_method($this->base_object);
|
||||
@@ -60,6 +68,7 @@ class admin_payment_method_editor {
|
||||
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
|
||||
$this->layout_object->assign('config_data', $config_data);
|
||||
}
|
||||
$this->assign_provider($payment_method_data);
|
||||
}// read
|
||||
elseif($has_id && !$is_submitted && $_GET['mod'] != 'delete') {
|
||||
// template variable name
|
||||
@@ -72,6 +81,7 @@ class admin_payment_method_editor {
|
||||
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
|
||||
$this->layout_object->assign('config_data', $config_data);
|
||||
}
|
||||
$this->assign_provider($payment_method_data);
|
||||
} // update
|
||||
elseif($has_id && $is_submitted && $_GET['mod'] != 'delete') {
|
||||
$payment_method_object->id = $_POST['id'];
|
||||
@@ -92,6 +102,7 @@ class admin_payment_method_editor {
|
||||
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
|
||||
$this->layout_object->assign('config_data', $config_data);
|
||||
}
|
||||
$this->assign_provider($payment_method_data);
|
||||
} // delete
|
||||
elseif($has_id && isset($_GET['mod']) && $_GET['mod'] == 'delete'){
|
||||
$payment_method_object->id = $_GET['id'];
|
||||
@@ -103,6 +114,76 @@ class admin_payment_method_editor {
|
||||
|
||||
return $this->layout_object->fetch('admin_payment_method_editor.tpl');
|
||||
}
|
||||
|
||||
/**
|
||||
* 13.09.2026 -- Zahlungsanbieter aus core/payments: Secret-Felder fuers Template.
|
||||
* Klartext wird nie ausgeliefert, nur "hinterlegt seit".
|
||||
*/
|
||||
private function assign_provider($payment_method_data) {
|
||||
if (!is_object($payment_method_data) || empty($payment_method_data->module)) {
|
||||
return;
|
||||
}
|
||||
include_once './core/payments/Registry.php';
|
||||
if (!\Payments\Registry::has((string)$payment_method_data->module)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$provider = \Payments\Registry::get((string)$payment_method_data->module, $this->base_object);
|
||||
} catch (Throwable $e) {
|
||||
$this->layout_object->assign('provider_error', $e->getMessage());
|
||||
return;
|
||||
}
|
||||
if (!$provider) {
|
||||
return;
|
||||
}
|
||||
$key_ok = \Payments\Secrets::keyAvailable();
|
||||
$status = $key_ok ? \Payments\Secrets::status($this->base_object->db, $provider->module()) : array();
|
||||
$fields = array();
|
||||
foreach ($provider->secretFields() as $name => $label) {
|
||||
$fields[] = array(
|
||||
'name' => $name,
|
||||
'label' => $label,
|
||||
'since' => isset($status[$name]) ? $status[$name] : '',
|
||||
);
|
||||
}
|
||||
$this->layout_object->assign('provider_label', $provider->label());
|
||||
$this->layout_object->assign('provider_module', $provider->module());
|
||||
$this->layout_object->assign('provider_key_ok', $key_ok ? 1 : 0);
|
||||
$this->layout_object->assign('provider_key_path', \Payments\Secrets::keyPath());
|
||||
$this->layout_object->assign('provider_secrets', $fields);
|
||||
$this->layout_object->assign('provider_webhook_url', rtrim(MAIN_URL, '/').'/payment_webhook.php?p='.$provider->module());
|
||||
}
|
||||
|
||||
/** Einstellungen (configuration-Gruppe) und Secrets aus dem Reiter "Einstellungen" speichern. */
|
||||
private function save_provider_config($payment_method_id) {
|
||||
$pm_object = new Payment_method($this->base_object);
|
||||
$pm_object->id = $payment_method_id;
|
||||
$pm = $pm_object->get_data();
|
||||
if (!$pm) {
|
||||
return;
|
||||
}
|
||||
if (!empty($pm->configuration_group_id) && isset($_POST['form_field']) && is_array($_POST['form_field'])) {
|
||||
$clean = array();
|
||||
foreach ($_POST['form_field'] as $k => $v) {
|
||||
$clean[$k] = $this->base_object->db->real_escape_string((string)$v);
|
||||
}
|
||||
$this->config->update($pm->configuration_group_id, $clean);
|
||||
}
|
||||
if (!empty($pm->module) && isset($_POST['secret_field']) && is_array($_POST['secret_field'])) {
|
||||
include_once './core/payments/Registry.php';
|
||||
if (\Payments\Registry::has((string)$pm->module) && \Payments\Secrets::keyAvailable()) {
|
||||
$provider = \Payments\Registry::get((string)$pm->module, $this->base_object);
|
||||
$allowed = $provider ? $provider->secretFields() : array();
|
||||
foreach ($_POST['secret_field'] as $name => $value) {
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || !isset($allowed[$name])) {
|
||||
continue; // leer = unveraendert lassen
|
||||
}
|
||||
\Payments\Secrets::set($this->base_object->db, $provider->module(), (string)$name, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -250,6 +250,8 @@ class website_customercenter {
|
||||
}
|
||||
|
||||
header('location: ' . $_SERVER["HTTP_REFERER"]);
|
||||
// 22.09.2026: nach dem Redirect nicht weiterrendern (lief sonst in die Widgets und den SQL-Fatal).
|
||||
exit();
|
||||
}
|
||||
|
||||
private function edit_base_data($customer_id) {
|
||||
@@ -332,7 +334,12 @@ class website_customercenter {
|
||||
$base_url .= ':' . $_SERVER['SERVER_PORT'];
|
||||
}
|
||||
|
||||
return $base_url . '/index.php?menu_id=' . $_GET['menu_id'] . '&parent_id=' . $_GET['parent_id'];
|
||||
// 22.09.2026: leere Parameter nicht anhaengen (Seite kommt per short_uri /Kundencenter/ ohne parent_id).
|
||||
$url = $base_url . '/index.php?menu_id=' . (int) ($_GET['menu_id'] ?? 0);
|
||||
if (!empty($_GET['parent_id'])) {
|
||||
$url .= '&parent_id=' . (int) $_GET['parent_id'];
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function get_invoice_pdf() {
|
||||
|
||||
@@ -392,6 +392,12 @@ class website_dyncontent {
|
||||
$var_data = array();
|
||||
$var_data['[#HIDDEN_TEXT_START]'] = '<span class="text_box_hidden_text">';
|
||||
$var_data['[#HIDDEN_TEXT_END]'] = ' </span> <a class="text_box_link_show_text" href="#">mehr>></a><a class="text_box_link_hide_text" href="#"><<weniger</a>';
|
||||
// 27.09.2026 -- [#zahlarten]: Liste der aktiven Zahlarten der Kundengruppe (Tom: die
|
||||
// CMS-Seite "Bezahlarten" soll nicht mehr von Hand gepflegt werden, sondern dem
|
||||
// Aktiv-Schalter im Backend folgen).
|
||||
if (strpos($block->content, '[#zahlarten]') !== false) {
|
||||
$var_data['[#zahlarten]'] = $this->zahlarten_liste();
|
||||
}
|
||||
$block->content = strtr($block->content, $var_data);
|
||||
|
||||
|
||||
@@ -401,6 +407,48 @@ class website_dyncontent {
|
||||
return $this->layout_object->_fetch('content_box_textbox.tpl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlarten, die der Besucher in der Kasse bekaeme -- dieselbe Quelle wie die Kasse
|
||||
* (Payment_method::getMethodsForGroup: payment_methods.active, shipping_area.active_<key>,
|
||||
* Kundengruppen-Freischaltung). Besucher (Gruppe 100) sehen die Gast-Zahlarten, weil sie
|
||||
* als Gast bestellen; Haendler ihr B2B-Versandgebiet. Bankeinzug/Rechnung nur, wenn der
|
||||
* angemeldete Kunde dafuer freigeschaltet ist (wie in content_cash_desk_neu.tpl).
|
||||
*/
|
||||
private function zahlarten_liste() {
|
||||
try {
|
||||
include_once './core/payment_method.class.php';
|
||||
$group = (int)$this->base_object->customer_group->id;
|
||||
if ($group <= 0 || $group == 100) {
|
||||
$group = 103; // Gast
|
||||
}
|
||||
$area = ($group == 101) ? 30 : 1; // Deutschland-B2B / Deutschland-B2C
|
||||
$pm_object = new Payment_method($this->base_object);
|
||||
$all = $pm_object->getMethodsForGroup($group, -1, $area);
|
||||
$customer = $this->base_object->customer;
|
||||
$list = array();
|
||||
foreach ((array)$all as $pm) {
|
||||
if (!isset($pm->activeType) || $pm->activeType <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($pm->helperjoinkey == 'be' && empty($customer->direct_debit_active)) {
|
||||
continue;
|
||||
}
|
||||
if ($pm->helperjoinkey == 'r' && empty($customer->rechnung_active)) {
|
||||
continue;
|
||||
}
|
||||
$list[] = $pm;
|
||||
}
|
||||
$this->layout_object->assign('zahlarten', $list);
|
||||
$this->layout_object->assign('zahlarten_gruppe', $group);
|
||||
// Platzhalter im Bestelltext (z.B. "Kundenspezifisch" bei Haendlern), wie content_cash_desk_neu.tpl
|
||||
$this->layout_object->assign('zahlarten_freitext3', isset($customer->freetextfield3) ? (string)$customer->freetextfield3 : '');
|
||||
return $this->layout_object->_fetch('content_box_zahlarten.tpl');
|
||||
} catch (Throwable $e) {
|
||||
error_log('zahlarten_liste: '.$e->getMessage());
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function banner($structure_id, $block) {
|
||||
$config = array();
|
||||
eval($block->configuration);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,33 @@ class website_shoppingcart {
|
||||
|
||||
private function show_shoppingcart() {
|
||||
if (isset($_POST['customer_info'])) {
|
||||
$_SESSION['customer_info'] = $_POST['customer_info'];
|
||||
$_SESSION['customer_info'] = trim((string)$_POST['customer_info']);
|
||||
}
|
||||
|
||||
// 18.09.2026 -- Warenkorb ist EIN Formular: "Zur Kasse" und die Express-Knoepfe schicken
|
||||
// es ab (Mengen + Anmerkung werden so mitgespeichert) und das Ziel steht in cart_goto.
|
||||
if (isset($_POST['cart_goto']) && $_POST['cart_goto'] !== '') {
|
||||
$goto = (string)$_POST['cart_goto'];
|
||||
if ($goto === 'kasse') {
|
||||
header('Location: /Kasse/');
|
||||
exit;
|
||||
}
|
||||
// 27.09.2026: "Weiter einkaufen" geht ueber das Formular (Mengen werden mitgespeichert),
|
||||
// festes Ziel Startseite -- kein offener Redirect.
|
||||
if ($goto === 'weiter') {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
if (preg_match('/^express:([a-z0-9_]{1,40})$/', $goto, $m)) {
|
||||
header('Location: /Kasse/?payment_express_start='.$m[1]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// Post-Redirect-Get fuer die neue Ansicht: nach Mengen-/Anmerkungsaenderung per GET neu
|
||||
// laden, sonst fragt ein Reload "Formular erneut senden" und schickt die Mengen doppelt.
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cart_goto']) && !$this->alte_ansicht()) {
|
||||
header('Location: /Warenkorb/');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_SESSION['customer_info']) && $_SESSION['customer_info']) {
|
||||
@@ -56,14 +82,64 @@ class website_shoppingcart {
|
||||
}
|
||||
|
||||
$data = $this->shoppingcart_object->get_data();
|
||||
|
||||
|
||||
$this->layout_object->assign('shoppingcart', $data);
|
||||
$this->layout_object->assign('shoppingcartVATUsage', $this->base_object->customer_group->show_tax);
|
||||
|
||||
// 14.09.2026: Express-Buttons der Zahlungsanbieter ("Direkt mit PayPal bezahlen").
|
||||
// Angezeigt, wenn der Anbieter Express kann und seine Zahlart aktiv ist.
|
||||
$this->layout_object->assign('express_providers', $this->express_providers());
|
||||
|
||||
return $this->layout_object->_fetch('content_shoppingcart.tpl');
|
||||
// 27.09.2026 -- die neue Ansicht (content_shoppingcart_neu.tpl) ist Standard.
|
||||
// Rueckfall auf das alte Template: /Warenkorb/?ansicht=alt (siehe alte_ansicht()).
|
||||
if ($this->alte_ansicht()) {
|
||||
return $this->layout_object->_fetch('content_shoppingcart.tpl');
|
||||
}
|
||||
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
|
||||
return $this->layout_object->_fetch('content_shoppingcart_neu.tpl');
|
||||
} // end show_shoppingcart
|
||||
|
||||
|
||||
/**
|
||||
* Rueckfall auf die alte Warenkorb-/Kassenansicht (27.09.2026, Go-Live der neuen Ansicht):
|
||||
* ?ansicht=alt schaltet fuer die Sitzung auf die alten Templates, ?ansicht=neu hebt das auf.
|
||||
* Haelt ueber die Formular-POSTs hinweg, weil die alten Templates keinen Parameter mitschicken.
|
||||
* Dieselbe Weiche steht in website_cashdesk.php.
|
||||
*/
|
||||
private function alte_ansicht() {
|
||||
if (isset($_GET['ansicht'])) {
|
||||
$_SESSION['easyshop']['ansicht_alt'] = ($_GET['ansicht'] === 'alt') ? 1 : 0;
|
||||
}
|
||||
return !empty($_SESSION['easyshop']['ansicht_alt']);
|
||||
}
|
||||
|
||||
|
||||
/** @return array<int,array{module:string,label:string}> */
|
||||
private function express_providers() {
|
||||
$out = array();
|
||||
$file = $_SERVER['DOCUMENT_ROOT'].'/core/payments/Registry.php';
|
||||
if (!is_file($file)) {
|
||||
return $out;
|
||||
}
|
||||
include_once $file;
|
||||
$res = $this->base_object->db->query("SELECT module FROM payment_methods WHERE active=1 AND module<>''");
|
||||
while ($res && ($row = $res->fetch_object())) {
|
||||
$module = (string)$row->module;
|
||||
if (!\Payments\Registry::has($module)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$p = \Payments\Registry::get($module, $this->base_object);
|
||||
if ($p && $p->supportsExpress()) {
|
||||
$out[] = array('module' => $module, 'label' => $p->label());
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('Payments express button '.$module.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function update_shoppingcart() {
|
||||
foreach ($_POST['update_item'] as $item => $quantity) {
|
||||
if ($quantity > 0) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook-Endpunkt fuer alle Zahlungsanbieter: /payment_webhook.php?p=<module>
|
||||
*
|
||||
* Server-zu-Server, keine Session. Der Provider prueft die Signatur selbst und
|
||||
* entscheidet, was mit dem Ereignis passiert. Antwort 2xx = angenommen, sonst
|
||||
* stellt der Anbieter erneut zu.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/core/payments/bootstrap.php';
|
||||
|
||||
$module = isset($_GET['p']) ? (string) $_GET['p'] : '';
|
||||
$provider = \Payments\Registry::get($module, $base_object);
|
||||
if (!$provider) {
|
||||
\http_response_code(404);
|
||||
echo 'unknown provider';
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawBody = (string) \file_get_contents('php://input');
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (\strncmp($key, 'HTTP_', 5) === 0) {
|
||||
$headers[\strtolower(\str_replace('_', '-', \substr($key, 5)))] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte Zustellung roh ablegen -- nur wenn logs/webhook_debug existiert (Fehlersuche an
|
||||
// der Signaturpruefung, 13.09.2026). logs/ ist per .htaccess gesperrt.
|
||||
if (\is_file(__DIR__ . '/logs/webhook_debug')) {
|
||||
@\file_put_contents(__DIR__ . '/logs/webhook_last_' . \preg_replace('/[^a-z0-9_]/', '', $module) . '.json',
|
||||
\json_encode(['time' => \date('c'), 'headers' => $headers, 'body' => $rawBody], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $provider->onWebhook($headers, $rawBody);
|
||||
} catch (\Throwable $e) {
|
||||
\error_log('payment_webhook ' . $module . ': ' . $e->getMessage());
|
||||
$status = 500;
|
||||
}
|
||||
|
||||
\http_response_code($status);
|
||||
echo $status < 300 ? 'ok' : 'rejected';
|
||||
@@ -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); ?> 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); ?> 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, ',', '.'); ?> €</span>
|
||||
<span style="font-size:12px;color:#999;text-decoration:line-through;font-weight:400"><?php echo number_format($pb, 2, ',', '.'); ?> €</span>
|
||||
<?php elseif ($pb > 0): ?>
|
||||
<?php echo number_format($pb, 2, ',', '.'); ?> €
|
||||
<?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 <Nr>" 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>
|
||||
@@ -0,0 +1,28 @@
|
||||
ul.tabs {
|
||||
list-style:none;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
ul.tabs li {
|
||||
display:inline-block;
|
||||
border:1px solid black;
|
||||
border-bottom:none;
|
||||
}
|
||||
ul.tabs li a {
|
||||
height:30px;
|
||||
line-height:30px;
|
||||
text-decoration:none;
|
||||
margin-left:10px;
|
||||
margin-right:10px;
|
||||
|
||||
}
|
||||
ui.tabs li a:hover {
|
||||
text-decoration:underline;
|
||||
}
|
||||
#tabs div.tab {
|
||||
display:none;
|
||||
}
|
||||
#tabs div.display {
|
||||
display:block;
|
||||
border-top:1px solid black;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.easyway_shop .line {
|
||||
width:100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block {
|
||||
border: 1px solid #D9D9D9;
|
||||
padding:15px;
|
||||
}
|
||||
|
||||
.full {
|
||||
width: 948px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 537px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.narrow {
|
||||
width: 312px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.easyway_shop label {
|
||||
color: #003580;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 1em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.easyway_shop input[type="text"], .easyway_shop select {
|
||||
width: 100%;
|
||||
border: 1px solid #B5C0F9;
|
||||
color: #009900;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.wide .width_1_out_of_5 {
|
||||
display: inline-block;
|
||||
height:40px;
|
||||
width: 94px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wide .width_2_out_of_5 {
|
||||
display: inline-block;
|
||||
height:40px;
|
||||
width: 206px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wide .width_3_out_of_5 {
|
||||
display: inline-block;
|
||||
height:40px;
|
||||
width: 322px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wide .width_4_out_of_5 {
|
||||
display: inline-block;
|
||||
height:40px;
|
||||
width: 416px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wide .width_5_out_of_5 {
|
||||
display: inline-block;
|
||||
height:40px;
|
||||
width: 517px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action_button {
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
border: 1px solid #B5C0F9;
|
||||
padding: 5px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action_button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
.payment_method_config {
|
||||
padding-left:5px;
|
||||
clear:both;
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid #D9D9D9;
|
||||
padding: 15px;
|
||||
margin-bottom:15px;
|
||||
}
|
||||
|
||||
.regular {
|
||||
width:573px;
|
||||
}
|
||||
|
||||
#contentContainer #adminLeftFloater {
|
||||
float: left;
|
||||
width: 605px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
#contentContainer #adminRightFloater {
|
||||
float: left;
|
||||
width: 344px;
|
||||
padding-left: 15px;
|
||||
min-height: 866px;
|
||||
background: transparent url(../images/content.rightcolumn.background.jpg) no-repeat left top;
|
||||
}
|
||||
|
||||
div.checkbox label {
|
||||
display:inline;
|
||||
}
|
||||
|
||||
form.edit2 label {
|
||||
display: block;
|
||||
color: #003580;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.span_form {
|
||||
font-weight: normal;
|
||||
float: right;
|
||||
color: #A6A6A6;
|
||||
text-align: right;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
form.edit2 input, form.edit2 textarea, form.edit2 select {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
border: #B5C0F9 1px solid;
|
||||
color: #009900;
|
||||
font-weight: bold;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
form.edit label {
|
||||
display: block;
|
||||
color: #003580;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form.edit label.checkbox {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/*
|
||||
form.edit span {
|
||||
font-weight: normal;
|
||||
float: right;
|
||||
color: #A6A6A6;
|
||||
text-align: right;
|
||||
width: 60px;
|
||||
}
|
||||
*/
|
||||
|
||||
form.edit input, form.edit textarea, form.edit select {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
border: #B5C0F9 1px solid;
|
||||
color: #009900;
|
||||
font-weight: bold;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
form.edit input.required, form.edit select.required {
|
||||
background: #ffd5d5;
|
||||
}
|
||||
|
||||
forms.edit input[readonly="readonly"] {
|
||||
color:#003580;
|
||||
}
|
||||
|
||||
form.edit input[type="checkbox"] {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.edit_title {
|
||||
position: relative;
|
||||
font-family: Arial;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #990000;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 15px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.edit_title_toggler {
|
||||
position: relative;
|
||||
font-family: Arial;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #003594;
|
||||
margin: 10px 10px;
|
||||
cursor: pointer;
|
||||
background: transparent url(../images/edit_form_open.png) no-repeat right;
|
||||
}
|
||||
.edit_title_hidden {
|
||||
color: #003594;
|
||||
background: transparent url(../images/edit_form_close.png) no-repeat right;
|
||||
}
|
||||
|
||||
.formVariant2 .edit_title_toggler {
|
||||
position: relative;
|
||||
font-family: Arial;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #003594;
|
||||
margin: 0px 10px;
|
||||
cursor: pointer;
|
||||
background: #F0F0EE url(../images/edit_form_open.png) no-repeat right;
|
||||
padding: 10px;
|
||||
border: #CCCCCC 1px solid;
|
||||
}
|
||||
.formVariant2 .edit_title_hidden {
|
||||
color: #003594;
|
||||
background: #F0F0EE url(../images/edit_form_close.png) no-repeat right;
|
||||
}
|
||||
|
||||
.toggleable_container2 {
|
||||
border: #CCCCCC 1px solid;
|
||||
padding: 10px;
|
||||
margin: 0px 10px;
|
||||
}
|
||||
|
||||
.edit_element_1_3 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 170px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
|
||||
}
|
||||
.toggleable_container2 .edit_element_1_3 {
|
||||
width: 153px;
|
||||
}
|
||||
.toggleable_container2 .edit_element_1_4 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 110px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_1_5 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 94px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_2_5 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 208px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_3_5 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 322px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_4_5 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 416px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_5_5 {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 550px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_endline {
|
||||
width:550px;
|
||||
height:1px;
|
||||
background:#eeeeee;
|
||||
clear:left;
|
||||
margin-bottom:8px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.edit_element_1_1 {
|
||||
float:left;
|
||||
width:550px;
|
||||
height:40px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_75 {
|
||||
float:left;
|
||||
width:454px;
|
||||
height:40px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.edit_element_150 {
|
||||
width:930px;
|
||||
height:40px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.forms_table {
|
||||
width:100%;
|
||||
}
|
||||
|
||||
.forms_table td.position {
|
||||
width:40px;
|
||||
}
|
||||
|
||||
.forms_table td.amount {
|
||||
width:40px;
|
||||
}
|
||||
|
||||
.forms_table td.item_number {
|
||||
width:80px;
|
||||
}
|
||||
|
||||
.forms_table td.price {
|
||||
width:40px;
|
||||
}
|
||||
|
||||
.forms_table td.item_total {
|
||||
width:40px;
|
||||
}
|
||||
|
||||
.forms_table thead{
|
||||
background:#7086B4;
|
||||
color:white;
|
||||
}
|
||||
|
||||
div.search_results {
|
||||
position:relative;
|
||||
background:white;
|
||||
z-index:200;
|
||||
-moz-box-sizing: border-box;
|
||||
border: 1px solid #B5C0F9;
|
||||
border-top: 0;
|
||||
color: #009900;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.hidden {
|
||||
display:none;
|
||||
}
|
||||
|
||||
div.togglebutton {
|
||||
height:16px;
|
||||
line-height:16px;
|
||||
margin-top:4px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
span.togglebutton {
|
||||
display: inline-block;
|
||||
height:16px;
|
||||
line-height:16px;
|
||||
}
|
||||
span.togglebutton > label.togglebutton {
|
||||
padding-left: 20px;
|
||||
display:inline-block;
|
||||
height:16px;
|
||||
line-height:16px;;
|
||||
}
|
||||
label.togglebutton > span {
|
||||
display:none;
|
||||
}
|
||||
label.leftalign {
|
||||
background:transparent url(../images/text_left.png) no-repeat;
|
||||
}
|
||||
label.centeralign {
|
||||
background:transparent url(../images/text_center.png) no-repeat;
|
||||
}
|
||||
label.rightalign {
|
||||
background:transparent url(../images/text_right.png) no-repeat;
|
||||
}
|
||||
|
||||
.sort_arrow {
|
||||
height: 15px;
|
||||
width: 32px;
|
||||
float: right;
|
||||
background:transparent url(../images/sort.png) no-repeat 0px 0px;
|
||||
}
|
||||
|
||||
.sort_arrow_up {
|
||||
background-position: 0px -32px;
|
||||
}
|
||||
|
||||
.sort_arrow_down {
|
||||
background-position: 0px -16px;
|
||||
}
|
||||
|
||||
select[readonly] {
|
||||
background: #EEEEEE;
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
background: #EEEEEE;
|
||||
}
|
||||
|
||||
.order_editor_button {
|
||||
border: 1px solid #B5C0F9;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
background: #F9F9F9;
|
||||
}
|
||||
|
||||
#customer_search_results {
|
||||
background: white;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
div#file_list {
|
||||
min-height: 194px;
|
||||
}
|
||||
|
||||
.not_used {
|
||||
background: #f5e7e7;
|
||||
}
|
||||
|
||||
|
||||
select option.level1 {
|
||||
color: #009900;
|
||||
}
|
||||
select option.level2 {
|
||||
color: #C88300;
|
||||
}
|
||||
select option.level3 {
|
||||
color: #668800;
|
||||
}
|
||||
select option.level4 {
|
||||
color: #DE0043;
|
||||
}
|
||||
select option.level5 {
|
||||
color: #430066;
|
||||
}
|
||||
select option.level6 {
|
||||
color: #001834;
|
||||
}
|
||||
select option.levelAssigned {
|
||||
font-weight: normal;
|
||||
background: #DEDEDE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
html {
|
||||
font-size: 100%;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.5em;
|
||||
font-family: Tahoma, "Liberation Sans", Arial;
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
color: #1A1A1A;
|
||||
text-align: center;
|
||||
}
|
||||
#OverlayPixl {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
z-index: 101;
|
||||
}
|
||||
input {
|
||||
font-family: Arial;
|
||||
}
|
||||
|
||||
a {
|
||||
outline-width: 0px;
|
||||
color: #003580;
|
||||
}
|
||||
a:hover {
|
||||
color: #990000;
|
||||
}
|
||||
.float_right {
|
||||
float: right;
|
||||
}
|
||||
.float_left {
|
||||
float: left;
|
||||
}
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
#bodycontainer
|
||||
{
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.main_footer {
|
||||
/*background: #003D92;*/
|
||||
background-image: -webkit-linear-gradient(#003D92 0%, #205Db2 100%);
|
||||
background-image: -moz-linear-gradient(#003D92 0%, #205Db2 100%);
|
||||
background-image: -o-linear-gradient(#003D92 0%, #205Db2 100%);
|
||||
background-image: linear-gradient(#003D92 0%, #205Db2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#003D92', endColorstr='#205Db2');
|
||||
height: 15px;
|
||||
}
|
||||
.footer_copy {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
color: #ffffff;
|
||||
line-height: 15px;
|
||||
}
|
||||
#header {
|
||||
position: relative;
|
||||
height: 51px;
|
||||
margin: 0px 0px;
|
||||
/*background: #003D92;*/
|
||||
background-image: -webkit-linear-gradient(#306Dc2 0%, #003D92 100%);
|
||||
background-image: -moz-linear-gradient(#306Dc2 0%, #003D92 100%);
|
||||
background-image: -o-linear-gradient(#306Dc2 0%, #003D92 100%);
|
||||
background-image: linear-gradient(#306Dc2 0%, #003D92 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#306Dc2', endColorstr='#003D92');
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#loggedin_user {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 35px;
|
||||
height: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
#header_today {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 10px;
|
||||
height: 16px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
.header_stat {
|
||||
opacity:0.5;
|
||||
position:absolute;
|
||||
top:15px;
|
||||
height:27px;
|
||||
width:25px;
|
||||
text-align:right;
|
||||
color:#ffffff;
|
||||
}
|
||||
.header_stat span {
|
||||
display:none;
|
||||
}
|
||||
.header_stat_active {
|
||||
opacity:1.0;
|
||||
}
|
||||
.header_stat_active span {
|
||||
display:inline;
|
||||
position:absolute;
|
||||
top:0px;
|
||||
right:0px;
|
||||
height:11px;
|
||||
line-height:11px;
|
||||
font-size:11px;
|
||||
font-family:Arial;
|
||||
}
|
||||
.header_stat_customer {
|
||||
left:590px;
|
||||
background:transparent url(../images/header_user.png) bottom left no-repeat;
|
||||
cursor:pointer;
|
||||
}
|
||||
.header_stat_email {
|
||||
left:640px;
|
||||
background:transparent url(../images/header_email.png) bottom left no-repeat;
|
||||
cursor:pointer;
|
||||
}
|
||||
.header_stat_order {
|
||||
left:690px;
|
||||
background:transparent url(../images/header_order.png) bottom left no-repeat;
|
||||
cursor:pointer;
|
||||
}
|
||||
#loggedin_user_link {
|
||||
padding-left: 15px;
|
||||
color: #ffffff;
|
||||
text-decoration:none;
|
||||
}
|
||||
#logout_button {
|
||||
color: #ffffff;
|
||||
text-decoration:none;
|
||||
}
|
||||
#logout_button a {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#logo {
|
||||
position: absolute;
|
||||
height: 51px;
|
||||
left: 5px;
|
||||
top: 13px;
|
||||
}
|
||||
#logo a {
|
||||
color: white;
|
||||
text-decoration:none;
|
||||
line-height: 51px;
|
||||
}
|
||||
#new_action {
|
||||
position: absolute;
|
||||
left: 200px;
|
||||
top: 18px;
|
||||
}
|
||||
#global_live_search {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 350px;
|
||||
display: block;
|
||||
width: 200px;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
a.areaLinkItem {
|
||||
position: relative;
|
||||
left: -3px;
|
||||
display: block;
|
||||
height: 80px;
|
||||
width: 120px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent url(../images/submenu_item_default.png) no-repeat center 15px;
|
||||
}
|
||||
a.areaLinkItem:hover {
|
||||
background-color:#eeeeee;
|
||||
border: 1px solid #ff0000;
|
||||
}
|
||||
a.areaLinkItem span {
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
font-size: 11px;
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#searchContainer {
|
||||
position: absolute;
|
||||
top: -479px;
|
||||
left: -15px;/*hack*/
|
||||
width: 1024px;
|
||||
height: 479px;
|
||||
background: transparent url(../images/search.background.png) no-repeat 0px 0px;
|
||||
z-index: 1000;
|
||||
}
|
||||
#searchScopes {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 0px;
|
||||
width: 620px;
|
||||
height: 100%;
|
||||
}
|
||||
#searchScopeSelectors {
|
||||
position: absolute;
|
||||
left: 636px;
|
||||
top: -1px;
|
||||
width: 375px;
|
||||
height: 100%;
|
||||
}
|
||||
#searchScopeSelectors a {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 375px;
|
||||
height: 112px;
|
||||
background: transparent url(../images/search.scope.background.jpg) no-repeat 0px 0px;
|
||||
text-decoration:none;
|
||||
color: #000000;
|
||||
font-size: 16px;
|
||||
}
|
||||
#searchScopeSelectors a.active {
|
||||
background-position:0px -112px;
|
||||
}
|
||||
#searchScopeSelectors a span {
|
||||
position: relative;
|
||||
display: block;
|
||||
left: 110px;
|
||||
top: 43px;
|
||||
}
|
||||
#searchScopes div.searchScope {
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 30px;
|
||||
right: 30px;
|
||||
top: 40px;
|
||||
bottom: 20px;
|
||||
}
|
||||
#searchScopes div.searchScopeActive {
|
||||
display: block;
|
||||
}
|
||||
#contentContainer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
.message {
|
||||
text-align:center;
|
||||
top:0;
|
||||
position:absolute;
|
||||
line-height:20px;
|
||||
font-size:20px;
|
||||
width:936px;
|
||||
padding:20px;
|
||||
z-index:1000;
|
||||
}
|
||||
/* blue 30 130 230 */
|
||||
.info_message {
|
||||
background:#d3d6f9;
|
||||
border:2px solid #2333e1;
|
||||
color:#070b35;
|
||||
}
|
||||
/* yellow 30 130 230 */
|
||||
.warning_message {
|
||||
background:#f9f7d3;
|
||||
border:2px solid #e1d723;
|
||||
color:#353207;
|
||||
}
|
||||
/* red 30 130 230 */
|
||||
.error_message {
|
||||
background:#f9d3d3;
|
||||
border:2px solid #e02424;
|
||||
color:#350707;
|
||||
}
|
||||
.pagination {
|
||||
float:right;
|
||||
margin-top:2px;
|
||||
}
|
||||
.pagination a{
|
||||
color: black;
|
||||
margin-left:2px;
|
||||
padding:2px 4px;
|
||||
border:1px solid #DFDFDF;
|
||||
text-decoration:none;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background: #DFDFDF;
|
||||
}
|
||||
.pagination a.highlight {
|
||||
background: #DFDFDF;
|
||||
}
|
||||
a > img {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
#rightMenu {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
border-bottom: none;
|
||||
list-style: none;
|
||||
}
|
||||
#rightMenu li {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
#rightMenu a {
|
||||
line-height: 2em;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
#rightMenu a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
#sortable li {
|
||||
margin:5px;
|
||||
}
|
||||
|
||||
.content_box {
|
||||
position: relative;
|
||||
background:white;
|
||||
padding:5px;
|
||||
}
|
||||
.content_box .header {
|
||||
background:#7086B4;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
.content_box .body {
|
||||
margin-top:10px;
|
||||
}
|
||||
|
||||
.content_box .footer {
|
||||
position: absolute;
|
||||
top: 140px;
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
#sortable_images li {
|
||||
margin:5px;
|
||||
width:120px;
|
||||
float:left;
|
||||
border: 1px solid #cccccc;
|
||||
height: 210px;
|
||||
}
|
||||
|
||||
#sortable_attributes li {
|
||||
margin:2px;
|
||||
width:580px;
|
||||
height:60px;
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
|
||||
|
||||
.edit_icon {
|
||||
border: 0px solid #B5C0F9;
|
||||
}
|
||||
|
||||
.edit_icon_bar {
|
||||
position: absolute;
|
||||
left: 390px;
|
||||
top: 15px;
|
||||
width: 200px;
|
||||
height: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#button_article_list_1 {
|
||||
float: left;
|
||||
width: 100px;
|
||||
height: 110px;
|
||||
cursor: pointer;
|
||||
background: transparent url(../images/artikelliste_1_normal.png) no-repeat;
|
||||
}
|
||||
|
||||
#button_article_list_1.active {
|
||||
background: transparent url(../images/artikelliste_1_active.png) no-repeat;
|
||||
}
|
||||
|
||||
#button_article_list_2 {
|
||||
float: left;
|
||||
width: 100px;
|
||||
height: 110px;
|
||||
margin-left:20px;
|
||||
cursor: pointer;
|
||||
background: transparent url(../images/artikelliste_2_normal.png) no-repeat;
|
||||
}
|
||||
|
||||
#button_article_list_2.active {
|
||||
background: transparent url(../images/artikelliste_2_active.png) no-repeat;
|
||||
}
|
||||
|
||||
#button_article_list_3 {
|
||||
float: left;
|
||||
width: 100px;
|
||||
height: 110px;
|
||||
margin-left:20px;
|
||||
cursor: pointer;
|
||||
background: transparent url(../images/artikelliste_3_normal.png) no-repeat;
|
||||
}
|
||||
|
||||
#button_article_list_3.active {
|
||||
background: transparent url(../images/artikelliste_3_active.png) no-repeat;
|
||||
}
|
||||
|
||||
#searchResultBox {
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
width: 978px;
|
||||
height: 407px;
|
||||
margin: auto;
|
||||
z-index: 100;
|
||||
border: 1px solid #D9D9D9;
|
||||
border-bottom: 5px solid #D9D9D9;
|
||||
}
|
||||
|
||||
.search_box {
|
||||
position:absolute;
|
||||
top:5px;
|
||||
width:235px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
display:inline-block;
|
||||
}
|
||||
|
||||
.search_box h3 {
|
||||
text-align: center;
|
||||
color: white;
|
||||
width:100%;
|
||||
background:#7086B4;
|
||||
}
|
||||
|
||||
.search_box a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul.tabs {
|
||||
border: 1px solid #D9D9D9;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul.tabs li {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul.tabs a {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
ul.tabs a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul#definitions {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
ul#definitions li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul#definitions span {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.info_block {
|
||||
background:#f9f9f9;
|
||||
border:1px solid #f9f9f9;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.info_block * {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.money {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.date {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#banner_image {
|
||||
width:550px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
dl.legend {
|
||||
width: 312px;
|
||||
}
|
||||
|
||||
dl.legend dt {
|
||||
color: red;
|
||||
clear: both;
|
||||
float: left;
|
||||
width: 140px;
|
||||
display: inline-block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
dl.legend dd {
|
||||
float: left;
|
||||
text-align: left;
|
||||
width: 130px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.opinion_bull {
|
||||
position: relative;
|
||||
float: left;
|
||||
margin-right:7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: transparent url(./../images/opinion_bull.png) no-repeat;
|
||||
}
|
||||
|
||||
.opinion_bull_active {
|
||||
position: relative;
|
||||
float: left;
|
||||
margin-right:7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: transparent url(./../images/opinion_bull_active.png) no-repeat;
|
||||
}
|
||||
|
||||
|
||||
.multilang_element input {
|
||||
background: #ccccff;
|
||||
}
|
||||
|
||||
#centerContainer {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -15px;
|
||||
}
|
||||
|
||||
#adminLeftFloater {
|
||||
float: left;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
form.new input[type="submit"] {
|
||||
clear:both;
|
||||
position:relative;
|
||||
width:200px;
|
||||
margin-top: 10px;
|
||||
margin-left: auto;
|
||||
maring-right: auto;
|
||||
border: 1px solid #B5C0F9;
|
||||
color: #009900;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
form.new div {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
form.new label {
|
||||
color: #003580;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 1em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
form.new input[type="text"] {
|
||||
width: 100%;
|
||||
border: 1px solid #B5C0F9;
|
||||
color: #009900;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
|
||||
.object_list_table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #ffffff;
|
||||
}
|
||||
.object_list_table thead {
|
||||
background: #7086B4;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.object_list_table thead tr {
|
||||
border: #000000 0px solid;
|
||||
}
|
||||
.object_list_table thead tr th, .object_list_table>tbody>tr>td, .object_list_table tfoot tr td {
|
||||
padding: 2px;
|
||||
}
|
||||
.object_list_table thead tr th, .object_list_table>tbody>tr>td {
|
||||
border: #9B9B9B 1px dotted;
|
||||
border-width: 0px 1px;
|
||||
text-align: left;
|
||||
}
|
||||
.object_list_table thead tr th.left, .object_list_table>tbody>tr>td.left {
|
||||
text-align: left;
|
||||
}
|
||||
.object_list_table thead tr th.right, .object_list_table>tbody>tr>td.right {
|
||||
text-align: right;
|
||||
}
|
||||
.object_list_table thead tr th .sortables {
|
||||
min-width: 32px;
|
||||
height: 16px;
|
||||
padding-left: 32px;
|
||||
display: inline;
|
||||
background: transparent url(../images/audarisTools.table.sortables.png) no-repeat 0px 0px;
|
||||
}
|
||||
.object_list_table thead tr th .sortables.desc {
|
||||
background-position: 0px -16px;
|
||||
}
|
||||
.object_list_table thead tr th .sortables.asc {
|
||||
background-position: 0px -32px;
|
||||
}
|
||||
.object_list_table thead tr th.left .sortables {
|
||||
padding-left: 0px;
|
||||
padding-right: 32px;
|
||||
background-position: right 0px;
|
||||
}
|
||||
.object_list_table thead tr th.left .sortables.desc {
|
||||
background-position: right -16px;
|
||||
}
|
||||
.object_list_table thead tr th.left .sortables.asc {
|
||||
background-position: right -32px;
|
||||
}
|
||||
.object_list_table>tbody>tr {
|
||||
border: #000000 0px solid;
|
||||
border-width: 0px 0px;
|
||||
border-bottom: #9B9B9B 1px dotted;
|
||||
}
|
||||
.object_list_table>tbody>tr.lastChild {
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
.object_list_table>tbody>tr.evenRow {
|
||||
background: #DFE4ED;
|
||||
}
|
||||
.object_list_table>tbody>tr.hover {
|
||||
background: #FFCC8D;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.object_list_table tbody tr:hover {
|
||||
background: #999999;
|
||||
}
|
||||
|
||||
.object_list_item_activated {
|
||||
background: #777777;
|
||||
}
|
||||
|
||||
.object_list_table_filter {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #dddddd;
|
||||
}
|
||||
|
||||
.object_list_table_filter_wrapper {
|
||||
position: relative;
|
||||
height: 0px;
|
||||
background: #dddddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter_element_wrapper {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 200px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.filter_set {
|
||||
border: 1px solid #999999;
|
||||
color: #444444;
|
||||
margin:10px;
|
||||
}
|
||||
.object_list_table_toolbar_title {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
/*background: #90a6d4;*/
|
||||
background-image: -webkit-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: -moz-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: -o-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#e7e5de', endColorstr='#d2d2d2');
|
||||
}
|
||||
.object_list_table_toolbar_title span {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 5px;
|
||||
line-height: 18px;
|
||||
font-weight: normal;
|
||||
font-family: Arial;
|
||||
font-size: 14px;
|
||||
color:#604a48;
|
||||
|
||||
}
|
||||
.quick_filter {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
/*background: #dddddd;*/
|
||||
background-image: -webkit-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
|
||||
background-image: -moz-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
|
||||
background-image: -o-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
|
||||
background-image: linear-gradient(#c0d6f4 0%, #90a6d4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#c0d6f4', endColorstr='#90a6d4');
|
||||
}
|
||||
.quick_filter span {
|
||||
text-decoration: none;
|
||||
padding-left: 20px;
|
||||
line-height: 30px;
|
||||
color: #555555;
|
||||
cursor: pointer;
|
||||
}
|
||||
.quick_filter span:hover, .quick_filter span.active {
|
||||
color: #000000;
|
||||
}
|
||||
.object_list_table_toolbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #B6B6B6;
|
||||
border: 0px solid #333333;
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar a {
|
||||
position: relative;
|
||||
display: block;
|
||||
float: left;
|
||||
height: 20px;
|
||||
top: 5px;
|
||||
margin-left: 15px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar a:hover {
|
||||
background: #8096c4;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar a span {
|
||||
text-decoration: none;
|
||||
color: #000000;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar img {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
|
||||
}
|
||||
|
||||
.object_list_table_toolbar a.inactive {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar a.inactive span {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar .search_wrapper {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar .search_wrapper input {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 16px;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.object_list_table_toolbar .search_wrapper input.search_button {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
width: 50px;
|
||||
height: 20px;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.object_list_col_header {
|
||||
background: #eeeeee;
|
||||
color: #444444;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
.object_list_col_sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.object_list_table_footer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
/*background: #90a6d4;*/
|
||||
background-image: -webkit-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: -moz-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: -o-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
background-image: linear-gradient(#e7e5de 0%, #d2d2d2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#e7e5de', endColorstr='#d2d2d2');
|
||||
border: 0px solid #333333;
|
||||
border-top: 0px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.object_list_table_footer .items_per_page_wrapper {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 20px;
|
||||
color:#000000;
|
||||
}
|
||||
|
||||
.object_list_table_footer .pagination_wrapper {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.object_list_table_footer .object_list_stats {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 300px;
|
||||
color:#000000;
|
||||
}
|
||||
|
||||
|
||||
.object_list_table_action {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #dddddd;
|
||||
}
|
||||
|
||||
.object_list_table_action_wrapper {
|
||||
position: relative;
|
||||
height: 0px;
|
||||
background: #dddddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action_element_wrapper {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 200px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.action_set {
|
||||
border: 1px solid #999999;
|
||||
color: #444444;
|
||||
margin:10px;
|
||||
}
|
||||
.list_action_button {
|
||||
border: 1px solid #999999;
|
||||
color: #444444;
|
||||
height: 37px;
|
||||
width: 250px;
|
||||
cursor: default;
|
||||
}
|
||||
.list_action_button_active {
|
||||
border: 1px solid #000000;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* order list css */
|
||||
.order_list_item_price {
|
||||
display: block;
|
||||
width: 95%;
|
||||
text-align: right;
|
||||
}
|
||||
.order_list_item_inventar_0 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.order_list_item_inventar_1 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #00ff00;
|
||||
}
|
||||
|
||||
.orders_order_status_Neu {
|
||||
font-weight:bold;
|
||||
}
|
||||
.orders_order_status_Storno {
|
||||
font-weight:bold;
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
/* ticket list css */
|
||||
.tickets_state_id_Neu {
|
||||
font-weight:bold;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* object edit */
|
||||
|
||||
|
||||
.object_edit_table_toolbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #b6b6b6;
|
||||
border: 0px solid #333333;
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.object_edit_table_toolbar a {
|
||||
position: relative;
|
||||
display: block;
|
||||
float: left;
|
||||
height: 20px;
|
||||
top: 5px;
|
||||
margin-left: 15px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.object_edit_table_toolbar a:hover {
|
||||
background: #8096c4;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
.object_edit_table_toolbar a span {
|
||||
text-decoration: none;
|
||||
color: #000000;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
padding-left: 2px;
|
||||
}
|
||||
.object_edit_table_toolbar img {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
|
||||
}
|
||||
.object_edit_table_toolbar a.inactive {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.object_edit_table_toolbar a.inactive span {
|
||||
color: #555555;
|
||||
}
|
||||
.object_edit_table_toolbar .language_wrapper {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 5px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.object_edit_table_toolbar .language_wrapper select {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
.object_list_table_view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #dddddd;
|
||||
}
|
||||
|
||||
.object_list_table_view_wrapper {
|
||||
position: relative;
|
||||
height: 0px;
|
||||
background: #dddddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter_element_view {
|
||||
float:left;
|
||||
height:40px;
|
||||
width: 200px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.view_set {
|
||||
border: 1px solid #999999;
|
||||
color: #444444;
|
||||
margin:10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
float:right;
|
||||
margin-top:2px;
|
||||
color:#000000;
|
||||
}
|
||||
.pagination a{
|
||||
color: black;
|
||||
margin-left:2px;
|
||||
padding:2px 4px;
|
||||
border:1px solid #000000;
|
||||
text-decoration:none;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background: #000000;
|
||||
}
|
||||
.pagination a.highlight {
|
||||
background: #000000;
|
||||
}
|
||||
.pagination a {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
table.order {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table.order .buttons {
|
||||
width: 62px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
table.order .pos {
|
||||
width: 36px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
table.order .quantity {
|
||||
width: 36px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
table.order .item {
|
||||
font-weight: bold;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
table.order .price {
|
||||
width: 70px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
table.order .price > input {
|
||||
width: 54px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
table.order .tax_rate {
|
||||
width: 45px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.total {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table.order .total {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
table.order .total span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span.total {
|
||||
float: right;
|
||||
}
|
||||
|
||||
td.inventory label, td.inventory {
|
||||
float: right;
|
||||
}
|
||||
|
||||
td.inventory span {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.order .item div {
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
color: #727272;
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop_box {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop_box.open {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.drop_box_button {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drop_box_button.down {
|
||||
background: url(../images/edit_form_close.png) no-repeat;
|
||||
}
|
||||
|
||||
.drop_box_button.up {
|
||||
background: url(../images/edit_form_open.png) no-repeat;
|
||||
}
|
||||
|
||||
.delete_button {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url(../images/delete.png) no-repeat;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.rsTabs {
|
||||
position: relative;
|
||||
}
|
||||
.rsTabs_Header {
|
||||
height: 30px;
|
||||
background: transparent url(../images/rsTabs.head.shadow.jpg) no-repeat right bottom;
|
||||
}
|
||||
.rsTabs_Body {
|
||||
position: relative;
|
||||
border: #d9d9d9 1px solid;
|
||||
z-index: 2;
|
||||
padding: 15px;
|
||||
background: #ffffff;
|
||||
/*background: transparent url(images/rsTabs.body.background.png) repeat-x left top;*/
|
||||
}
|
||||
.rsTabs_BodyElement {
|
||||
display: none;
|
||||
}
|
||||
.rsTabs_BodyElementActive {
|
||||
display: block;
|
||||
}
|
||||
.rsTabs_HeaderTab {
|
||||
position: relative;
|
||||
height: 30px;
|
||||
border: #d9d9d9 1px solid;
|
||||
border-bottom-width: 0px;
|
||||
bottom: 0px;
|
||||
text-align: center;
|
||||
padding: 0px 15px;
|
||||
background: #EEEEEE;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
color: #003580;
|
||||
}
|
||||
.rsTabs_HeaderTab:hover {
|
||||
background: #CCCCCC;
|
||||
}
|
||||
.rsTabs_HeaderTabActive, .rsTabs_HeaderTabActive:hover {
|
||||
background: #ffffff;
|
||||
cursor: default;
|
||||
z-index: 3;
|
||||
}
|
||||
.rsTabs_HeaderTab span {
|
||||
position: relative;
|
||||
top: 9px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rsTabs_HeaderTab .error {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
width: 80px;
|
||||
height: 31px;
|
||||
background: transparent url(../images/rsTabs.head.error.png) no-repeat 0px 0px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.rsToolsToolTip {
|
||||
position: absolute;
|
||||
width: 287px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.rsToolsToolTipHeader {
|
||||
height: 25px;
|
||||
background: transparent url(../images/rsTools.Tooltip.sprites.png) no-repeat 0px 0px;
|
||||
}
|
||||
.rsToolsToolTipContent {
|
||||
background: transparent url(../images/rsTools.Tooltip.sprites.png) repeat-y -574px 0px;
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
padding: 0px 15px;
|
||||
}
|
||||
.rsToolsToolTipFooter {
|
||||
height: 25px;
|
||||
background: transparent url(../images/rsTools.Tooltip.sprites.png) no-repeat -287px 0px;
|
||||
}
|
||||
|
||||
.rsTabs_HeaderTab a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rsTabs_HeaderTab a:hover {
|
||||
color: #003580;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.search_text_box {
|
||||
width: 200px;
|
||||
}
|
||||
.checkbox_container {
|
||||
width: 350px;
|
||||
display: inline-block;
|
||||
}
|
||||
div.search_list {
|
||||
position:relative;
|
||||
width: 200px;
|
||||
height: 250px;
|
||||
display: inline-block;
|
||||
border: 1px solid #7086B4;
|
||||
}
|
||||
div.search_list label {
|
||||
background: #7086B4;
|
||||
color: white;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
div.search_list ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
/*div.search_list ul.nested ul {
|
||||
display:none;
|
||||
}*/
|
||||
div.search_list > ul {
|
||||
position: absolute;
|
||||
top:20px;
|
||||
bottom:20px;
|
||||
margin:0px;
|
||||
padding:0px;
|
||||
overflow:auto;
|
||||
width:200px;
|
||||
}
|
||||
div.search_list > ul > li {
|
||||
margin-left:5px;
|
||||
}
|
||||
div.search_list input {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-top:1px solid #7086B4;
|
||||
width: 200px;
|
||||
height:20px;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.statistics {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statistics thead{
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.statistics th {
|
||||
font-size: 1.2em;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.statistics tr.top td {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.statistics tr.bottom {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.statistics tr.bottom td {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.selection {
|
||||
display:inline-block;
|
||||
width:150px;
|
||||
margin-right:10px;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.selection select {
|
||||
width:100%;
|
||||
}
|
||||
|
||||
.year {
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.money {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* audaris tools table */
|
||||
.audarisToolsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.audarisToolsTable thead {
|
||||
background: #7086B4;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.audarisToolsTable thead tr {
|
||||
border: #000000 1px solid;
|
||||
}
|
||||
.audarisToolsTable thead tr th, .audarisToolsTable>tbody>tr>td, .audarisToolsTable tfoot tr td {
|
||||
padding: 5px;
|
||||
}
|
||||
.audarisToolsTable thead tr th, .audarisToolsTable>tbody>tr>td {
|
||||
border: #9B9B9B 1px dotted;
|
||||
border-width: 0px 1px;
|
||||
text-align: center;
|
||||
}
|
||||
.audarisToolsTable thead tr th.left, .audarisToolsTable>tbody>tr>td.left {
|
||||
text-align: left;
|
||||
}
|
||||
.audarisToolsTable thead tr th.right, .audarisToolsTable>tbody>tr>td.right {
|
||||
text-align: right;
|
||||
}
|
||||
.audarisToolsTable thead tr th .sortables {
|
||||
min-width: 32px;
|
||||
height: 16px;
|
||||
padding-left: 32px;
|
||||
display: inline;
|
||||
background: transparent url(../images/audarisTools.table.sortables.png) no-repeat 0px 0px;
|
||||
}
|
||||
.audarisToolsTable thead tr th .sortables.desc {
|
||||
background-position: 0px -16px;
|
||||
}
|
||||
.audarisToolsTable thead tr th .sortables.asc {
|
||||
background-position: 0px -32px;
|
||||
}
|
||||
.audarisToolsTable thead tr th.left .sortables {
|
||||
padding-left: 0px;
|
||||
padding-right: 32px;
|
||||
background-position: right 0px;
|
||||
}
|
||||
.audarisToolsTable thead tr th.left .sortables.desc {
|
||||
background-position: right -16px;
|
||||
}
|
||||
.audarisToolsTable thead tr th.left .sortables.asc {
|
||||
background-position: right -32px;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr {
|
||||
border: #000000 1px solid;
|
||||
border-width: 0px 1px;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.lastChild {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.evenRow {
|
||||
background: #DFE4ED;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.hover {
|
||||
background: #FFCC8D;
|
||||
cursor: pointer;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr>td.boolean_true {
|
||||
background: transparent url(../images/audarisTools.table.boolean_true.png) no-repeat center center;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr>td.boolean_false {
|
||||
background: transparent url(../images/audarisTools.table.boolean_false.png) no-repeat center center;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.active {
|
||||
background: #FFCC8D;
|
||||
border: #7086B4 2px solid;
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.rowDetails {
|
||||
background: #E1DAC9;
|
||||
border: #7086B4 2px solid;
|
||||
border-top-width: 0px;
|
||||
}
|
||||
.audarisToolsTable>tbody>tr.rowDetails>td {
|
||||
text-align: left;
|
||||
padding: 0px;
|
||||
}
|
||||
.audarisToolsTable tbody {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
.audarisToolsTable tfoot tr td .left {
|
||||
float: left;
|
||||
}
|
||||
.audarisToolsTable tfoot tr td .right {
|
||||
float: right;
|
||||
}
|
||||
.audarisToolsTable .red {
|
||||
|
||||
}
|
||||
.audarisToolsTable .orange {
|
||||
|
||||
}
|
||||
.audarisToolsTable .green {
|
||||
|
||||
}
|
||||
.audarisToolsTable tr.disabled {
|
||||
background:#f2f2f2;
|
||||
}
|
||||
.action_icon {
|
||||
margin-right:5px;
|
||||
}
|
||||
.audarisToolsTable tr.locked {
|
||||
color:red;
|
||||
}
|
||||
|
||||
.audarisToolsTable th a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.audarisToolsTable th a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
.audarisToolsTable tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
|
||||
/* template specific talbes */
|
||||
|
||||
#order_editor_history_talbe {
|
||||
width: 400px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/* widget containers */
|
||||
.widget_container {
|
||||
list-style:none;
|
||||
margin:0;
|
||||
padding:0;
|
||||
margin-bottom:80px;
|
||||
min-height:60px;
|
||||
}
|
||||
.widget_container:hover {
|
||||
background: #eee;
|
||||
}
|
||||
#big_widget_container {
|
||||
width: 650px;
|
||||
display: inline-block;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#little_widget_container {
|
||||
width: 320px;
|
||||
display: inline-block;
|
||||
float: right;
|
||||
}
|
||||
/* little widget */
|
||||
div.little_widget {
|
||||
width:318px;
|
||||
border:1px solid #DFDFDF;
|
||||
margin-bottom:10px;
|
||||
background:white;
|
||||
}
|
||||
div.little_widget_title_bar {
|
||||
background:#7086B4;
|
||||
color:white;
|
||||
width:318px;
|
||||
position:relative;
|
||||
height:27px;
|
||||
line-height:27px;
|
||||
width:100%;
|
||||
}
|
||||
div.little_widget_title_bar span{
|
||||
margin-left:10px;
|
||||
}
|
||||
div.little_widget_content {
|
||||
width:298px;
|
||||
margin:10px;
|
||||
}
|
||||
div.little_widget_content table {
|
||||
width:100%;
|
||||
}
|
||||
div.little_widget_content table thead {
|
||||
background:#DFDFDF;
|
||||
}
|
||||
/* big widget */
|
||||
div.big_widget {
|
||||
width:648px;
|
||||
border:1px solid #DFDFDF;
|
||||
margin-bottom:10px;
|
||||
background:white;
|
||||
}
|
||||
div.big_widget_title_bar {
|
||||
background:#7086B4;
|
||||
color:white;
|
||||
width:648px;
|
||||
position:relative;
|
||||
height:27px;
|
||||
line-height:27px;
|
||||
width:100%;
|
||||
}
|
||||
div.big_widget_title_bar span{
|
||||
margin-left:10px;
|
||||
}
|
||||
div.big_widget_content {
|
||||
width:628px;
|
||||
margin:10px;
|
||||
}
|
||||
div.big_widget_content table {
|
||||
width:100%;
|
||||
}
|
||||
div.big_widget_content table thead {
|
||||
background:#DFDFDF;
|
||||
}
|
||||
|
After Width: | Height: | Size: 129 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 731 B |
@@ -0,0 +1,92 @@
|
||||
$(document).ready(function() {
|
||||
$('#adminLeftFloater').rsTabs();
|
||||
|
||||
var id = $('input[name="form_field[id]"]').val();
|
||||
|
||||
$('#header_upload').uploadify({
|
||||
'uploader' : '../../../../libs/uploadify/uploadify.swf',
|
||||
'script' : './index.php?admin_modul=admin_manufacturer_editor',
|
||||
'cancelImg' : '../../../../libs/uploadify/cancel.png',
|
||||
'folder' : '../../../../libs/uploads',
|
||||
'scriptData' : {
|
||||
'action' : 'upload',
|
||||
'id' : id
|
||||
},
|
||||
'fileDesc' : 'Web Image Files',
|
||||
'buttonText': 'Logo',
|
||||
'auto' : true,
|
||||
'multi' : false,
|
||||
'onComplete': function(event, ID, fileObj, response, data) {
|
||||
try {
|
||||
var mydata = $.parseJSON(response);
|
||||
|
||||
if (mydata.status == 'success') {
|
||||
window.location.href = './index.php?admin_modul=admin_manufacturer_editor&id=' + id;
|
||||
} else {
|
||||
$().easywayShopMessageDialog("ERROR: can't save", {}, function(){
|
||||
$().easywayShopMessageDialog(mydata.data, {}, function(){});
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
$().easywayShopMessageDialog("ERROR: can't upload", {}, function(){});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#delete_logo').click(function(e) {
|
||||
e.preventDefault();
|
||||
var url = $(this).attr('href');
|
||||
var jqxhr = $.ajax({
|
||||
'url': url
|
||||
});
|
||||
|
||||
jqxhr.success(function(data) {
|
||||
try {
|
||||
data = $.parseJSON(data);
|
||||
|
||||
if (data.status == 'success') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$().easywayShopMessageDialog("ERROR: no save'", {}, function(){});
|
||||
}
|
||||
} catch(e) {
|
||||
$().easywayShopMessageDialog("ERROR: wrong answer", {}, function(){});
|
||||
}
|
||||
});
|
||||
|
||||
jqxhr.error(function() {
|
||||
$().easywayShopMessageDialog("ERROR", {}, function(){});
|
||||
});
|
||||
});
|
||||
|
||||
$('form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var url = $(this).attr('action');
|
||||
var data = $(this).serialize();
|
||||
|
||||
var jqxhr = $.ajax({
|
||||
url: url,
|
||||
data: data,
|
||||
type: 'POST'
|
||||
});
|
||||
|
||||
jqxhr.success(function(data) {
|
||||
try {
|
||||
data = $.parseJSON(data);
|
||||
|
||||
if (data.status == 'success') {
|
||||
window.location.href = './index.php?admin_modul=admin_manufacturer_editor&id=' + data.id;
|
||||
} else {
|
||||
$().easywayShopMessageDialog("ERROR: no save'", {}, function(){});
|
||||
}
|
||||
} catch(e) {
|
||||
$().easywayShopMessageDialog("ERROR: wrong answer", {}, function(){});
|
||||
}
|
||||
});
|
||||
|
||||
jqxhr.error(function() {
|
||||
$().easywayShopMessageDialog("ERROR: no answer", {}, function(){});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 899 B |
|
After Width: | Height: | Size: 612 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 212 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 117 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 131 B |
|
After Width: | Height: | Size: 632 B |