0 && $filterTo > 0 && $filterTo < $filterFrom) { [$filterFrom, $filterTo] = [$filterTo, $filterFrom]; } $computeRoi = in_array($filterMode, ['range', 'all'], true); if ($filterMode === 'range' && ($filterFrom <= 0 || $filterTo <= 0)) { $computeRoi = false; $filterMode = ''; } /* ========================= ROI-Cache: Konfiguration & Zeitraum-Auflösung – Aktiv, wenn ROI_Cache_YearRange in der Config definiert ist. – Semantik: negative Zahl -N → alles bis inkl. currentYear-|N| (also älter oder gleich) 0 → nur aktuelles Jahr (bewusst; ändert sich täglich) >= 1900 → nur exakt dieses Kalenderjahr ========================= */ $cacheEnabled = defined('ROI_Cache_YearRange'); $cacheFrom = null; // int|null (null = -unendlich, kein untere Grenze) $cacheTo = null; // int|null (null = +unendlich, keine obere Grenze) $cacheDir = __DIR__ . '/cache'; $cacheFile = $cacheDir . '/roi_mandant_' . (defined('Epirent_Mandant') ? (string)Epirent_Mandant : '0') . '.cache'; $cacheRefreshRequested = isset($_GET['refresh_cache']) && (string)$_GET['refresh_cache'] === '1'; /** * Parst die Cache-Range-Konfiguration. * Unterstützte Formate: * Integer: -N (relative Vorjahre), 0 (aktuelles), >=1900 (konkretes Jahr) * String: "YYYY", ">=YYYY", "YYYY-YYYY", oder numerisch * Return: ['from' => int|null, 'to' => int|null] oder null wenn nicht parsbar. */ function parseCacheRangeConfig($val, int $currentYear): ?array { if (is_int($val) || (is_string($val) && preg_match('/^-?\d+$/', trim((string)$val)))) { $r = (int)$val; if ($r >= 1900) return ['from' => $r, 'to' => $r]; return ['from' => null, 'to' => $currentYear + $r]; // r <= 0 } if (is_string($val)) { $v = trim($val); if (preg_match('/^<(=?)\s*(\d{4})$/', $v, $m)) { $incl = ($m[1] === '='); $y = (int)$m[2]; return ['from' => null, 'to' => $incl ? $y : $y - 1]; } if (preg_match('/^>(=?)\s*(\d{4})$/', $v, $m)) { $incl = ($m[1] === '='); $y = (int)$m[2]; return ['from' => $incl ? $y : $y + 1, 'to' => null]; } if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $v, $m)) { $a = (int)$m[1]; $b = (int)$m[2]; if ($a > $b) [$a, $b] = [$b, $a]; return ['from' => $a, 'to' => $b]; } } return null; } if ($cacheEnabled) { $currentYear = (int)date('Y'); $parsed = parseCacheRangeConfig(ROI_Cache_YearRange, $currentYear); if ($parsed === null) { // Ungültiger Wert → Cache deaktivieren, damit die Seite nicht kaputt geht $cacheEnabled = false; } else { $cacheFrom = $parsed['from']; $cacheTo = $parsed['to']; } } if ($cacheEnabled) { if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } // .htaccess: Cache-Ordner nicht direkt aus dem Web zugänglich machen if (is_dir($cacheDir) && !file_exists($cacheDir . '/.htaccess')) { @file_put_contents($cacheDir . '/.htaccess', "Require all denied\nDeny from all\n"); } // .gitignore, damit die Cache-Dateien nicht ins Repo wandern if (is_dir($cacheDir) && !file_exists($cacheDir . '/.gitignore')) { @file_put_contents($cacheDir . '/.gitignore', "*\n!.gitignore\n!.htaccess\n"); } } /** * Prüft, ob ein User-Filter komplett vom Cache abgedeckt wird. * $cf = cacheFrom (int|null), $ct = cacheTo (int|null), $uf = userFrom (int), $ut = userTo (int) */ function filterCoveredByCache(?int $cf, ?int $ct, int $uf, int $ut): bool { if ($ct !== null && $ut > $ct) return false; if ($cf !== null && $uf < $cf) return false; return true; } /** * Normalisiert eine Liste von Ranges: sortiert, mergt überlappende und angrenzende Bereiche. * Ranges: [ ['from' => int|null, 'to' => int|null], ... ] * null bedeutet -∞ (from) bzw. +∞ (to). */ function normalizeCachedRanges(array $ranges): array { $ranges = array_values(array_filter($ranges, fn($r) => is_array($r))); usort($ranges, function($a, $b) { $af = $a['from'] ?? PHP_INT_MIN; $bf = $b['from'] ?? PHP_INT_MIN; return $af <=> $bf; }); $out = []; foreach ($ranges as $r) { $rf = $r['from'] ?? null; $rt = $r['to'] ?? null; if (empty($out)) { $out[] = ['from' => $rf, 'to' => $rt]; continue; } $last = &$out[count($out) - 1]; $lastToNum = $last['to'] ?? PHP_INT_MAX; $curFromNum = $rf ?? PHP_INT_MIN; if ($curFromNum <= $lastToNum + 1) { if ($rt === null || $lastToNum < ($rt ?? PHP_INT_MAX)) { $last['to'] = $rt; } } else { $out[] = ['from' => $rf, 'to' => $rt]; } unset($last); } return $out; } function addToCachedRanges(array $ranges, ?int $from, ?int $to): array { $ranges[] = ['from' => $from, 'to' => $to]; return normalizeCachedRanges($ranges); } function isYearCovered(array $cachedRanges, int $year): bool { foreach ($cachedRanges as $r) { $f = $r['from'] ?? PHP_INT_MIN; $t = $r['to'] ?? PHP_INT_MAX; if ($year >= $f && $year <= $t) return true; } return false; } /** * Berechnet innerhalb [askFrom, askTo] die zusammenhängenden Jahres-Segmente, * die NICHT von $cachedRanges abgedeckt sind. Return: [['from', 'to'], ...]. */ function computeMissingRanges(array $cachedRanges, int $askFrom, int $askTo): array { if ($askFrom > $askTo) return []; $missing = []; $y = $askFrom; while ($y <= $askTo) { if (!isYearCovered($cachedRanges, $y)) { $start = $y; while ($y <= $askTo && !isYearCovered($cachedRanges, $y)) $y++; $missing[] = ['from' => $start, 'to' => $y - 1]; } else { $y++; } } return $missing; } /** * Formatiert cached_ranges für die UI als lesbaren String, z.B. "2017–2020, 2022–2025" * oder "−∞–2025". */ function formatCachedRangesHuman(array $cachedRanges): string { if (empty($cachedRanges)) return '—'; $parts = []; foreach ($cachedRanges as $r) { $f = $r['from'] === null ? '−∞' : (string)(int)$r['from']; $t = $r['to'] === null ? '+∞' : (string)(int)$r['to']; $parts[] = ($f === $t) ? $f : ($f . '–' . $t); } return implode(', ', $parts); } const ROI_CACHE_VERSION = 4; /** * Cache atomar schreiben (temp-file + rename). * Speichert die ROHDATEN aus dem Invoice-Loop, damit sich beim Load Cache + Live-Anteil * (für Jahre > cacheTo) sauber zusammenführen lassen. Aggregate/Slot-Berechnung passiert * IMMER erst nach dem Load auf den kombinierten Daten. */ function saveRoiCache(string $path, ?int $cacheFrom, ?int $cacheTo, array $cachedRanges): bool { global $rows, $slotItemsDirect, $slotItemsIncl, $meta, $pivot, $pivotB, $pivotExt, $pivotExtB, $eventsDirect, $eventsIncl, $customerAgg, $allYears, $productCache, $bundleLeafCache, $rentPriceCache, $journalCache, $orderCustomerCache; $data = [ 'ts' => time(), 'cache_from' => $cacheFrom, // Config-Wert (informational) 'cache_to' => $cacheTo, // Config-Wert (informational) 'cached_ranges' => $cachedRanges, // welche Jahre tatsächlich abgedeckt sind 'roi_ver' => ROI_CACHE_VERSION, // Rohdaten aus dem Invoice-Loop 'rows' => $rows, 'slotItemsDirect' => $slotItemsDirect, 'slotItemsIncl' => $slotItemsIncl, 'meta' => $meta, 'pivot' => $pivot, 'pivotB' => $pivotB, 'pivotExt' => $pivotExt, 'pivotExtB' => $pivotExtB, 'eventsDirect' => $eventsDirect, 'eventsIncl' => $eventsIncl, 'customerAgg' => $customerAgg, 'allYears' => $allYears, // API-Caches – sparen beim Live-Loop viele Detail-Requests 'productCache' => $productCache, 'bundleLeafCache' => $bundleLeafCache, 'rentPriceCache' => $rentPriceCache, 'journalCache' => $journalCache, 'orderCustomerCache' => $orderCustomerCache, ]; $blob = serialize($data); $tmp = $path . '.tmp.' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, $blob, LOCK_EX) === false) return false; if (!@rename($tmp, $path)) { @unlink($tmp); return false; } return true; } /** * Cache laden und alle globalen Datenstrukturen befüllen. * Return: ['ts', 'cache_from', 'cache_to'] wenn erfolgreich, null sonst. */ function loadRoiCache(string $path): ?array { global $rows, $slotItemsDirect, $slotItemsIncl, $meta, $pivot, $pivotB, $pivotExt, $pivotExtB, $eventsDirect, $eventsIncl, $customerAgg, $allYears, $productCache, $bundleLeafCache, $rentPriceCache, $journalCache, $orderCustomerCache; $blob = @file_get_contents($path); if ($blob === false || $blob === '') return null; $data = @unserialize($blob); if (!is_array($data) || (int)($data['roi_ver'] ?? 0) !== ROI_CACHE_VERSION) return null; $rows = $data['rows'] ?? []; $slotItemsDirect = $data['slotItemsDirect'] ?? []; $slotItemsIncl = $data['slotItemsIncl'] ?? []; $meta = $data['meta'] ?? []; $pivot = $data['pivot'] ?? []; $pivotB = $data['pivotB'] ?? []; $pivotExt = $data['pivotExt'] ?? []; $pivotExtB = $data['pivotExtB'] ?? []; $eventsDirect = $data['eventsDirect'] ?? []; $eventsIncl = $data['eventsIncl'] ?? []; $customerAgg = $data['customerAgg'] ?? []; $allYears = $data['allYears'] ?? []; $productCache = $data['productCache'] ?? []; $bundleLeafCache = $data['bundleLeafCache'] ?? []; $rentPriceCache = $data['rentPriceCache'] ?? []; $journalCache = $data['journalCache'] ?? []; $orderCustomerCache = $data['orderCustomerCache'] ?? []; return [ 'ts' => (int)($data['ts'] ?? 0), 'cache_from' => $data['cache_from'] ?? null, 'cache_to' => $data['cache_to'] ?? null, 'cached_ranges' => is_array($data['cached_ranges'] ?? null) ? $data['cached_ranges'] : [], ]; } /** * Liest nur die Cache-Meta ohne die Rohdaten in die Globals zu schreiben. * Nützlich, um vor dem eigentlichen Load zu entscheiden, ob überhaupt geladen werden muss. */ function peekRoiCacheMeta(string $path): ?array { $blob = @file_get_contents($path); if ($blob === false || $blob === '') return null; $data = @unserialize($blob); if (!is_array($data) || (int)($data['roi_ver'] ?? 0) !== ROI_CACHE_VERSION) return null; return [ 'ts' => (int)($data['ts'] ?? 0), 'cache_from' => $data['cache_from'] ?? null, 'cache_to' => $data['cache_to'] ?? null, 'cached_ranges' => is_array($data['cached_ranges'] ?? null) ? $data['cached_ranges'] : [], ]; } /** * Filtert die ROHDATEN in den globalen Aggregations-Strukturen auf einen Jahres-Range. * Diese Funktion wird VOR der Aggregation aufgerufen, damit Peak/Histogramm/Slots/Kunden * automatisch nur den gewünschten Zeitraum abbilden. * $from = 0 → kein Untergrenze; $to = 0 → keine Obergrenze. */ function applyRangeFilterToRawData(int $from, int $to): void { global $rows, $slotItemsDirect, $slotItemsIncl, $pivot, $pivotB, $pivotExt, $pivotExtB, $eventsDirect, $eventsIncl, $customerAgg, $allYears; $yearMatches = function(int $y) use ($from, $to): bool { if ($y <= 0) return false; if ($from > 0 && $y < $from) return false; if ($to > 0 && $y > $to) return false; return true; }; $ymdMatches = function(string $ymd) use ($yearMatches): bool { if (!$ymd || strlen($ymd) < 4) return false; return $yearMatches((int)substr($ymd, 0, 4)); }; // Debug-Rows: nach year $rows = array_values(array_filter($rows, fn($r) => $yearMatches((int)($r['year'] ?? 0)))); // Pivot-Aggregate: Jahr-Schlüssel filtern foreach ([&$pivot, &$pivotB, &$pivotExt, &$pivotExtB] as &$agg) { foreach ($agg as $pk => &$yearMap) { foreach (array_keys($yearMap) as $y) { if (!$yearMatches((int)$y)) unset($yearMap[$y]); } } unset($yearMap); } unset($agg); // allYears foreach (array_keys($allYears) as $y) { if (!$yearMatches((int)$y)) unset($allYears[$y]); } // Peak/Histogramm-Events (ymd-basiert) foreach ([&$eventsDirect, &$eventsIncl] as &$ev) { foreach ($ev as $pk => &$ymdMap) { foreach (array_keys($ymdMap) as $ymd) { if (!$ymdMatches((string)$ymd)) unset($ymdMap[$ymd]); } } unset($ymdMap); } unset($ev); // Slot-Items: nach start_ymd year foreach ([&$slotItemsDirect, &$slotItemsIncl] as &$sit) { foreach ($sit as $pk => &$items) { $items = array_values(array_filter($items, fn($it) => $ymdMatches((string)($it['start_ymd'] ?? '')))); } unset($items); } unset($sit); // Customer-Aggregation: by_year filtern foreach ($customerAgg as $pk => &$byCustomer) { foreach ($byCustomer as $cpk => &$c) { if (isset($c['by_year'])) { foreach (array_keys($c['by_year']) as $y) { if (!$yearMatches((int)$y)) unset($c['by_year'][$y]); } } } unset($c); } unset($byCustomer); } /* ========================= Helpers ========================= */ function formatEuro(float $amount): string { $formatted = number_format(abs($amount), 2, ',', '.') . ' €'; return $amount < 0 ? '-' . $formatted : $formatted; } function yearFromDate(?string $ymd): ?int { if (!$ymd || $ymd === '0000-00-00') return null; return (int)substr($ymd, 0, 4); } function safeYmd(?string $ymd): ?string { if (!$ymd || $ymd === '0000-00-00') return null; if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $ymd)) return null; return $ymd; } function ymdToTs(string $ymd): int { return (new DateTime($ymd . ' 00:00:00', new DateTimeZone('Europe/Berlin')))->getTimestamp(); } function tsToYmd(int $ts): string { return (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('Europe/Berlin'))->format('Y-m-d'); } function base64Json($data): string { return base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } function apiJsonDecode($raw) { if (is_object($raw)) { if (method_exists($raw, 'getContents')) { $raw = $raw->getContents(); } else { $raw = (string)$raw; } } if (!is_string($raw)) $raw = (string)$raw; return json_decode($raw); } /* ========================= API Caches ========================= */ $productCache = []; // [pk => productObj] $bundleLeafCache = []; // [bundlePk => [leafPk => amount]] $rentPriceCache = []; // [pk => float] function getProduct(Epirent $Epi, int $productPk) { global $productCache; if ($productPk <= 0) return null; if (isset($productCache[$productPk])) return $productCache[$productPk]; $res = apiJsonDecode($Epi->requestEpiApi('/v1/product/' . $productPk . '?cl=' . Epirent_Mandant)); $prod = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null; $productCache[$productPk] = $prod; return $prod; } function getRentPrice(Epirent $Epi, int $productPk): float { global $rentPriceCache; if (isset($rentPriceCache[$productPk])) return $rentPriceCache[$productPk]; $p = getProduct($Epi, $productPk); $price = 0.0; if ($p && isset($p->pricing) && isset($p->pricing->price_rent)) { $price = (float)$p->pricing->price_rent; } $rentPriceCache[$productPk] = $price; return $price; } /** * WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles. * Ergebnis: [leafProductPk => amount] */ 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; } /* ========================= Customer Cache – Zieht den Kunden aus dem Invoice-Objekt, fällt bei Bedarf auf den Order-Endpoint zurück. – Ergebnis pro Order gecached, damit mehrere Rechnungen desselben Auftrags nur 1x API-Kosten haben. ========================= */ $orderCustomerCache = []; // [orderPk => ['pk'=>int, 'name'=>string]] function resolveInvoiceCustomer(Epirent $Epi, $invObj, int $orderPk): array { global $orderCustomerCache; $pk = 0; $name = ''; $pickInt = function($obj, array $fields) { foreach ($fields as $f) { $v = (int)($obj->{$f} ?? 0); if ($v > 0) return $v; } return 0; }; $pickStr = function($obj, array $fields) { foreach ($fields as $f) { $v = trim((string)($obj->{$f} ?? '')); if ($v !== '') return $v; } return ''; }; if (is_object($invObj)) { $pk = $pickInt($invObj, ['customer_pk','contact_pk','pk_customer','kunde_pk']); $name = $pickStr($invObj, ['customer_name','contact_name','kunde_name','name_customer']); foreach (['address_invoice','address_delivery','customer','contact'] as $af) { if ($pk > 0 && $name !== '') break; $sub = $invObj->{$af} ?? null; if (is_object($sub)) { if ($pk <= 0) $pk = $pickInt($sub, ['contact_pk','primary_key','pk','id']); if ($name === '') $name = $pickStr($sub, ['company','name','display_name','name1']); } } } if (($pk <= 0 || $name === '') && $orderPk > 0) { if (!array_key_exists($orderPk, $orderCustomerCache)) { $res = apiJsonDecode($Epi->requestEpiApi('/v1/order/' . $orderPk . '?cl=' . Epirent_Mandant)); $ord = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null; $c = ['pk' => 0, 'name' => '']; if (is_object($ord)) { $c['pk'] = $pickInt($ord, ['customer_pk','contact_pk','pk_customer']); $c['name'] = $pickStr($ord, ['customer_name','contact_name']); foreach (['address_invoice','address_delivery','customer','contact'] as $af) { if ($c['pk'] > 0 && $c['name'] !== '') break; $sub = $ord->{$af} ?? null; if (is_object($sub)) { if ($c['pk'] <= 0) $c['pk'] = $pickInt($sub, ['contact_pk','primary_key','pk','id']); if ($c['name'] === '') $c['name'] = $pickStr($sub, ['company','name','display_name','name1']); } } } $orderCustomerCache[$orderPk] = $c; } $c = $orderCustomerCache[$orderPk]; if ($pk <= 0) $pk = (int)$c['pk']; if ($name === '') $name = (string)$c['name']; } if ($pk <= 0) $pk = 0; if ($name === '') $name = $pk > 0 ? ('Kunde #' . $pk) : 'Unbekannt'; return ['pk' => $pk, 'name' => $name]; } /* ========================= 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 ========================= */ $eventsDirect = []; // eventsDirect[productPk][ymd] += deltaQty $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]; // clamp, damit kein negativer Bestand "Tage" erzeugt 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 = []; // Kunden-Aggregation pro Produkt: [productPk][customerPk] = ['pk','name','direct','incl','ext_direct','ext_incl'] // Semantik analog zu $pivot / $pivotB: // direct = Umsatz aus Rechnungen die diesen productPk DIREKT abgerechnet haben // incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist $customerAgg = []; function addCustomerRevenue(int $productPk, array $customer, int $year, float $direct, float $incl, float $extDirect, float $extIncl): void { global $customerAgg; if ($productPk <= 0 || $year <= 0) return; if ($direct == 0.0 && $incl == 0.0 && $extDirect == 0.0 && $extIncl == 0.0) return; $cpk = (int)($customer['pk'] ?? 0); $name = (string)($customer['name'] ?? ''); if (!isset($customerAgg[$productPk])) $customerAgg[$productPk] = []; if (!isset($customerAgg[$productPk][$cpk])) { $customerAgg[$productPk][$cpk] = [ 'pk' => $cpk, 'name' => $name, // Aufschlüsselung pro Jahr, damit Zeitraum-Filter sauber angewendet werden können 'by_year' => [], ]; } elseif ($customerAgg[$productPk][$cpk]['name'] === '' && $name !== '') { $customerAgg[$productPk][$cpk]['name'] = $name; } if (!isset($customerAgg[$productPk][$cpk]['by_year'][$year])) { $customerAgg[$productPk][$cpk]['by_year'][$year] = [ 'direct' => 0.0, 'incl' => 0.0, 'ext_direct' => 0.0, 'ext_incl' => 0.0, ]; } $customerAgg[$productPk][$cpk]['by_year'][$year]['direct'] += $direct; $customerAgg[$productPk][$cpk]['by_year'][$year]['incl'] += $incl; $customerAgg[$productPk][$cpk]['by_year'][$year]['ext_direct'] += $extDirect; $customerAgg[$productPk][$cpk]['by_year'][$year]['ext_incl'] += $extIncl; } function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title, string $invoiceDate = ''): 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 ?? ''), // titles: [title => letztes invoice_date, an dem dieser Titel auftauchte] 'titles' => [], ]; } // Titel-Historie: nur Titel erfassen, die tatsächlich auf einer Rechnung standen, // damit Umbenennungen (gleiche Artikelnummer, neuer Name) sichtbar bleiben. if ($title !== '' && $invoiceDate !== '') { $prev = $meta[$productPk]['titles'][$title] ?? ''; if ($invoiceDate > $prev) { $meta[$productPk]['titles'][$title] = $invoiceDate; } } } 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) */ 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) – Storno-sicher (SIGNED) ========================= */ /** * Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt) * qty kann positiv oder negativ sein (negativ = Storno/Rückbuchung) */ function addSlotItem( array &$slotItems, int $productPk, int $invoicePk, int $orderPk, int $invoiceNo, 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.0) return; if (!isset($slotItems[$productPk])) $slotItems[$productPk] = []; $slotItems[$productPk][] = [ 'product_pk' => $productPk, 'invoice_pk' => $invoicePk, 'order_pk' => $orderPk, 'invoice_no' => $invoiceNo, 'start_ts' => ymdToTs($startYmd), 'end_ts' => ymdToTs($endYmd), 'qty' => (float)$qty, // signed erlaubt 'rev' => (float)$revenueNet, 'rev_ext' => (float)$extRevenueNet, 'start_ymd' => $startYmd, 'end_ymd' => $endYmd, ]; } /** * Slotting (SIGNED, Storno gibt Slots wieder frei): * - Positive qty belegt Slots. * - Negative qty bucht Umsatz zurück UND gibt Slots frei. * - Priorität bei Storno: zuerst Slots mit gleichem Zeitraum (gleicher release_ts) abbauen. * * Ergebnis: * - 'direct' => [slot => revenue] * - 'direct_ext' => [slot => extRevenue] */ function computeOrderBasedSlots(array $itemsForProduct): array { if (empty($itemsForProduct)) { return ['direct' => [], 'direct_ext' => []]; } // Sortierung: start ASC, end DESC, invoice_no ASC, invoice_pk ASC usort($itemsForProduct, function($a, $b){ if (($a['start_ts'] ?? 0) !== ($b['start_ts'] ?? 0)) return ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0); if (($a['end_ts'] ?? 0) !== ($b['end_ts'] ?? 0)) return ($b['end_ts'] ?? 0) <=> ($a['end_ts'] ?? 0); if (($a['invoice_no'] ?? 0) !== ($b['invoice_no'] ?? 0)) return ($a['invoice_no'] ?? 0) <=> ($b['invoice_no'] ?? 0); return ($a['invoice_pk'] ?? 0) <=> ($b['invoice_pk'] ?? 0); }); // Active slots: slotIndex => release_ts $active = []; // release_ts => [slotIndex, ...] $activeByRelease = []; // free slot indices $freeSlots = []; // Revenues per slot $slotRevenue = []; $slotRevenueExt = []; $releaseTsFor = function(int $endTs): int { // Ende inklusiv -> frei am Folgetag 00:00 return $endTs + 86400; }; $freeExpired = function(int $currentStartTs) use (&$active, &$activeByRelease, &$freeSlots) { if (empty($active)) return; foreach ($active as $slot => $relTs) { if ($relTs <= $currentStartTs) { unset($active[$slot]); if (isset($activeByRelease[$relTs])) { $activeByRelease[$relTs] = array_values(array_filter( $activeByRelease[$relTs], fn($s) => (int)$s !== (int)$slot )); if (empty($activeByRelease[$relTs])) unset($activeByRelease[$relTs]); } $freeSlots[] = (int)$slot; } } }; $takeFreeSlot = function() use (&$freeSlots, &$active): int { if (!empty($freeSlots)) { sort($freeSlots); return (int)array_shift($freeSlots); } if (empty($active)) return 1; $keys = array_keys($active); return (int)(max($keys) + 1); }; foreach ($itemsForProduct as $it) { $startTs = (int)($it['start_ts'] ?? 0); $endTs = (int)($it['end_ts'] ?? 0); if ($startTs <= 0 || $endTs <= 0) continue; if ($endTs < $startTs) continue; // vor jeder Aktion: abgelaufene Slots freigeben $freeExpired($startTs); $qtySigned = (float)($it['qty'] ?? 0.0); if ($qtySigned == 0.0) continue; $qtyAbs = (int)round(abs($qtySigned)); if ($qtyAbs <= 0) continue; $rev = (float)($it['rev'] ?? 0.0); $ext = (float)($it['rev_ext'] ?? 0.0); $revPer = $rev / $qtyAbs; $extPer = $ext / $qtyAbs; $relTs = $releaseTsFor($endTs); if ($qtySigned > 0) { // --- Belegen --- for ($k = 0; $k < $qtyAbs; $k++) { $slot = $takeFreeSlot(); $active[$slot] = $relTs; if (!isset($activeByRelease[$relTs])) $activeByRelease[$relTs] = []; $activeByRelease[$relTs][] = $slot; if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; $slotRevenue[$slot] += $revPer; $slotRevenueExt[$slot] += $extPer; } } else { // --- Storno / Freigeben --- // erst gleiche release_ts abbauen (damit "Rechnung+Storno im gleichen Zeitraum" sauber 0 Slots macht) $candidates = $activeByRelease[$relTs] ?? []; for ($k = 0; $k < $qtyAbs; $k++) { $slot = null; if (!empty($candidates)) { rsort($candidates); // höchste Slotnummer zuerst abbauen $slot = (int)array_shift($candidates); } else { if (!empty($active)) { $keys = array_keys($active); rsort($keys); $slot = (int)$keys[0]; } } if ($slot === null) break; // Umsatz auf Slot zurückbuchen if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; $slotRevenue[$slot] += $revPer; // revPer ist i.d.R. negativ $slotRevenueExt[$slot] += $extPer; // extPer ist i.d.R. negativ // Slot freigeben $oldRel = $active[$slot] ?? null; unset($active[$slot]); if ($oldRel !== null && isset($activeByRelease[$oldRel])) { $activeByRelease[$oldRel] = array_values(array_filter( $activeByRelease[$oldRel], fn($s) => (int)$s !== (int)$slot )); if (empty($activeByRelease[$oldRel])) unset($activeByRelease[$oldRel]); } $freeSlots[] = $slot; } } } // --- Cleanup: Slots ohne Umsatz entfernen + neu durchnummerieren --- $eps = 0.00001; $allSlots = array_unique(array_merge(array_keys($slotRevenue), array_keys($slotRevenueExt))); sort($allSlots); $kept = []; foreach ($allSlots as $s) { $rev = (float)($slotRevenue[$s] ?? 0.0); $ext = (float)($slotRevenueExt[$s] ?? 0.0); if (abs($rev) < $eps && abs($ext) < $eps) continue; $kept[] = $s; } $newRev = []; $newExt = []; $idx = 1; foreach ($kept as $old) { $newRev[$idx] = (float)($slotRevenue[$old] ?? 0.0); $newExt[$idx] = (float)($slotRevenueExt[$old] ?? 0.0); $idx++; } return ['direct' => $newRev, 'direct_ext' => $newExt]; } /* ========================= Prozessierung einer Artikelzeile ========================= */ function processLineItem( Epirent $Epi, array &$rows, array &$slotItemsDirect, array &$slotItemsIncl, int $orderPk, int $invoicePk, int $invoiceNo, string $invoiceDate, int $invoiceYear, ?int $chapterId, object $li, bool $isFromJournal, bool $invoiceIsCredit, // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0) array $customer = ['pk'=>0, 'name'=>'Unbekannt'] ): 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); $qtyRaw = (float)($li->amount_total ?? 0); if ($qtyRaw == 0.0) $qtyRaw = 0.0; // Umsatz bleibt wie geliefert (kann negativ sein) $revenueNet = (float)($li->sum_total_net ?? 0); $extRevenueNet = calcExternalRevenueNet($li); // Für Auslastung/Slots: wenn Storno-Rechnung, Menge "negativ signieren" // (weil Epi bei Storno typischerweise Menge positiv lässt, aber Preise negativ macht) $qtySigned = $qtyRaw; if ($invoiceIsCredit && $qtySigned != 0.0) { $qtySigned = -abs($qtySigned); } $dateStart = safeYmd((string)($li->date_start ?? '')) ?: null; $dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null; ensureMeta($Epi, $productPk, $productNo, $title, $invoiceDate); // 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); // Kunden-Aggregation für direkten Umsatz (direct wird IMMER auch in incl gezählt) if ($year && ($revenueNet != 0.0 || $extRevenueNet != 0.0)) { addCustomerRevenue($productPk, $customer, (int)$year, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet); } // Peak/Histogram direct + incl (tagesbasiert) // -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt if ($dateStart && $dateEnd && $qtySigned != 0.0) { addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qtySigned); addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qtySigned); } // Slotting: direct (signed qty) if ($dateStart && $dateEnd && $qtySigned != 0.0) { addSlotItem($slotItemsDirect, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet); addSlotItem($slotItemsIncl, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $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' => $qtyRaw, 'qty_signed' => $qtySigned, 'revenue_net' => $revenueNet, 'ext_rev_net' => $extRevenueNet, 'amount_ext' => (float)($li->amount_external ?? 0), 'amount_total'=> (float)($li->amount_total ?? 0), 'is_credit' => $invoiceIsCredit ? 1 : 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) $allocForCustomer = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; $allocExtForCustomer = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; if ($revenueNet != 0.0 && $year) { foreach ($allocForCustomer 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) { foreach ($allocExtForCustomer as $leafPk => $leafRevenue) { $leafPk = (int)$leafPk; ensureMeta($Epi, $leafPk, 0, ''); addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue); } } // Kunden-Aggregation für Leafs (nur "incl", direct ist 0, da nicht direkt abgerechnet) if ($year && (!empty($allocForCustomer) || !empty($allocExtForCustomer))) { $leafKeys = array_unique(array_merge(array_keys($allocForCustomer), array_keys($allocExtForCustomer))); foreach ($leafKeys as $leafPk) { $leafPk = (int)$leafPk; if ($leafPk <= 0) continue; $leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0); $leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0); addCustomerRevenue($leafPk, $customer, (int)$year, 0.0, $leafRev, 0.0, $leafExt); } } // Auslastung allokieren (Peak/Histogramm inkl Bundle) – signed qty! if ($dateStart && $dateEnd && $qtySigned != 0.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, $qtySigned * $amt); } } // Slotting allokieren (inkl Bundle) – signed qty! if ($dateStart && $dateEnd && $qtySigned != 0.0) { // Bundle-Allokationen aus dem Umsatz-Block wiederverwenden foreach ($leafMap as $leafPk => $amt) { $leafPk = (int)$leafPk; $amt = (float)$amt; if ($leafPk <= 0 || $amt <= 0) continue; $leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0); $leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0); $qtyLeafSigned = $qtySigned * $amt; ensureMeta($Epi, $leafPk, 0, ''); addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtyLeafSigned, $leafRev, $leafExt); } } } } /* ========================= 1) Invoices holen + Daten sammeln ========================= */ // Defaults für die HTML-Ausgabe, wenn keine Berechnung läuft $years = []; $pivotRows = []; $excelFileName = ''; $excelDownloadUrl = ''; /* ========================= Cache-Entscheidung (inkrementell) – Cache-Datei enthält Rohdaten + eine Liste "cached_ranges" (welche Jahre bereits gecacht). – Bei einer Abfrage werden nur die Jahre im Cache-Config-Range gefetcht, die NOCH NICHT abgedeckt sind. Diese neuen Jahre werden anschließend an den Cache angehängt. – Jahre außerhalb der Cache-Config-Range werden IMMER live geladen (nie in den Cache). – Reset per Reload-Button (?refresh_cache=1) wirft die Cache-Datei komplett weg und baut sie im vollen Cache-Config-Range neu. Setzt: $loadedFromCache, $rebuildCache, $doLiveLoop, $liveFrom, $liveTo, $missingCacheRanges (Liste von Ranges, die ins Cache nachgeladen werden), $cachedRanges (bereits abgedeckte Ranges, wird beim Loop erweitert). ========================= */ $loadedFromCache = false; $rebuildCache = false; $doLiveLoop = false; $liveFrom = 0; $liveTo = 0; $cacheDataMeta = null; $cacheFilterApplied = null; $cachedRanges = []; // aktuell abgedeckte Bereiche $missingCacheRanges = []; // Bereiche, die noch fetched werden müssen $liveBelowFrom = 0; $liveBelowTo = 0; $doLiveLoopBelow = false; if ($cacheEnabled) { $cacheExists = file_exists($cacheFile); // Cache-Meta vorab lesen (Config-Wechsel-Erkennung, cached_ranges) if ($cacheExists) { $peek = peekRoiCacheMeta($cacheFile); if ($peek !== null) { $cachedRanges = $peek['cached_ranges']; } else { // veraltet/defekt → wie kein Cache $cacheExists = false; } } // Anzeige-Zeitraum bestimmen (0 = kein Limit) if ($filterMode === 'range') { $viewFrom = (int)$filterFrom; $viewTo = (int)$filterTo; } else { $viewFrom = 0; $viewTo = 0; } if ($cacheRefreshRequested) { // Kompletter Neu-Build: alten Cache verwerfen, konfigurierte Cache-Range komplett fetchen $rebuildCache = true; $cachedRanges = []; $missingCacheRanges = [['from' => $cacheFrom, 'to' => $cacheTo]]; // Live-Anteile (immer) if ($cacheTo !== null && ($viewTo === 0 || $viewTo > $cacheTo)) { $liveFrom = $cacheTo + 1; $liveTo = $viewTo; $doLiveLoop = true; } if ($cacheFrom !== null && $viewFrom > 0 && $viewFrom < $cacheFrom) { $liveBelowFrom = $viewFrom; $liveBelowTo = min($cacheFrom - 1, $viewTo > 0 ? $viewTo : $cacheFrom - 1); $doLiveLoopBelow = true; } $computeRoi = true; } elseif ($computeRoi) { // Cache-Interested Range = User-Range ∩ Cache-Config-Range // ("null" auf Cache-Seite bedeutet unbeschränkt) $askFromCache = ($cacheFrom !== null && ($viewFrom === 0 || $viewFrom < $cacheFrom)) ? $cacheFrom : (($viewFrom === 0) ? null : $viewFrom); $askToCache = ($cacheTo !== null && ($viewTo === 0 || $viewTo > $cacheTo)) ? $cacheTo : (($viewTo === 0) ? null : $viewTo); $hasCachePart = true; if ($askFromCache !== null && $askToCache !== null && $askFromCache > $askToCache) { $hasCachePart = false; } // Cache-Config-Range komplett unterhalb der Anzeige? if ($cacheTo !== null && $viewFrom > 0 && $viewFrom > $cacheTo) $hasCachePart = false; // Cache-Config-Range komplett oberhalb der Anzeige? if ($cacheFrom !== null && $viewTo > 0 && $viewTo < $cacheFrom) $hasCachePart = false; if ($hasCachePart) { $loadedFromCache = true; // wird geladen (auch wenn Datei leer ist) // Fehlende Jahre im Cache-Anteil bestimmen if ($askFromCache !== null && $askToCache !== null) { // Beide Grenzen bounded → Set-Filter für Missing Years $missingCacheRanges = computeMissingRanges($cachedRanges, (int)$askFromCache, (int)$askToCache); } else { // Mindestens eine Seite unbounded → wir müssen einen Range-Loop machen. // Falls bereits ein Range im Cache existiert der uns komplett abdeckt: keine Aktion. $needFetch = true; foreach ($cachedRanges as $r) { $rf = $r['from'] ?? null; $rt = $r['to'] ?? null; $covers = true; if ($askFromCache !== null && ($rf !== null && $rf > $askFromCache)) $covers = false; if ($askToCache !== null && ($rt !== null && $rt < $askToCache)) $covers = false; if ($askFromCache === null && $rf !== null) $covers = false; if ($askToCache === null && $rt !== null) $covers = false; if ($covers) { $needFetch = false; break; } } if ($needFetch) { $missingCacheRanges = [['from' => $askFromCache, 'to' => $askToCache]]; } } } // Live-Anteile berechnen (immer live, nie gecacht) if ($cacheTo !== null && ($viewTo === 0 || $viewTo > $cacheTo)) { $liveFrom = max($cacheTo + 1, (int)$viewFrom); $liveTo = (int)$viewTo; $doLiveLoop = true; } if ($cacheFrom !== null && $viewFrom > 0 && $viewFrom < $cacheFrom) { $liveBelowFrom = (int)$viewFrom; $liveBelowTo = min($cacheFrom - 1, $viewTo > 0 ? (int)$viewTo : $cacheFrom - 1); $doLiveLoopBelow = true; } // Wenn User-Range komplett außerhalb Cache-Range → nur Live (keine Cache-Aktion) if (!$hasCachePart) { $loadedFromCache = false; $missingCacheRanges = []; // Live-Loop nutzt User-Range direkt if (!$doLiveLoop && !$doLiveLoopBelow) { $doLiveLoop = true; $liveFrom = (int)$viewFrom; $liveTo = (int)$viewTo; } } } } /** * Führt den Invoice-Loop für einen Jahres-Filter aus. * Filter-Modi: * - Range: $filterFromEff / $filterToEff, jeweils 0 = kein Limit. (0,0) = alles. * - Set: $yearSet = [year => true, ...]. Wenn nicht null: nur diese Jahre werden verarbeitet; * $filterFromEff / $filterToEff werden dann ignoriert. * Nutzt die globalen Datenstrukturen (rows, slotItems*, meta, pivot* etc.). * Ruft /v1/invoice/all nur einmal auf; die Liste wird via static gecached. */ function runInvoiceLoop(Epirent $Epi, int $filterFromEff, int $filterToEff, ?array $yearSet = null): void { global $rows, $slotItemsDirect, $slotItemsIncl; static $invoiceListCached = null; if ($invoiceListCached === null) { $invoiceAll = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/all?ir=true&ib=true&cl=' . Epirent_Mandant)); $invoiceListCached = ($invoiceAll && ($invoiceAll->success ?? false) && is_array($invoiceAll->payload ?? null)) ? $invoiceAll->payload : []; } $useSet = ($yearSet !== null && !empty($yearSet)); foreach ($invoiceListCached as $inv) { $invoicePk = (int)($inv->primary_key ?? 0); if ($invoicePk <= 0) continue; // Vorab-Filter aus Listen-Item (spart Detail-Call, wenn invoice_date schon dabei) $listYear = yearFromDate((string)($inv->invoice_date ?? '')) ?? 0; if ($listYear > 0) { if ($useSet) { if (!isset($yearSet[$listYear])) continue; } else { if ($filterFromEff > 0 && $listYear < $filterFromEff) continue; if ($filterToEff > 0 && $listYear > $filterToEff) 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); $orderPk = (int)($invObj->order_pk ?? 0); // Endgültiger Year-Filter (falls Listen-Antwort kein Datum hatte) if ($invoiceYear <= 0) continue; if ($useSet) { if (!isset($yearSet[$invoiceYear])) continue; } else { if ($filterFromEff > 0 && $invoiceYear < $filterFromEff) continue; if ($filterToEff > 0 && $invoiceYear > $filterToEff) continue; } $invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0; $customer = resolveInvoiceCustomer($Epi, $invObj, $orderPk); $orderItems = $invObj->order_items ?? []; if (!is_array($orderItems)) $orderItems = []; foreach ($orderItems as $oi) { $oiType = (int)($oi->type ?? -1); $oiPk = (int)($oi->primary_key ?? 0); if ($oiType === 5 && $oiPk > 0) { $chapterId = $oiPk; $journalItems = getJournalByChapter($Epi, $chapterId); foreach ($journalItems as $ji) { processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit, $customer); } continue; } if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) { processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit, $customer); continue; } } } } if ($computeRoi) { // Alle Aggregations-Globals unconditional initialisieren. loadRoiCache() überschreibt sie // im Erfolgsfall aus der Cache-Datei; ohne Datei bleiben sie leer und die Loops füllen sie. $rows = []; $slotItemsDirect = []; $slotItemsIncl = []; $meta = []; $pivot = []; $pivotB = []; $pivotExt = []; $pivotExtB = []; $eventsDirect = []; $eventsIncl = []; $customerAgg = []; $allYears = []; $productCache = []; $bundleLeafCache = []; $rentPriceCache = []; $journalCache = []; $orderCustomerCache = []; // Cache-Load-Phase: Rohdaten aus Datei in Globals holen (nur wenn nicht rebuild) if ($loadedFromCache && !$rebuildCache && file_exists($cacheFile)) { $cacheDataMeta = loadRoiCache($cacheFile); if ($cacheDataMeta === null) { // Cache defekt/veraltet: als nicht-vorhanden behandeln (Globals bleiben leer) $loadedFromCache = false; $cachedRanges = []; } else { $cachedRanges = $cacheDataMeta['cached_ranges']; } } // Cache-Add-Phase: fehlende Cache-Ranges nachladen und in cachedRanges eintragen if (!empty($missingCacheRanges)) { // Bounded Ranges können in einem einzigen Set-Loop erledigt werden. $boundedSet = []; $unboundedRanges = []; foreach ($missingCacheRanges as $r) { $from = $r['from']; $to = $r['to']; if ($from !== null && $to !== null) { for ($y = (int)$from; $y <= (int)$to; $y++) $boundedSet[$y] = true; } else { $unboundedRanges[] = $r; } } if (!empty($boundedSet)) { runInvoiceLoop($Epi, 0, 0, $boundedSet); } foreach ($unboundedRanges as $r) { $from = $r['from']; $to = $r['to']; runInvoiceLoop($Epi, $from !== null ? (int)$from : 0, $to !== null ? (int)$to : 0); } // Neu abgedeckte Bereiche in cachedRanges übernehmen foreach ($missingCacheRanges as $r) { $cachedRanges = addToCachedRanges($cachedRanges, $r['from'], $r['to']); } // Cache-Datei jetzt speichern (VOR dem Live-Loop, damit dessen Daten nicht mit reinkommen) @saveRoiCache($cacheFile, $cacheFrom, $cacheTo, $cachedRanges); $cacheDataMeta = [ 'ts' => time(), 'cache_from' => $cacheFrom, 'cache_to' => $cacheTo, 'cached_ranges' => $cachedRanges, ]; } // Live-Add-Phase: Loop für den Live-Range außerhalb der Cache-Config-Range if ($doLiveLoopBelow) { runInvoiceLoop($Epi, (int)$liveBelowFrom, (int)$liveBelowTo); } if ($doLiveLoop) { runInvoiceLoop($Epi, (int)$liveFrom, (int)$liveTo); } /* ========================= 1b) Rohdaten-Filter (User-Zeitraum) – Wenn der User einen konkreten Zeitraum gewählt hat, werden hier die Rohdaten (rows, pivot*, events*, slotItems*, customerAgg, allYears) auf den Range gefiltert. – Die anschließende Aggregation (Slots, Peak/Histogramm, Pivot-Rows, Kunden) läuft dadurch AUTOMATISCH nur über den gewählten Zeitraum – kein Post-Filter nötig. ========================= */ if ($filterMode === 'range' && $filterFrom > 0 && $filterTo > 0) { applyRangeFilterToRawData((int)$filterFrom, (int)$filterTo); $cacheFilterApplied = ['from' => (int)$filterFrom, 'to' => (int)$filterTo]; } /* ========================= 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; // Titel-Historie auswerten: neuester Titel als Hauptname, restliche als "früher"-Liste $titlesMap = $m['titles'] ?? []; if (!empty($titlesMap)) { arsort($titlesMap); // nach letztem invoice_date absteigend $orderedTitles = array_keys($titlesMap); $newestTitle = (string)$orderedTitles[0]; $olderTitles = array_slice($orderedTitles, 1); } else { $newestTitle = (string)($m['title'] ?? ''); $olderTitles = []; } $row = [ 'product_pk' => $productPk, 'product_no' => (int)($m['product_no'] ?? 0), 'title' => $newestTitle, 'older_titles' => $olderTitles, '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'] ?? [], 'customers' => [], ]; // Kundenliste: pro Kunde die 'by_year'-Werte zu Totalen summieren, // dann absteigend nach "incl" sortieren – größte Kunden zuerst. $custList = []; foreach ($customerAgg[$productPk] ?? [] as $cpk => $c) { $sumD = 0.0; $sumI = 0.0; $sumED = 0.0; $sumEI = 0.0; foreach (($c['by_year'] ?? []) as $y => $v) { $sumD += (float)($v['direct'] ?? 0.0); $sumI += (float)($v['incl'] ?? 0.0); $sumED += (float)($v['ext_direct'] ?? 0.0); $sumEI += (float)($v['ext_incl'] ?? 0.0); } if ($sumD == 0.0 && $sumI == 0.0 && $sumED == 0.0 && $sumEI == 0.0) continue; $custList[] = [ 'pk' => (int)($c['pk'] ?? 0), 'name' => (string)($c['name'] ?? ''), 'direct' => $sumD, 'incl' => $sumI, 'ext_direct' => $sumED, 'ext_incl' => $sumEI, ]; } usort($custList, fn($a,$b) => ($b['incl'] <=> $a['incl'])); $row['customers'] = $custList; $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']; $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); } // end if ($computeRoi) /* ========================= 5) HTML Ausgabe ========================= */ function slotsToDisplay(array $slotMap): array { ksort($slotMap); $out = []; foreach ($slotMap as $slot => $rev) { $slot = (int)$slot; if ($slot <= 0) continue; $v = (float)$rev; if (abs($v) < 0.0000001) $v = 0.0; $out[] = ['slot'=>$slot, 'rev'=>$v]; } return $out; } ?> ROI – Umsatz pro Artikel / Jahr

