added ReturnOfInvest Statistic

This commit is contained in:
2026-01-29 14:45:22 +01:00
parent a71d401864
commit b963ab9ca1
2 changed files with 936 additions and 0 deletions

932
dist/ROI.php vendored Normal file
View File

@@ -0,0 +1,932 @@
<?php
// ROI.php Umsatz/Peak/Histogramm pro Artikel inkl. Bundleanteil
// 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;
use PhpOffice\PhpSpreadsheet\Style\Fill;
$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 {
// midnight local
return (new DateTime($ymd . ' 00:00:00'))->getTimestamp();
}
function tsToYmd(int $ts): string {
return (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('Europe/Berlin'))->format('Y-m-d');
}
function base64Json(array $data): string {
return base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
function apiJsonDecode($raw) {
// requestEpiApi kann je nach Implementierung string ODER Stream liefern
if (is_object($raw)) {
// Guzzle Stream etc.
if (method_exists($raw, 'getContents')) {
$raw = $raw->getContents();
} else {
$raw = (string)$raw;
}
}
if (!is_string($raw)) $raw = (string)$raw;
$obj = json_decode($raw);
return $obj;
}
/* =========================
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;
}
/**
* Bundle-Komponenten rekursiv auflösen.
* Ergebnis: [leafProductPk => amount]
*
* WICHTIG (Fix für "UJ90 hat (0)"):
* - NUR virtuelle Produkte (is_virtual==true) werden expandiert.
* - Nicht-virtuelle Produkte zählen IMMER als leaf auch wenn sie materials haben (Zubehör/Case/etc.)
*
* - bevorzugt materials_ext.rent_fix (weil rent_fix sauberer ist)
* - fallback materials
* - 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])) {
// cycle detected
return [];
}
$stack[$productPk] = true;
$p = getProduct($Epi, $productPk);
if (!$p) {
unset($stack[$productPk]);
return [];
}
// ✅ FIX: Nur virtuelle Produkte expandieren
$isVirtual = (bool)($p->is_virtual ?? false);
if (!$isVirtual) {
unset($stack[$productPk]);
$bundleLeafCache[$productPk] = [$productPk => 1.0];
return $bundleLeafCache[$productPk];
}
// Komponentenliste auslesen (nur für virtuelle Produkte)
$mats = [];
// materials_ext.rent_fix bevorzugen
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;
}
// Wenn keine 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;
// rekursiv
$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]);
// falls aus irgendeinem Grund leer => als leaf behandeln
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 [];
$weights = [];
$sumW = 0.0;
foreach ($leafMap as $leafPk => $amt) {
$rp = getRentPrice($Epi, (int)$leafPk);
$w = $rp * (float)$amt;
$weights[$leafPk] = $w;
$sumW += $w;
}
// fallback: sumW=0 => gleich nach amount
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)
========================= */
// 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;
// end+1 day
$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;
}
/**
* Aus Events Peak + Histogramm berechnen:
* return ['peak'=>int, 'hist'=>[level=>days]]
*/
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 = []; // [intLevel => floatDays]
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;
// segment length until next date
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);
// hist days runden
foreach ($hist as $k => $v) $hist[$k] = (int)round($v);
return ['peak' => (int)$peak, 'hist' => $hist];
}
/* =========================
Data aggregation
========================= */
$rows = []; // Debug rows (HTML only, no excel)
$pivot = []; // direct revenue per year
$pivotB = []; // bundle revenue per year (alloc to leaf)
$meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]]
$allYears = [];
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;
}
/**
* Eine Artikelzeile (aus Journal ODER direkt) verarbeiten
*/
function processLineItem(
Epirent $Epi,
array &$rows,
int $invoicePk,
string $invoiceDate,
int $invoiceYear,
?int $chapterId,
object $li,
bool $isFromJournal
): void {
global $pivot, $pivotB, $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;
// Zeitraum (für Peak/Histogramm)
$dateStart = (string)($li->date_start ?? '');
$dateEnd = (string)($li->date_end ?? '');
$dateStart = safeYmd($dateStart) ?: null;
$dateEnd = safeYmd($dateEnd) ?: null;
// Umsatz netto (für Pivot)
$revenueNet = (float)($li->sum_total_net ?? 0);
ensureMeta($Epi, $productPk, $productNo, $title);
// Jahr: AN RECHNUNGSDATUM gebunden (dein Wunsch)
$year = $invoiceYear;
if ($year) $allYears[$year] = true;
// Direct Umsatz (auch wenn Zeitraum evtl. leer ist)
if ($revenueNet != 0.0 && $year) {
addRevenue($pivot, $productPk, $year, $revenueNet);
}
// Direct Auslastung (nur wenn Zeitraum da)
if ($dateStart && $dateEnd && $qty > 0) {
addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qty);
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qty);
}
// Debug Row
$rows[] = [
'year' => $year,
'invoice_pk' => $invoicePk,
'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,
];
/* ===== Bundle-Auflösung =====
- Bundle-Erkennung NUR über is_virtual (Fix!)
- Auslastung inkl Bundle: Komponenten bekommen qty*amount über Zeitraum
- Umsatz inkl Bundle: Umsatz wird proportional auf Leaf-Produkte verteilt
*/
$p = getProduct($Epi, $productPk);
$isBundle = (bool)($p->is_virtual ?? false); // ✅ FIX: nicht über materials ableiten
if ($isBundle) {
$leafMap = resolveBundleLeafMap($Epi, $productPk);
// Umsatz allokieren
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);
}
}
// Auslastung allokieren
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);
}
}
}
}
/* =========================
1) Invoices holen
========================= */
$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;
$orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = [];
foreach ($orderItems as $oi) {
$oiType = (int)($oi->type ?? -1);
$oiPk = (int)($oi->primary_key ?? 0);
// 1) Kapitel-Zeile (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, $invoicePk, $invoiceDate, $invoiceYear, $chapterId, $ji, true);
}
continue;
}
// 2) Direkte Artikel-Zeile ohne Kapitel (type=0)
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) {
processLineItem($Epi, $rows, $invoicePk, $invoiceDate, $invoiceYear, null, $oi, false);
continue;
}
// Andere Typen ignorieren (Zwischensumme etc.)
}
}
/* =========================
2) Years + Pivot Rows bauen
========================= */
$years = array_keys($allYears);
sort($years);
// Pivot Rows: pro Produkt => revenueDirect / revenueIncl und PeakDirect / PeakIncl + Histogramme
$pivotRows = [];
foreach ($meta as $productPk => $m) {
$productPk = (int)$productPk;
// revenue direct/incl
$sumDirect = 0.0;
$sumIncl = 0.0;
$row = [
'product_pk' => $productPk,
'product_no' => (int)($m['product_no'] ?? 0),
'title' => (string)($m['title'] ?? ''),
'years' => [],
'total_direct'=> 0.0,
'total_incl' => 0.0,
'peak_direct' => 0,
'peak_incl' => 0,
'hist_direct' => [],
'hist_incl' => [],
];
foreach ($years as $y) {
$d = (float)($pivot[$productPk][$y] ?? 0.0);
$b = (float)($pivotB[$productPk][$y] ?? 0.0);
$incl = $d + $b;
$row['years'][$y] = ['direct'=>$d, 'incl'=>$incl];
$sumDirect += $d;
$sumIncl += $incl;
}
$row['total_direct'] = $sumDirect;
$row['total_incl'] = $sumIncl;
// Peak + Histogram
$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 Produkte zeigen, die überhaupt irgendwo vorkommen (direct oder incl)
if ($row['total_direct'] == 0.0 && $row['total_incl'] == 0.0 && $row['peak_direct'] == 0 && $row['peak_incl'] == 0) {
continue;
}
$pivotRows[] = $row;
}
// Sort: nach Total_incl absteigend (inkl Bundle-Anteil ist meist interessanter)
usort($pivotRows, fn($a,$b) => ($b['total_incl'] <=> $a['total_incl']));
/* =========================
3) 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)'];
foreach ($years as $y) {
$cols[] = (string)$y;
$cols[] = (string)$y . ' (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++;
foreach ($years as $y) {
$d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$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++;
}
$rowIdx++;
}
$sheet->setAutoFilter("A{$headerRow}:{$lastColLetter}{$headerRow}");
$sheet->freezePane("A" . ($headerRow + 1));
// Save
$writer = new Xlsx($spreadsheet);
$writer->save($excelFilePath);
/* =========================
4) HTML Ausgabe
========================= */
?>
<!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>
<!-- Bootstrap -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- DataTables -->
<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>
<!-- Chart.js -->
<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; }
</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>
<?php foreach ($years as $y): ?>
<th class="nowrap"><?php echo (int)$y; ?></th>
<?php endforeach; ?>
<th class="nowrap">Histogramm</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'];
?>
<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>
<?php foreach ($years as $y): ?>
<?php
$d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$y]['incl'];
?>
<td class="nowrap">
<?php echo formatEuro($d); ?>
<span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span>
</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>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<small class="text-muted">
Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span> (Rechnungsdatum). Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span>.
Werte in Klammern = inkl. Bundle-Anteil.
</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>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>Umsatz netto</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 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="nowrap"><?php echo formatEuro((float)$r['revenue_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.
</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>
<script>
let histChart = 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() {
$('#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);
});
});
</script>
</body>
</html>