350 lines
19 KiB
PHP
350 lines
19 KiB
PHP
<?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)];
|
||
}
|
||
}
|