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;
}
?>
ROI – Umsatz pro Artikel / Jahr
Epi Webview
ROI
Umsatz / Peak / Histogramm pro Artikel
Produkt-PK
Artikel-Nr
Artikel
Peak
Summe
davon extern
Histogramm
Slots
$slotsDirect,
'incl' => $slotsIncl,
'direct_ext' => $slotsDirectExt,
'incl_ext' => $slotsInclExt,
];
$slotsB64 = base64Json($slotsPayload);
?>
(' . $peakIncl . ')';
?>
(' . formatEuro($sumIncl) . ')';
?>
(' . formatEuro($extIncl) . ')';
?>
()
Histogramm
Histogramm inkl. Bundle
Slots (Auftragsbasiert)
Jahr-Zuordnung Umsatz: invoice_date .
Peak/Histogramm: Zeitraum date_start..date_end (tagesbasiert).
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles werden zerlegt).
Extern-Umsatz: sum_total_net * (amount_external/amount_total) (amount_external ist Menge).
Slots: auftragsbasierte Slotbelegung nach Start, dann Ende DESC, dann Auftragsnr.
Jahr
Invoice
Rechnungsnr
Rechnungsdatum
Quelle
Chapter
Produkt-PK
Artikel-Nr
Artikel
Start
Ende
Menge
Ext-Menge
Umsatz netto
Umsatz extern
Wenn ein Artikel “ohne Kapitel” fehlt, muss er hier als Quelle direct auftauchen.
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet.
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) | Y-Achse: Tage
Slot-Umsatz ist auftragsbasiert : Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt.
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles).
Slot
Umsatz
Umsatz inkl. Bundle
davon extern
davon extern inkl. Bundle