Files
EpiWebview/dist/ROI.php

933 lines
35 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
// 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>