Files
skrift-preisrechner/skrift-preisrechner.php
2026-08-24 12:14:00 +02:00

423 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Plugin Name: Skrift Preisrechner
* Description: Preisrechner für Briefe/Postkarten per Shortcode. Holt Preisdaten stündlich per WP-Cron aus dem Skrift-Backend (öffentliche Read-API, ohne Token) und speichert sie in WordPress. Die Anzeige rechnet ohne Netzwerkzugriff – dadurch ohne Verzögerung. Shortcodes: [skrift_preis_inkl] und [skrift_preis_exkl].
* Version: 2.0.0
* Author: Skrift
*
* Sicherheit: Es werden ausschließlich ÖFFENTLICHE Werte gelesen (Basispreis,
* Formel-Parameter, MwSt, Porto) – keine Secrets, kein API-Token.
*/
if (!defined('ABSPATH')) exit;
require_once __DIR__ . '/formel.php';
const SKRIFT_PREIS_OPTION = 'skrift_preis_daten';
const SKRIFT_PREIS_CRON = 'skrift_preis_sync';
/** Nach dieser Zeit gelten gespeicherte Daten als veraltet (Cron-Ausfall). */
const SKRIFT_PREIS_MAX_ALTER = 7200; // 2 Stunden
/** Basis-URL des Skrift-Backends (Directus). Per Filter überschreibbar. */
function skrift_preis_backend_url() {
return rtrim(apply_filters('skrift_preis_backend_url', 'https://admin.skrift.de'), '/');
}
/**
* Holt die Preisdaten frisch aus Directus und legt sie in einer WP-Option ab.
* Wird stündlich per Cron aufgerufen – nicht im normalen Seitenaufruf.
* Bei Fehlern bleiben die zuletzt gespeicherten Daten erhalten.
*/
function skrift_preis_sync() {
$base = skrift_preis_backend_url();
$fehler = null;
$hole = function ($pfad) use ($base, &$fehler) {
$res = wp_remote_get($base . $pfad, array('timeout' => 10));
if (is_wp_error($res)) {
if ($fehler === null) $fehler = 'Backend nicht erreichbar (' . $res->get_error_message() . ')';
return null;
}
$code = wp_remote_retrieve_response_code($res);
if ($code !== 200) {
if ($fehler === null) $fehler = 'Backend antwortete mit HTTP ' . $code . ' auf ' . $pfad;
return null;
}
$json = json_decode(wp_remote_retrieve_body($res), true);
if (!is_array($json) || !array_key_exists('data', $json)) {
if ($fehler === null) $fehler = 'Unerwartete Antwort auf ' . $pfad;
return null;
}
return $json['data'];
};
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,price_factor_a,price_factor_e,pricing_mode,type&filter[active][_eq]=true&limit=-1');
$settings = $hole('/items/pricing_settings?fields=*');
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
// Nur bei Erfolg überschreiben – sonst bleiben die alten Werte stehen.
if ($fehler === null && is_array($produkte) && !empty($produkte)) {
update_option(SKRIFT_PREIS_OPTION, array(
'produkte' => $produkte,
'settings' => is_array($settings) ? $settings : array(),
'items' => is_array($items) ? $items : array(),
'zeit' => time(),
'fehler' => null,
), false); // kein autoload – wird nur bei Bedarf geladen
return true;
}
// Fehler am vorhandenen Datensatz vermerken (für die Diagnose in der Ausgabe).
$alt = get_option(SKRIFT_PREIS_OPTION);
if (is_array($alt)) {
$alt['fehler'] = $fehler;
update_option(SKRIFT_PREIS_OPTION, $alt, false);
} else {
update_option(SKRIFT_PREIS_OPTION, array(
'produkte' => array(), 'settings' => array(), 'items' => array(),
'zeit' => 0, 'fehler' => $fehler,
), false);
}
return false;
}
add_action(SKRIFT_PREIS_CRON, 'skrift_preis_sync');
/** Stündlichen Cron beim Aktivieren einplanen und sofort einmal synchronisieren. */
register_activation_hook(__FILE__, function () {
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
}
skrift_preis_sync();
});
register_deactivation_hook(__FILE__, function () {
$ts = wp_next_scheduled(SKRIFT_PREIS_CRON);
if ($ts) wp_unschedule_event($ts, SKRIFT_PREIS_CRON);
});
/** Sicherheitsnetz: fehlt der Cron-Eintrag (z. B. nach Migration), neu planen. */
add_action('init', function () {
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
}
// Manuelles Aktualisieren als Admin: ?skrift_preis_refresh=1
if (isset($_GET['skrift_preis_refresh']) && current_user_can('manage_options')) {
skrift_preis_sync();
}
});
/**
* Liefert die gespeicherten Preisdaten. Kein HTTP im Normalfall.
* Nur wenn noch nie synchronisiert wurde (oder die Daten deutlich veraltet
* sind), wird einmalig nachgeholt.
*/
function skrift_preis_daten() {
$d = get_option(SKRIFT_PREIS_OPTION);
$leer = !is_array($d) || empty($d['produkte']);
$alt = is_array($d) && isset($d['zeit']) && (time() - (int) $d['zeit']) > SKRIFT_PREIS_MAX_ALTER;
if ($leer || $alt) {
skrift_preis_sync();
$d = get_option(SKRIFT_PREIS_OPTION);
}
if (!is_array($d)) {
$d = array('produkte' => array(), 'settings' => array(), 'items' => array(), 'zeit' => 0, 'fehler' => 'Keine Daten.');
}
return $d;
}
/** Einen Preis-Positionswert (z. B. porto_inland) aus dem Katalog lesen. */
function skrift_preis_item($items, $key) {
foreach ($items as $it) {
if (isset($it['key']) && $it['key'] === $key) return (float) $it['price'];
}
return 0.0;
}
/** Ein Produkt per Schlüssel finden. */
function skrift_preis_produkt($produkte, $key) {
foreach ($produkte as $p) {
if (isset($p['key']) && $p['key'] === $key) return $p;
}
return null;
}
/**
* Die für die Rechnung nötigen Parameter eines Produkts – Grundlage sowohl für
* die PHP-Rechnung als auch für die identische Rechnung im Browser.
*/
function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
$s = $daten['settings'];
$p = skrift_preis_produkt($daten['produkte'], $produktKey);
if (!$p) return null;
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
// Kleinmengen-Aufschlag je Produkt (a = Deckel, e = Exponent). Leer → 1 / 3.
$a = (isset($p['price_factor_a']) && $p['price_factor_a'] !== null && $p['price_factor_a'] !== '') ? (float) $p['price_factor_a'] : 1.0;
$e = (isset($p['price_factor_e']) && $p['price_factor_e'] !== null && $p['price_factor_e'] !== '') ? (float) $p['price_factor_e'] : 3.0;
// Eine gemeinsame Formel.
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
$tiers = array();
if (isset($s['quantity_discounts']) && is_array($s['quantity_discounts'])) {
foreach ($s['quantity_discounts'] as $t) {
$tiers[] = array(
'min_qty' => isset($t['min_qty']) ? (int) $t['min_qty'] : 0,
'percent' => isset($t['percent']) ? (float) $t['percent'] : 0.0,
);
}
}
return array(
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
'norm' => $norm,
'a' => $a,
'e' => $e,
'formel' => $formel,
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
'tiers' => $tiers,
'porto' => skrift_preis_item($daten['items'], 'porto_inland'),
'name' => isset($p['name']) ? $p['name'] : $produktKey,
);
}
/**
* Kernrechnung in PHP (spiegelt die Standard-Formel des Backends):
* grund = base * menge * multiplikator
* multiplik. = menge >= norm ? 1 : 2 - sqrt(menge / norm)
* danach Mengenrabatt-Staffel, dann / menge = Preis pro Stück (netto).
*/
function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
if (!$par) return null;
$menge = max(1, (int) $menge);
// Multiplikator exakt mit der in Directus hinterlegten Formel berechnen.
// Ohne Normalpreis-Menge am Produkt greift kein Kleinmengen-Aufschlag.
$mult = 1.0;
if ($par['norm'] > 0) {
$mult = skrift_formel_eval($par['formel'], array(
'qty' => $menge, 'menge' => $menge,
'norm' => $par['norm'], 'base_price' => $par['base'],
'a' => $par['a'], 'e' => $par['e'],
), 1.0);
}
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
$grund = $par['base'] * $menge * $mult;
$besteMin = -1; $bestePercent = 0.0;
foreach ($par['tiers'] as $t) {
if ($menge >= $t['min_qty'] && $t['percent'] > 0 && $t['min_qty'] > $besteMin) {
$besteMin = $t['min_qty']; $bestePercent = $t['percent'];
}
}
if ($bestePercent > 0) $grund *= (1 - $bestePercent / 100);
$netto = $grund / $menge;
if ($mitKuvert) $netto += $par['porto'];
return array(
'netto' => round($netto, 2),
'brutto' => round($netto * (1 + $par['mwst'] / 100), 2),
'mwst' => $par['mwst'],
);
}
/** REST-Endpunkt bleibt bestehen (PHP-Rechnung), z. B. für eigene Integrationen. */
add_action('rest_api_init', function () {
register_rest_route('skrift/v1', '/preis', array(
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $req) {
$daten = skrift_preis_daten();
$par = skrift_preis_parameter(
$daten,
sanitize_text_field($req->get_param('produkt')),
$req->get_param('typ') === 'unternehmen' ? 'unternehmen' : 'privat'
);
$r = skrift_preis_rechne_param(
$par,
(int) $req->get_param('menge'),
$req->get_param('kuvert') === '1' || $req->get_param('kuvert') === 'true'
);
if (!$r) {
$grund = !empty($daten['fehler']) ? $daten['fehler'] : 'Produkt nicht gefunden.';
return new WP_Error('skrift_preis', $grund, array('status' => 404));
}
return rest_ensure_response($r);
},
));
});
/**
* Darstellung. $mitMwst bestimmt, WELCHER Preis gezeigt wird – der Besucher
* wählt das bewusst nicht selbst.
*
* Die Parameter stehen im Seitenquelltext; die Neuberechnung passiert dadurch
* sofort im Browser (kein Netzwerkzugriff, keine Latenz) mit exakt derselben
* Formel wie in PHP.
*/
function skrift_preis_render($atts, $mitMwst) {
$a = shortcode_atts(array('produkt' => 'briefe', 'typ' => 'privat', 'menge' => 100), $atts);
$daten = skrift_preis_daten();
$par = skrift_preis_parameter($daten, $a['produkt'], $a['typ']);
if (!$par) {
if (!empty($daten['fehler'])) {
return '<p><strong>Preisrechner nicht verfügbar.</strong><br><span style="color:#64748b;font-size:13px;">'
. esc_html($daten['fehler']) . '</span></p>';
}
$bekannt = array();
foreach ($daten['produkte'] as $pp) { if (isset($pp['key'])) $bekannt[] = $pp['key']; }
return '<p><strong>Preisrechner:</strong> Produkt „' . esc_html($a['produkt']) . '" nicht gefunden.'
. (count($bekannt) ? '<br><span style="color:#64748b;font-size:13px;">Verfügbar: '
. esc_html(implode(', ', $bekannt)) . '</span>' : '') . '</p>';
}
$startMenge = max(1, (int) $a['menge']);
$start = skrift_preis_rechne_param($par, $startMenge, false);
$startWert = $mitMwst ? $start['brutto'] : $start['netto'];
$hinweis = $mitMwst
? 'inkl. ' . rtrim(rtrim(number_format($par['mwst'], 1, ',', '.'), '0'), ',') . ' % MwSt.'
: 'zzgl. MwSt.';
$id = 'skpreis_' . wp_rand(1000, 9999);
ob_start(); ?>
<div class="sk-preisrechner" id="<?php echo esc_attr($id); ?>" style="width:100%;box-sizing:border-box;color:#1A1A1A;font-family:var(--e-global-typography-text-font-family, inherit);font-weight:var(--e-global-typography-text-font-weight, inherit);">
<label style="display:block;font-size:0.875em;margin-bottom:8px;color:#1A1A1A;">Anzahl</label>
<input type="number" min="1" value="<?php echo esc_attr($startMenge); ?>" class="skp-menge"
style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d8dde5;border-radius:8px;font-family:inherit;font-size:1em;color:#1A1A1A;margin-bottom:18px;">
<label style="display:flex;align-items:flex-start;gap:10px;font-size:0.95em;color:#1A1A1A;margin-bottom:20px;cursor:pointer;">
<input type="checkbox" class="skp-kuvert" style="margin-top:4px;flex:0 0 auto;">
<span>Mit handbeschriebenem Kuvert und Direktversand an die Empfänger</span>
</label>
<div style="border-top:1px solid #e6e6e6;padding-top:16px;display:flex;justify-content:space-between;align-items:baseline;gap:16px;">
<span style="font-size:1em;">Preis pro Stück</span>
<strong class="skp-wert" style="font-size:1.5em;font-family:var(--e-global-typography-primary-font-family, inherit);white-space:nowrap;"><?php echo esc_html(number_format($startWert, 2, ',', '.')); ?> €</strong>
</div>
<div style="text-align:right;font-size:0.8em;opacity:.65;margin-top:4px;"><?php echo esc_html($hinweis); ?></div>
</div>
<script>
(function () {
var wrap = document.getElementById(<?php echo wp_json_encode($id); ?>);
if (!wrap) return;
var par = <?php echo wp_json_encode($par); ?>;
var mitMwst = <?php echo $mitMwst ? 'true' : 'false'; ?>;
var menge = wrap.querySelector('.skp-menge');
var kuvert = wrap.querySelector('.skp-kuvert');
var wert = wrap.querySelector('.skp-wert');
// Formel-Interpreter (Spiegel der PHP-Fassung) – wertet die in Directus
// hinterlegte Formel aus, damit Änderungen automatisch mitwirken.
function formelEval(ausdruck, vars) {
if (!ausdruck) return 1;
var s = String(ausdruck).replace(/Math\./g, '').replace(/%(\w+)%/g, '$1');
var T = [], i = 0;
while (i < s.length) {
var c = s[i];
if (/\s/.test(c)) { i++; continue; }
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] || ''))) {
var st = i; while (i < s.length && /[0-9.]/.test(s[i])) i++;
T.push(['num', parseFloat(s.slice(st, i))]); continue;
}
if (/[A-Za-z_]/.test(c)) {
var sa = i; while (i < s.length && /[A-Za-z0-9_]/.test(s[i])) i++;
T.push(['id', s.slice(sa, i)]); continue;
}
var zwei = s.substr(i, 2);
if (['>=', '<=', '==', '!='].indexOf(zwei) >= 0) { T.push(['op', zwei]); i += 2; continue; }
T.push(['op', c]); i++;
}
var k = 0;
function ist(w) { return k < T.length && T[k][0] === 'op' && T[k][1] === w; }
function fn(n, a) {
var x = a[0] || 0;
switch (n) {
case 'sqrt': return x < 0 ? 0 : Math.sqrt(x);
case 'pow': return Math.pow(x, a[1] || 0);
case 'abs': return Math.abs(x);
case 'min': return Math.min.apply(null, a);
case 'max': return Math.max.apply(null, a);
case 'round': return Math.round(x);
case 'floor': return Math.floor(x);
case 'ceil': return Math.ceil(x);
}
return 0;
}
function primary() {
if (k >= T.length) return 0;
var t = T[k];
if (t[0] === 'num') { k++; return t[1]; }
if (t[0] === 'id') {
var n = t[1]; k++;
if (ist('(')) {
k++; var a = [];
if (!ist(')')) { a.push(ternary()); while (ist(',')) { k++; a.push(ternary()); } }
if (ist(')')) k++;
return fn(n, a);
}
return vars[n] !== undefined ? Number(vars[n]) : 0;
}
if (ist('(')) { k++; var r = ternary(); if (ist(')')) k++; return r; }
k++; return 0;
}
function unary() { if (ist('-')) { k++; return -unary(); } if (ist('+')) { k++; return unary(); } return primary(); }
function mul() { var l = unary(); while (ist('*') || ist('/')) { var o = T[k][1]; k++; var r = unary(); l = o === '*' ? l * r : (r === 0 ? 0 : l / r); } return l; }
function add() { var l = mul(); while (ist('+') || ist('-')) { var o = T[k][1]; k++; var r = mul(); l = o === '+' ? l + r : l - r; } return l; }
function cmp() {
var l = add();
while (k < T.length && T[k][0] === 'op' && ['>=', '<=', '==', '!=', '>', '<'].indexOf(T[k][1]) >= 0) {
var o = T[k][1]; k++; var r = add();
l = o === '>=' ? l >= r : o === '<=' ? l <= r : o === '>' ? l > r : o === '<' ? l < r : o === '==' ? l == r : l != r;
}
return l;
}
function ternary() {
var c = cmp();
if (ist('?')) { k++; var a = ternary(); if (ist(':')) k++; var b = ternary(); return c ? a : b; }
return c;
}
var out = Number(ternary());
return isFinite(out) ? out : 1;
}
function berechne(m, mitKuvert) {
m = Math.max(1, parseInt(m, 10) || 1);
var mult = par.norm > 0
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base, a: par.a, e: par.e })
: 1;
if (!isFinite(mult) || mult <= 0) mult = 1;
var grund = par.base * m * mult;
var besteMin = -1, bestePercent = 0;
(par.tiers || []).forEach(function (t) {
if (m >= t.min_qty && t.percent > 0 && t.min_qty > besteMin) { besteMin = t.min_qty; bestePercent = t.percent; }
});
if (bestePercent > 0) grund *= (1 - bestePercent / 100);
var netto = grund / m;
if (mitKuvert) netto += par.porto;
return mitMwst ? netto * (1 + par.mwst / 100) : netto;
}
function fmt(v) { return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
function aktualisiere() { wert.textContent = fmt(berechne(menge.value, kuvert.checked)); }
menge.addEventListener('input', aktualisiere);
kuvert.addEventListener('change', aktualisiere);
// Erst beim Verlassen des Feldes auf 1 zurücksetzen – währenddessen darf es leer sein.
menge.addEventListener('blur', function () {
var n = parseInt(menge.value, 10);
if (!(n >= 1)) { menge.value = '1'; aktualisiere(); }
});
})();
</script>
<?php
return ob_get_clean();
}
/** [skrift_preis_inkl produkt="briefe"] – Preis inkl. MwSt. */
add_shortcode('skrift_preis_inkl', function ($atts) { return skrift_preis_render($atts, true); });
/** [skrift_preis_exkl produkt="briefe"] – Preis exkl. MwSt. */
add_shortcode('skrift_preis_exkl', function ($atts) { return skrift_preis_render($atts, false); });
/** Alt-Shortcode bleibt gültig und zeigt den Bruttopreis. */
add_shortcode('skrift_preis', function ($atts) { return skrift_preis_render($atts, true); });