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 ========================= */ ?> ROI – Umsatz pro Artikel / Jahr

ROI

Pivot: Artikel × Rechnungsjahr
Export nach Excel
Produkt-PK Artikel-Nr Artikel Peak Summe Histogramm
(' . $peakIncl . ')'; ?> (' . formatEuro($sumIncl) . ')'; ?> ()
Jahr-Zuordnung Umsatz: invoice_date (Rechnungsdatum). Peak/Histogramm: Zeitraum date_start..date_end. Werte in Klammern = inkl. Bundle-Anteil.
Debug: verarbeitete Mietpositionen
Jahr Invoice Rechnungsdatum Quelle Chapter Produkt-PK Artikel-Nr Artikel Start Ende Menge Umsatz netto
Wenn ein Artikel “ohne Kapitel” fehlt, muss er hier als Quelle direct auftauchen.