Files
skrift-programme/Webseite/skrift-preisrechner/skrift-preisrechner.php
Lucas Orth 06c4078290 Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus
- Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/
  Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup
  (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange)
  und Empfängerliste mit Vorschau von Schriftstück und Kuvert.
- Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche.
- Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang.
- Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert),
  Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard.
- Absender aller Mails ist Skrift (Projektname + email_settings); die
  Angebotsanfrage nennt jetzt das angefragte Produkt.

Preislogik
- Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt
  (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt.
- Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen.
- Preis pro Stück ohne einmalige Kosten (unit_net_total).
- Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt.
- Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm.
- Briefnummern 1-basiert: Dateiname entspricht der Briefnummer.

app.skrift.de
- Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als
  eigene Seite, Kontaktprodukte direkt im Anfrageformular.
- Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator.
- Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet.
- Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar,
  Standardmenge 100; Checkout-Checkboxen im Kartenstil.

Produktion
- skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter
  und automatischer Übergang in die Kuvertierung.

WordPress
- Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt,
  stündlicher Abgleich der Preisdaten in WordPress und ein sicherer
  Formel-Interpreter, damit Formeländerungen automatisch greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:24:12 +02:00

417 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,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;
// 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,
'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'],
), 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 })
: 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); });