Files
EpiWebview/dist/ROI.php

1325 lines
51 KiB
PHP
Raw Permalink 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
// ROI.php Umsatz/Peak/Histogramm pro Artikel inkl. Bundleanteil + Extern-Umsatz
// + "Slot-Umsatz" (auftragsbasiert, nicht tagesbasiert) inkl. Bundleanteil in Modal
// liegt in /dist
require('../config.php');
require('../EpiApi.php');
require_once __DIR__ . '/../vendor/autoload.php';
date_default_timezone_set('Europe/Berlin');
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
$Epi = new Epirent();
/* =========================
Helpers
========================= */
function formatEuro(float $amount): string {
$formatted = number_format(abs($amount), 2, ',', '.') . ' €';
return $amount < 0 ? '-' . $formatted : $formatted;
}
function yearFromDate(?string $ymd): ?int {
if (!$ymd || $ymd === '0000-00-00') return null;
return (int)substr($ymd, 0, 4);
}
function safeYmd(?string $ymd): ?string {
if (!$ymd || $ymd === '0000-00-00') return null;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $ymd)) return null;
return $ymd;
}
function ymdToTs(string $ymd): int {
return (new DateTime($ymd . ' 00:00:00', new DateTimeZone('Europe/Berlin')))->getTimestamp();
}
function tsToYmd(int $ts): string {
return (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('Europe/Berlin'))->format('Y-m-d');
}
function base64Json($data): string {
return base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
function apiJsonDecode($raw) {
if (is_object($raw)) {
if (method_exists($raw, 'getContents')) {
$raw = $raw->getContents();
} else {
$raw = (string)$raw;
}
}
if (!is_string($raw)) $raw = (string)$raw;
return json_decode($raw);
}
/* =========================
API Caches
========================= */
$productCache = []; // [pk => productObj]
$bundleLeafCache = []; // [bundlePk => [leafPk => amount]]
$rentPriceCache = []; // [pk => float]
function getProduct(Epirent $Epi, int $productPk) {
global $productCache;
if ($productPk <= 0) return null;
if (isset($productCache[$productPk])) return $productCache[$productPk];
$res = apiJsonDecode($Epi->requestEpiApi('/v1/product/' . $productPk . '?cl=' . Epirent_Mandant));
$prod = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null;
$productCache[$productPk] = $prod;
return $prod;
}
function getRentPrice(Epirent $Epi, int $productPk): float {
global $rentPriceCache;
if (isset($rentPriceCache[$productPk])) return $rentPriceCache[$productPk];
$p = getProduct($Epi, $productPk);
$price = 0.0;
if ($p && isset($p->pricing) && isset($p->pricing->price_rent)) {
$price = (float)$p->pricing->price_rent;
}
$rentPriceCache[$productPk] = $price;
return $price;
}
/**
* WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles.
* Damit ist die anteilige Bundlepreis-Berechnung wieder wie in der funktionierenden Version.
*
* Ergebnis: [leafProductPk => amount]
* - nur wenn is_virtual == true UND rent_fix/materials vorhanden
* - sonst: leaf = [self=>1]
* - Cycle-Guard über $stack
*/
function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array {
global $bundleLeafCache;
if ($productPk <= 0) return [];
if (isset($bundleLeafCache[$productPk])) return $bundleLeafCache[$productPk];
if (isset($stack[$productPk])) {
return [];
}
$stack[$productPk] = true;
$p = getProduct($Epi, $productPk);
if (!$p) {
unset($stack[$productPk]);
return [];
}
// NUR virtuelle Bundles zerlegen
$isVirtual = (bool)($p->is_virtual ?? false);
if (!$isVirtual) {
unset($stack[$productPk]);
$bundleLeafCache[$productPk] = [$productPk => 1.0];
return $bundleLeafCache[$productPk];
}
$mats = [];
if (!empty($p->materials_ext) && !empty($p->materials_ext->rent_fix) && is_array($p->materials_ext->rent_fix)) {
$mats = $p->materials_ext->rent_fix;
} elseif (!empty($p->materials) && is_array($p->materials)) {
$mats = $p->materials;
}
// virtuell aber ohne Komponenten => leaf
if (empty($mats)) {
unset($stack[$productPk]);
$bundleLeafCache[$productPk] = [$productPk => 1.0];
return $bundleLeafCache[$productPk];
}
$leaf = [];
foreach ($mats as $m) {
$childPk = (int)($m->mat_product_pk ?? 0);
$amt = (float)($m->amount ?? 1);
if ($childPk <= 0 || $amt <= 0) continue;
$childLeaf = resolveBundleLeafMap($Epi, $childPk, $stack);
foreach ($childLeaf as $leafPk => $leafAmt) {
if (!isset($leaf[$leafPk])) $leaf[$leafPk] = 0.0;
$leaf[$leafPk] += $amt * (float)$leafAmt;
}
}
unset($stack[$productPk]);
if (empty($leaf)) $leaf = [$productPk => 1.0];
$bundleLeafCache[$productPk] = $leaf;
return $leaf;
}
/**
* Bundle-Umsatz anteilig auf leaf-Produkte verteilen.
* Gewichtung: rentPrice(leaf) * amount
* Fallback: gleichverteilt nach amount
*/
function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenueNet): array {
$leafMap = resolveBundleLeafMap($Epi, $bundlePk);
if (empty($leafMap)) return [];
// wenn NICHT virtuell (leafMap == self) => dann keine Allocation (weil kein Bundle)
if (count($leafMap) === 1 && isset($leafMap[$bundlePk])) {
return [];
}
$weights = [];
$sumW = 0.0;
foreach ($leafMap as $leafPk => $amt) {
$rp = getRentPrice($Epi, (int)$leafPk);
$w = $rp * (float)$amt;
$weights[$leafPk] = $w;
$sumW += $w;
}
if ($sumW <= 0.0) {
$sumAmt = array_sum($leafMap);
if ($sumAmt <= 0.0) $sumAmt = (float)count($leafMap);
$alloc = [];
foreach ($leafMap as $leafPk => $amt) {
$alloc[$leafPk] = $bundleRevenueNet * ((float)$amt / $sumAmt);
}
return $alloc;
}
$alloc = [];
foreach ($weights as $leafPk => $w) {
$alloc[$leafPk] = $bundleRevenueNet * ($w / $sumW);
}
return $alloc;
}
/* =========================
Journal Cache
========================= */
$journalCache = [];
function getJournalByChapter(Epirent $Epi, int $chapterId): array {
global $journalCache;
if ($chapterId <= 0) return [];
if (isset($journalCache[$chapterId])) return $journalCache[$chapterId];
$url = '/v1/journal/filter?chid=' . $chapterId . '&cl=' . Epirent_Mandant;
$res = apiJsonDecode($Epi->requestEpiApi($url));
$payload = ($res && ($res->success ?? false) && isset($res->payload) && is_array($res->payload)) ? $res->payload : [];
$journalCache[$chapterId] = $payload;
return $payload;
}
/* =========================
Interval aggregation (Peak/Histogram) tagesbasiert (wie gehabt)
========================= */
// eventsDirect[productPk][ymd] += deltaQty
$eventsDirect = [];
$eventsIncl = []; // direct + bundle
function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void {
if ($productPk <= 0) return;
$startYmd = safeYmd($startYmd);
$endYmd = safeYmd($endYmd);
if (!$startYmd || !$endYmd) return;
if ($qty == 0.0) return;
$startTs = ymdToTs($startYmd);
$endTs = ymdToTs($endYmd);
if ($endTs < $startTs) return;
$endPlusTs = $endTs + 86400;
$endPlusYmd = tsToYmd($endPlusTs);
if (!isset($events[$productPk])) $events[$productPk] = [];
if (!isset($events[$productPk][$startYmd])) $events[$productPk][$startYmd] = 0.0;
if (!isset($events[$productPk][$endPlusYmd])) $events[$productPk][$endPlusYmd] = 0.0;
$events[$productPk][$startYmd] += $qty;
$events[$productPk][$endPlusYmd] -= $qty;
}
function computePeakAndHistogram(array $eventMap): array {
if (empty($eventMap)) return ['peak' => 0, 'hist' => []];
ksort($eventMap);
$dates = array_keys($eventMap);
$level = 0.0;
$peak = 0.0;
$hist = [];
for ($i = 0; $i < count($dates); $i++) {
$d = $dates[$i];
$level += (float)$eventMap[$d];
if ($level < 0) $level = 0;
$intLevel = (int)round($level);
if ($intLevel > $peak) $peak = $intLevel;
if ($i < count($dates) - 1) {
$dTs = ymdToTs($d);
$nTs = ymdToTs($dates[$i + 1]);
$days = ($nTs - $dTs) / 86400.0;
if ($days > 0 && $intLevel > 0) {
if (!isset($hist[$intLevel])) $hist[$intLevel] = 0.0;
$hist[$intLevel] += $days;
}
}
}
ksort($hist);
foreach ($hist as $k => $v) $hist[$k] = (int)round($v);
return ['peak' => (int)$peak, 'hist' => $hist];
}
/* =========================
Data aggregation
========================= */
$rows = []; // Debug rows (HTML only)
$pivot = []; // direct revenue per year (net)
$pivotB = []; // bundle revenue per year allocated to leaf (net)
$pivotExt = []; // extern revenue per year (net) direct
$pivotExtB = []; // extern revenue per year allocated (net) for virtual bundles
$meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]]
$allYears = [];
// Auftragsbasierte "Slots" (direct/incl) pro Produkt
// slotAgg[productPk]['direct'][slot] += revenue
// slotAgg[productPk]['incl'][slot] += revenue
// slotAgg[productPk]['direct_ext'][slot] += extRevenue
// slotAgg[productPk]['incl_ext'][slot] += extRevenue
$slotAgg = [];
function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title): void {
global $meta;
if (!isset($meta[$productPk])) {
$p = getProduct($Epi, $productPk);
$meta[$productPk] = [
'product_pk' => $productPk,
'product_no' => $productNo ?: (int)($p->product_no ?? 0),
'title' => $title ?: (string)($p->name ?? ''),
];
}
}
function addRevenue(array &$pivotRef, int $productPk, int $year, float $revenueNet): void {
if ($productPk <= 0 || !$year) return;
if (!isset($pivotRef[$productPk])) $pivotRef[$productPk] = [];
if (!isset($pivotRef[$productPk][$year])) $pivotRef[$productPk][$year] = 0.0;
$pivotRef[$productPk][$year] += $revenueNet;
}
/**
* Extern-Logik:
* - Amount External ist eine ANZAHL (nicht Umsatz).
* - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0)
* - fallback: 0
*/
function calcExternalRevenueNet(object $li): float {
$amountTotal = (float)($li->amount_total ?? 0);
$amountExternal = (float)($li->amount_external ?? 0);
$net = (float)($li->sum_total_net ?? 0);
if ($net == 0.0) return 0.0;
if ($amountTotal <= 0.0) return 0.0;
if ($amountExternal <= 0.0) return 0.0;
if ($amountExternal > $amountTotal) $amountExternal = $amountTotal;
return $net * ($amountExternal / $amountTotal);
}
/* =========================
Slot (auftragsbasiert) Intervall Coloring
========================= */
/**
* Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt)
*/
function addSlotItem(array &$slotItems, int $productPk, int $invoicePk, int $orderNo, string $startYmd, string $endYmd, float $qty, float $revenueNet, float $extRevenueNet): void {
$startYmd = safeYmd($startYmd);
$endYmd = safeYmd($endYmd);
if ($productPk <= 0) return;
if (!$startYmd || !$endYmd) return;
if ($qty <= 0) return;
$slotItems[$productPk][] = [
'invoice_pk' => $invoicePk,
'order_no' => $orderNo,
'start_ts' => ymdToTs($startYmd),
'end_ts' => ymdToTs($endYmd),
'qty' => (float)$qty,
'rev' => (float)$revenueNet,
'rev_ext' => (float)$extRevenueNet,
'start_ymd' => $startYmd,
'end_ymd' => $endYmd,
];
}
/**
* Slotting-Regel:
* - Aufträge werden nach start_ts sortiert,
* - bei gleichem start: zuerst der der später endet bekommt den "zweiten" Slot (also: end_ts DESC),
* - wenn dann noch gleich: nach order_no (oder invoice_pk) ASC.
* - Belegung: für qty=2 werden 2 Slots gesucht; wenn Slots frei werden (end < start), wiederverwendbar.
*
* Umsatzverteilung:
* - pro Auftrag & Produkt wird der UMSATZ auf die belegten Slots gleichmäßig verteilt (rev/qty)
* - analog für extern-umsatz.
*/
function computeOrderBasedSlots(array $itemsForProduct): array {
if (empty($itemsForProduct)) return [
'direct' => [],
'direct_ext' => []
];
usort($itemsForProduct, function($a, $b){
if ($a['start_ts'] !== $b['start_ts']) return $a['start_ts'] <=> $b['start_ts'];
if ($a['end_ts'] !== $b['end_ts']) return $b['end_ts'] <=> $a['end_ts']; // später endend zuerst
if ($a['order_no'] !== $b['order_no']) return $a['order_no'] <=> $b['order_no'];
return $a['invoice_pk'] <=> $b['invoice_pk'];
});
$slotEnd = []; // slotIndex => end_ts
$slotRevenue = []; // slotIndex => revenue
$slotRevenueExt = []; // slotIndex => revenueExt
foreach ($itemsForProduct as $it) {
$qty = (int)round($it['qty']);
if ($qty <= 0) continue;
$revPer = ($it['rev'] ?? 0.0) / $qty;
$extPer = ($it['rev_ext'] ?? 0.0) / $qty;
// finde freie Slots / neue Slots
$assigned = [];
for ($k=0; $k<$qty; $k++) {
$slot = null;
// freier Slot: end < start
foreach ($slotEnd as $idx => $endTs) {
if ($endTs < $it['start_ts']) { // streng <, damit gleicher Tag als parallel zählt
$slot = (int)$idx;
break;
}
}
if ($slot === null) {
$slot = count($slotEnd) + 1; // Slots sind 1-based
}
// reservieren
$slotEnd[$slot] = $it['end_ts'];
$assigned[] = $slot;
// Revenue sammeln
if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0;
if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0;
$slotRevenue[$slot] += $revPer;
$slotRevenueExt[$slot] += $extPer;
}
}
ksort($slotRevenue);
ksort($slotRevenueExt);
// runden (2 Nachkommastellen für Anzeige später, intern float ok)
return [
'direct' => $slotRevenue,
'direct_ext' => $slotRevenueExt
];
}
/* =========================
Prozessierung einer Artikelzeile
========================= */
function processLineItem(
Epirent $Epi,
array &$rows,
array &$slotItemsDirect,
array &$slotItemsIncl,
int $invoicePk,
int $invoiceNo,
string $invoiceDate,
int $invoiceYear,
?int $chapterId,
object $li,
bool $isFromJournal
): void {
global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl;
$type = (int)($li->type ?? -1);
$productPk = (int)($li->product_pk ?? 0);
if ($type !== 0 || $productPk <= 0) return; // nur echte Artikelpositionen
$title = (string)($li->title ?? '');
$productNo = (int)($li->product_no ?? 0);
$qty = (float)($li->amount_total ?? 0);
if ($qty <= 0) $qty = 0.0;
$dateStart = safeYmd((string)($li->date_start ?? '')) ?: null;
$dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null;
$revenueNet = (float)($li->sum_total_net ?? 0);
$extRevenueNet = calcExternalRevenueNet($li);
ensureMeta($Epi, $productPk, $productNo, $title);
// Jahr: Rechnungsdatum
$year = $invoiceYear;
if ($year) $allYears[$year] = true;
// Direct Umsatz
if ($revenueNet != 0.0 && $year) addRevenue($pivot, $productPk, $year, $revenueNet);
// Direct Extern-Umsatz
if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet);
// Peak/Histogram direct + incl (tagesbasiert)
if ($dateStart && $dateEnd && $qty > 0) {
addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qty);
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qty);
}
// Slotting: direct
if ($dateStart && $dateEnd && $qty > 0) {
addSlotItem($slotItemsDirect, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet);
addSlotItem($slotItemsIncl, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet);
}
// Debug Row
$rows[] = [
'year' => $year,
'invoice_pk' => $invoicePk,
'invoice_no' => $invoiceNo,
'invoice_date'=> $invoiceDate,
'chapter_id' => $chapterId ?? 0,
'source' => $isFromJournal ? 'journal' : 'direct',
'product_pk' => $productPk,
'product_no' => $productNo,
'title' => $title,
'date_start' => $dateStart ?? '',
'date_end' => $dateEnd ?? '',
'qty' => $qty,
'revenue_net' => $revenueNet,
'ext_rev_net' => $extRevenueNet,
'amount_ext' => (float)($li->amount_external ?? 0),
'amount_total'=> (float)($li->amount_total ?? 0),
];
/* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */
$leafMap = resolveBundleLeafMap($Epi, $productPk);
$isVirtualBundle = !(count($leafMap) === 1 && isset($leafMap[$productPk]) && (float)$leafMap[$productPk] === 1.0);
if ($isVirtualBundle) {
// Umsatz allokieren (inkl Bundle-Anteil)
if ($revenueNet != 0.0 && $year) {
$alloc = allocateBundleRevenue($Epi, $productPk, $revenueNet);
foreach ($alloc as $leafPk => $leafRevenue) {
$leafPk = (int)$leafPk;
ensureMeta($Epi, $leafPk, 0, '');
addRevenue($pivotB, $leafPk, $year, (float)$leafRevenue);
}
}
// Extern-Umsatz allokieren (inkl Bundle-Anteil)
if ($extRevenueNet != 0.0 && $year) {
$allocExt = allocateBundleRevenue($Epi, $productPk, $extRevenueNet);
foreach ($allocExt as $leafPk => $leafRevenue) {
$leafPk = (int)$leafPk;
ensureMeta($Epi, $leafPk, 0, '');
addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue);
}
}
// Auslastung allokieren (Peak/Histogramm inkl Bundle)
if ($dateStart && $dateEnd && $qty > 0) {
foreach ($leafMap as $leafPk => $amt) {
$leafPk = (int)$leafPk;
$amt = (float)$amt;
if ($leafPk <= 0 || $amt <= 0) continue;
ensureMeta($Epi, $leafPk, 0, '');
addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qty * $amt);
}
}
// Slotting allokieren (inkl Bundle): wir erzeugen SlotItems für Leaf-Produkte
if ($dateStart && $dateEnd && $qty > 0) {
// Revenue pro "Bundle-Item" wird in allocateBundleRevenue bereits verteilt.
$alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : [];
$allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : [];
foreach ($leafMap as $leafPk => $amt) {
$leafPk = (int)$leafPk;
$amt = (float)$amt;
if ($leafPk <= 0 || $amt <= 0) continue;
$leafRev = (float)($alloc[$leafPk] ?? 0.0);
$leafExt = (float)($allocExt[$leafPk] ?? 0.0);
// qty_leaf = qty * amt
$qtyLeaf = $qty * $amt;
ensureMeta($Epi, $leafPk, 0, '');
addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qtyLeaf, $leafRev, $leafExt);
}
}
}
}
/* =========================
1) Invoices holen + Daten sammeln
========================= */
$slotItemsDirect = []; // productPk => list of intervals
$slotItemsIncl = []; // productPk => list of intervals
$invoiceAll = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/all?ir=true&ib=true&cl=' . Epirent_Mandant));
$invoiceList = ($invoiceAll && ($invoiceAll->success ?? false) && is_array($invoiceAll->payload ?? null)) ? $invoiceAll->payload : [];
foreach ($invoiceList as $inv) {
$invoicePk = (int)($inv->primary_key ?? 0);
if ($invoicePk <= 0) continue;
$invRes = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/' . $invoicePk . '?cl=' . Epirent_Mandant));
$invObj = ($invRes && ($invRes->success ?? false) && !empty($invRes->payload[0])) ? $invRes->payload[0] : null;
if (!$invObj) continue;
$invoiceDate = (string)($invObj->invoice_date ?? '');
$invoiceYear = yearFromDate($invoiceDate) ?? 0;
$invoiceNo = (int)($invObj->invoice_no ?? 0);
$orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = [];
foreach ($orderItems as $oi) {
$oiType = (int)($oi->type ?? -1);
$oiPk = (int)($oi->primary_key ?? 0);
// Kapitel (type=5): chid ist primary_key der Kapitelzeile
if ($oiType === 5 && $oiPk > 0) {
$chapterId = $oiPk;
$journalItems = getJournalByChapter($Epi, $chapterId);
foreach ($journalItems as $ji) {
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true);
}
continue;
}
// Direkte Artikelposition ohne Kapitel (type=0)
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) {
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false);
continue;
}
}
}
/* =========================
2) Slot Aggregation (auftragsbasiert)
========================= */
foreach ($meta as $productPk => $m) {
$productPk = (int)$productPk;
$resD = computeOrderBasedSlots($slotItemsDirect[$productPk] ?? []);
$resI = computeOrderBasedSlots($slotItemsIncl[$productPk] ?? []);
$slotAgg[$productPk] = [
'direct' => $resD['direct'] ?? [],
'incl' => $resI['direct'] ?? [],
'direct_ext' => $resD['direct_ext'] ?? [],
'incl_ext' => $resI['direct_ext'] ?? [],
];
}
/* =========================
3) Years + Pivot Rows bauen
========================= */
$years = array_keys($allYears);
sort($years);
$pivotRows = [];
foreach ($meta as $productPk => $m) {
$productPk = (int)$productPk;
$row = [
'product_pk' => $productPk,
'product_no' => (int)($m['product_no'] ?? 0),
'title' => (string)($m['title'] ?? ''),
'years' => [],
'years_ext' => [],
'total_direct'=> 0.0,
'total_incl' => 0.0,
'total_ext_direct'=> 0.0,
'total_ext_incl' => 0.0,
'peak_direct' => 0,
'peak_incl' => 0,
'hist_direct' => [],
'hist_incl' => [],
'slots_direct' => $slotAgg[$productPk]['direct'] ?? [],
'slots_incl' => $slotAgg[$productPk]['incl'] ?? [],
'slots_direct_ext' => $slotAgg[$productPk]['direct_ext'] ?? [],
'slots_incl_ext' => $slotAgg[$productPk]['incl_ext'] ?? [],
];
$sumDirect = 0.0;
$sumIncl = 0.0;
$sumExtDirect = 0.0;
$sumExtIncl = 0.0;
foreach ($years as $y) {
$d = (float)($pivot[$productPk][$y] ?? 0.0);
$b = (float)($pivotB[$productPk][$y] ?? 0.0);
$incl = $d + $b;
$de = (float)($pivotExt[$productPk][$y] ?? 0.0);
$be = (float)($pivotExtB[$productPk][$y] ?? 0.0);
$inclE = $de + $be;
$row['years'][$y] = ['direct'=>$d, 'incl'=>$incl];
$row['years_ext'][$y] = ['direct'=>$de, 'incl'=>$inclE];
$sumDirect += $d;
$sumIncl += $incl;
$sumExtDirect += $de;
$sumExtIncl += $inclE;
}
$row['total_direct'] = $sumDirect;
$row['total_incl'] = $sumIncl;
$row['total_ext_direct'] = $sumExtDirect;
$row['total_ext_incl'] = $sumExtIncl;
$pd = computePeakAndHistogram($GLOBALS['eventsDirect'][$productPk] ?? []);
$pi = computePeakAndHistogram($GLOBALS['eventsIncl'][$productPk] ?? []);
$row['peak_direct'] = (int)$pd['peak'];
$row['peak_incl'] = (int)$pi['peak'];
$row['hist_direct'] = $pd['hist'];
$row['hist_incl'] = $pi['hist'];
// nur anzeigen, wenn was los ist
$hasAny = (
$row['total_direct'] != 0.0 ||
$row['total_incl'] != 0.0 ||
$row['total_ext_direct'] != 0.0 ||
$row['total_ext_incl'] != 0.0 ||
$row['peak_direct'] != 0 ||
$row['peak_incl'] != 0
);
if (!$hasAny) continue;
$pivotRows[] = $row;
}
usort($pivotRows, fn($a,$b) => ($b['total_incl'] <=> $a['total_incl']));
/* =========================
4) Excel Export (nur Pivot)
========================= */
$exportDir = __DIR__ . '/exports';
if (!is_dir($exportDir)) {
@mkdir($exportDir, 0775, true);
}
$timestamp = date('Ymd_His');
$excelFileName = "ROI_Artikel_Jahre_{$timestamp}.xlsx";
$excelFilePath = $exportDir . '/' . $excelFileName;
$excelDownloadUrl = 'exports/' . $excelFileName;
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Pivot');
$cols = [
'Produkt-PK','Artikel-Nr','Artikel',
'Peak','Peak (inkl. Bundle)',
'Summe','Summe (inkl. Bundle)',
'davon extern','davon extern (inkl. Bundle)'
];
foreach ($years as $y) {
$cols[] = (string)$y;
$cols[] = (string)$y . ' (inkl. Bundle)';
$cols[] = (string)$y . ' ext';
$cols[] = (string)$y . ' ext (inkl. Bundle)';
}
$colCount = count($cols);
$lastColLetter = Coordinate::stringFromColumnIndex($colCount);
// Header
$sheet->setCellValue('A1', 'EpiWebview ROI / Umsatz pro Artikel & Jahr');
$sheet->mergeCells('A1:' . $lastColLetter . '1');
$sheet->getStyle('A1:' . $lastColLetter . '1')->getFont()->setBold(true)->setSize(14);
$sheet->getStyle('A1:' . $lastColLetter . '1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->setCellValue('A2', 'Exportdatum: ' . date('d.m.Y H:i') . ' | Mandant: ' . (defined('Epirent_Mandant') ? Epirent_Mandant : ''));
$sheet->mergeCells('A2:' . $lastColLetter . '2');
$sheet->getStyle('A2:' . $lastColLetter . '2')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$headerRow = 4;
for ($i=0; $i<$colCount; $i++) {
$addr = Coordinate::stringFromColumnIndex($i+1) . $headerRow;
$sheet->setCellValue($addr, $cols[$i]);
$sheet->getStyle($addr)->getFont()->setBold(true);
$sheet->getStyle($addr)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i+1))->setAutoSize(true);
}
$rowIdx = $headerRow + 1;
foreach ($pivotRows as $r) {
$c = 1;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['product_pk'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['product_no'], DataType::TYPE_NUMERIC);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($c++).$rowIdx, (string)$r['title']);
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['peak_direct'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['peak_incl'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_direct'], DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_incl'], DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_ext_direct'], DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_ext_incl'], DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
foreach ($years as $y) {
$d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$y]['incl'];
$de = (float)$r['years_ext'][$y]['direct'];
$ie = (float)$r['years_ext'][$y]['incl'];
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $d, DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $iVal, DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $de, DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $ie, DataType::TYPE_NUMERIC);
$sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++;
}
$rowIdx++;
}
$sheet->setAutoFilter("A{$headerRow}:{$lastColLetter}{$headerRow}");
$sheet->freezePane("A" . ($headerRow + 1));
$writer = new Xlsx($spreadsheet);
$writer->save($excelFilePath);
/* =========================
5) HTML Ausgabe
========================= */
function slotsToDisplay(array $slotMap): array {
// slotMap: [slot=>revenue]
ksort($slotMap);
$out = [];
foreach ($slotMap as $slot => $rev) {
$slot = (int)$slot;
if ($slot <= 0) continue;
$out[] = ['slot'=>$slot, 'rev'=>(float)$rev];
}
return $out;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>ROI Umsatz pro Artikel / Jahr</title>
<link href="css/styles.css" rel="stylesheet" />
<link href="https://cdn.datatables.net/1.10.20/css/dataTables.bootstrap4.min.css" rel="stylesheet" crossorigin="anonymous" />
<script src="js/jquery-3.5.1.min.js"></script>
<script src="https://kit.fontawesome.com/93d71de8bc.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/1.10.20/js/jquery.dataTables.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/1.10.20/js/dataTables.bootstrap4.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<style>
.kpi-updated { font-size: .82rem; opacity: .85; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.btn-xs { padding:.15rem .4rem; font-size:.78rem; }
.nowrap { white-space: nowrap; }
.small-muted { font-size: .82rem; opacity:.85; }
</style>
<script>
$(function () {
$('#layoutSidenav_nav').load('../sources/getSidenav.php');
$('#footerholder').load('../sources/getFooter.php');
});
</script>
</head>
<body class="sb-nav-fixed">
<nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
<a class="navbar-brand" href="index.php">Epi Webview</a>
<button class="btn btn-link btn-sm order-1 order-lg-0" id="sidebarToggle"><i class="fas fa-bars"></i></button>
</nav>
<div id="layoutSidenav">
<div id="layoutSidenav_nav"></div>
<div id="layoutSidenav_content">
<main>
<div class="container-fluid">
<h1 class="mt-4">ROI</h1>
<ol class="breadcrumb mb-4">
<li class="breadcrumb-item active">Umsatz / Peak / Histogramm pro Artikel</li>
</ol>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fas fa-table mr-1"></i> Pivot: Artikel × Rechnungsjahr</div>
<a class="btn btn-sm btn-success" href="<?php echo htmlspecialchars($excelDownloadUrl); ?>">
<i class="fas fa-file-excel"></i> Export nach Excel
</a>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="pivotTable" width="100%" cellspacing="0">
<thead>
<tr>
<th class="nowrap">Produkt-PK</th>
<th class="nowrap">Artikel-Nr</th>
<th>Artikel</th>
<th class="nowrap">Peak</th>
<th class="nowrap">Summe</th>
<th class="nowrap">davon extern</th>
<?php foreach ($years as $y): ?>
<th class="nowrap"><?php echo (int)$y; ?></th>
<?php endforeach; ?>
<th class="nowrap">Histogramm</th>
<th class="nowrap">Slots</th>
</tr>
</thead>
<tbody>
<?php foreach ($pivotRows as $r): ?>
<?php
$peakDirect = (int)$r['peak_direct'];
$peakIncl = (int)$r['peak_incl'];
$histDirectB64 = base64Json($r['hist_direct']);
$histInclB64 = base64Json($r['hist_incl']);
$sumDirect = (float)$r['total_direct'];
$sumIncl = (float)$r['total_incl'];
$extDirect = (float)$r['total_ext_direct'];
$extIncl = (float)$r['total_ext_incl'];
$slotsDirect = slotsToDisplay($r['slots_direct'] ?? []);
$slotsIncl = slotsToDisplay($r['slots_incl'] ?? []);
$slotsDirectExt = slotsToDisplay($r['slots_direct_ext'] ?? []);
$slotsInclExt = slotsToDisplay($r['slots_incl_ext'] ?? []);
$slotsPayload = [
'direct' => $slotsDirect,
'incl' => $slotsIncl,
'direct_ext' => $slotsDirectExt,
'incl_ext' => $slotsInclExt,
];
$slotsB64 = base64Json($slotsPayload);
?>
<tr>
<td class="mono"><?php echo (int)$r['product_pk']; ?></td>
<td class="mono"><?php echo (int)$r['product_no']; ?></td>
<td><?php echo htmlspecialchars((string)$r['title']); ?></td>
<td class="nowrap">
<?php
echo $peakDirect;
echo ' <span class="text-muted">(' . $peakIncl . ')</span>';
?>
</td>
<td class="nowrap">
<?php
echo formatEuro($sumDirect);
echo ' <span class="text-muted">(' . formatEuro($sumIncl) . ')</span>';
?>
</td>
<td class="nowrap">
<?php
echo formatEuro($extDirect);
echo ' <span class="text-muted">(' . formatEuro($extIncl) . ')</span>';
?>
</td>
<?php foreach ($years as $y): ?>
<?php
$d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$y]['incl'];
$de = (float)$r['years_ext'][$y]['direct'];
$ie = (float)$r['years_ext'][$y]['incl'];
?>
<td class="nowrap">
<div><?php echo formatEuro($d); ?> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span></div>
<!--<div class="small-muted">ext: <?php echo formatEuro($de); ?> <span class="text-muted">(<?php echo formatEuro($ie); ?>)</span></div>-->
</td>
<?php endforeach; ?>
<td class="nowrap">
<button
class="btn btn-outline-primary btn-xs js-hist"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-hist="<?php echo htmlspecialchars($histDirectB64); ?>"
data-peak="<?php echo (int)$peakDirect; ?>"
data-mode="direct">
Histogramm
</button>
<button
class="btn btn-outline-secondary btn-xs js-hist"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-hist="<?php echo htmlspecialchars($histInclB64); ?>"
data-peak="<?php echo (int)$peakIncl; ?>"
data-mode="incl">
Histogramm inkl. Bundle
</button>
</td>
<td class="nowrap">
<button
class="btn btn-outline-dark btn-xs js-slots"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-slots="<?php echo htmlspecialchars($slotsB64); ?>">
Slots (Auftragsbasiert)
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<small class="text-muted">
Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>.
Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span> (tagesbasiert).
Werte in Klammern = inkl. Bundle-Anteil (nur <b>virtuelle</b> Bundles werden zerlegt).
Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span> (amount_external ist Menge).
Slots: auftragsbasierte Slotbelegung nach Start, dann Ende DESC, dann Auftragsnr.
</small>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-list mr-1"></i> Debug: verarbeitete Mietpositionen
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="detailTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>Jahr</th>
<th>Invoice</th>
<th>Rechnungsnr</th>
<th>Rechnungsdatum</th>
<th>Quelle</th>
<th>Chapter</th>
<th>Produkt-PK</th>
<th>Artikel-Nr</th>
<th>Artikel</th>
<th>Start</th>
<th>Ende</th>
<th>Menge</th>
<th>Ext-Menge</th>
<th>Umsatz netto</th>
<th>Umsatz extern</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><?php echo (int)$r['year']; ?></td>
<td class="mono"><?php echo (int)$r['invoice_pk']; ?></td>
<td class="mono"><?php echo (int)$r['invoice_no']; ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['invoice_date']); ?></td>
<td><?php echo htmlspecialchars((string)$r['source']); ?></td>
<td class="mono"><?php echo (int)$r['chapter_id']; ?></td>
<td class="mono"><?php echo (int)$r['product_pk']; ?></td>
<td class="mono"><?php echo (int)$r['product_no']; ?></td>
<td><?php echo htmlspecialchars((string)$r['title']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['date_start']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['date_end']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['qty']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)($r['amount_ext'] ?? '0')); ?></td>
<td class="nowrap"><?php echo formatEuro((float)$r['revenue_net']); ?></td>
<td class="nowrap"><?php echo formatEuro((float)$r['ext_rev_net']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<small class="text-muted">
Wenn ein Artikel “ohne Kapitel” fehlt, muss er hier als Quelle <b>direct</b> auftauchen.
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet.
</small>
</div>
</div>
</div>
</div>
</main>
<div id="footerholder"></div>
</div>
</div>
<!-- Histogram Modal -->
<div class="modal fade" id="histModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Histogramm</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<canvas id="histChart" height="140"></canvas>
<div class="mt-2 text-muted">
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) &nbsp;|&nbsp; Y-Achse: Tage
</div>
</div>
</div>
</div>
</div>
<!-- Slots Modal -->
<div class="modal fade" id="slotsModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Slots</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="mb-2 text-muted">
Slot-Umsatz ist <b>auftragsbasiert</b>: Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt.
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles).
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="slotsTable">
<thead>
<tr>
<th class="nowrap">Slot</th>
<th class="nowrap">Umsatz</th>
<th class="nowrap">Umsatz inkl. Bundle</th>
<th class="nowrap">davon extern</th>
<th class="nowrap">davon extern inkl. Bundle</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="mt-2">
<canvas id="slotsChart" height="130"></canvas>
</div>
</div>
</div>
</div>
</div>
<script>
let histChart = null;
let slotsChart = null;
function decodeB64Json(b64) {
try {
const json = atob(b64);
return JSON.parse(json);
} catch (e) {
return {};
}
}
function openHistogram(title, mode, peak, histObj) {
const labels = [];
const data = [];
const p = parseInt(peak || 0, 10);
for (let i = 1; i <= p; i++) {
labels.push(String(i));
data.push(histObj[String(i)] ? parseInt(histObj[String(i)], 10) : 0);
}
$('#histModal .modal-title').text(`${title} ${mode === 'incl' ? 'inkl. Bundle' : 'ohne Bundle'}`);
const ctx = document.getElementById('histChart');
if (histChart) { histChart.destroy(); histChart = null; }
histChart = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Tage', data }] },
options: {
responsive: true,
animation: false,
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
}
});
$('#histModal').modal('show');
}
function fmtEuro(n) {
const sign = n < 0 ? '-' : '';
n = Math.abs(n);
return sign + n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
}
function openSlots(title, payload) {
$('#slotsModal .modal-title').text(`${title} Slot-Umsatz (auftragsbasiert)`);
const direct = payload.direct || [];
const incl = payload.incl || [];
const directExt = payload.direct_ext || [];
const inclExt = payload.incl_ext || [];
// maps slot->rev
const mapD = {};
const mapI = {};
const mapDE = {};
const mapIE = {};
direct.forEach(x => mapD[String(x.slot)] = Number(x.rev || 0));
incl.forEach(x => mapI[String(x.slot)] = Number(x.rev || 0));
directExt.forEach(x => mapDE[String(x.slot)] = Number(x.rev || 0));
inclExt.forEach(x => mapIE[String(x.slot)] = Number(x.rev || 0));
const slots = new Set([
...Object.keys(mapD), ...Object.keys(mapI),
...Object.keys(mapDE), ...Object.keys(mapIE)
]);
const slotNums = Array.from(slots).map(s => parseInt(s,10)).filter(n => n>0).sort((a,b)=>a-b);
const $tb = $('#slotsTable tbody');
$tb.empty();
const labels = [];
const dataD = [];
const dataI = [];
slotNums.forEach(s => {
const sd = mapD[String(s)] || 0;
const si = mapI[String(s)] || 0;
const sde = mapDE[String(s)] || 0;
const sie = mapIE[String(s)] || 0;
$tb.append(`
<tr>
<td class="mono">${s}</td>
<td class="nowrap">${fmtEuro(sd)}</td>
<td class="nowrap text-muted">(${fmtEuro(si)})</td>
<td class="nowrap">${fmtEuro(sde)}</td>
<td class="nowrap text-muted">(${fmtEuro(sie)})</td>
</tr>
`);
labels.push(String(s));
dataD.push(sd);
dataI.push(si);
});
const ctx = document.getElementById('slotsChart');
if (slotsChart) { slotsChart.destroy(); slotsChart = null; }
slotsChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Umsatz', data: dataD },
{ label: 'Umsatz inkl. Bundle', data: dataI }
]
},
options: {
responsive: true,
animation: false,
scales: {
y: { beginAtZero: true }
}
}
});
$('#slotsModal').modal('show');
}
$(function() {
$('#pivotTable').DataTable({ pageLength: 50, order: [[4,'desc']] });
$('#detailTable').DataTable({ pageLength: 50, order: [[1,'desc']] });
$(document).on('click', '.js-hist', function() {
const title = $(this).data('title') || '';
const mode = $(this).data('mode') || 'direct';
const peak = $(this).data('peak') || 0;
const b64 = $(this).data('hist') || '';
const histObj = decodeB64Json(b64);
openHistogram(title, mode, peak, histObj);
});
$(document).on('click', '.js-slots', function() {
const title = $(this).data('title') || '';
const b64 = $(this).data('slots') || '';
const payload = decodeB64Json(b64);
openSlots(title, payload);
});
});
</script>
</body>
</html>