ROI

Zeitraum wählen

Aktuell:  – 
Aktuell: alle Jahre
0 ? '&from=' . (int)$filterFrom : '') . ($filterTo > 0 ? '&to=' . (int)$filterTo : ''); $coveredHuman = formatCachedRangesHuman($cachedRanges); $incrementalAdded = !$rebuildCache && !empty($missingCacheRanges); $usedCache = ($loadedFromCache || $rebuildCache); $usedLive = !empty($doLiveLoop) || !empty($doLiveLoopBelow); ?>
Cache-Modus aktiv  |  Konfiguriert:  –   |  Abgedeckt:  |  Stand:  komplett neu gebaut  Cache erweitert  Cache + Live  aus Cache  nur Live (außerhalb Cache-Zeitraum)
Cache neu laden
Zeitraum  –  ist auf alle Kennzahlen angewendet – auch Peak, Histogramm, Slots und Kunden.
Bitte oben einen Zeitraum auswählen und auf Zeitraum berechnen klicken, oder Alles berechnen für die vollständige Auswertung.
Pivot: Artikel × Rechnungsjahr
Export nach Excel
$slotsDirect, 'incl' => $slotsIncl, 'direct_ext' => $slotsDirectExt, 'incl_ext' => $slotsInclExt, ]; $slotsB64 = base64Json($slotsPayload); // Kunden-Payload für Modal (bereits nach 'incl' desc sortiert) $customersB64 = base64Json($r['customers'] ?? []); $customersCount = is_array($r['customers'] ?? null) ? count($r['customers']) : 0; ?>
Produkt-PK Artikel-Nr Artikel Peak Summe davon extern Histogramm Slots Kunden
• ' . implode('
• ', array_map( fn($t) => htmlspecialchars((string)$t, ENT_QUOTES), $r['older_titles'] )); ?>
() () () ()
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). Slots: auftragsbasiert, SIGNED (Storno gibt Slots frei und bucht auf die gleichen Zeiträume zurück). Storno-Erkennung: Rechnung mit sum_net < 0.
Debug: verarbeitete Mietpositionen
Jahr Invoice Rechnungsnr Rechnungsdatum Quelle Chapter Produkt-PK Artikel-Nr Artikel Start Ende Menge Menge (signed) Ext-Menge Umsatz netto Umsatz extern Credit
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet. Menge(signed) ist für Peak/Slots relevant (Storno-Rechnung -> negativ).