From 5582a09e216137afc40f6c1758861a5f3b2a0de4 Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Mon, 20 Jul 2026 20:33:31 +0200 Subject: [PATCH] =?UTF-8?q?caching=20eingebaut:=20Cacht=20eine=20definiert?= =?UTF-8?q?e=20Anzahl=20Jahren=20f=C3=BCr=20die=20ROI=20Planung=20vor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/ROI.php | 797 ++++++++++++++++++++++++++++++++++++++++----- example.config.php | 16 + 2 files changed, 739 insertions(+), 74 deletions(-) diff --git a/dist/ROI.php b/dist/ROI.php index ee4b0b2..d8e62e6 100644 --- a/dist/ROI.php +++ b/dist/ROI.php @@ -36,6 +36,353 @@ if ($filterMode === 'range' && ($filterFrom <= 0 || $filterTo <= 0)) { $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 ========================= */ @@ -396,9 +743,9 @@ $slotAgg = []; // incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist $customerAgg = []; -function addCustomerRevenue(int $productPk, array $customer, float $direct, float $incl, float $extDirect, float $extIncl): void { +function addCustomerRevenue(int $productPk, array $customer, int $year, float $direct, float $incl, float $extDirect, float $extIncl): void { global $customerAgg; - if ($productPk <= 0) return; + 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); @@ -407,22 +754,24 @@ function addCustomerRevenue(int $productPk, array $customer, float $direct, floa if (!isset($customerAgg[$productPk])) $customerAgg[$productPk] = []; if (!isset($customerAgg[$productPk][$cpk])) { $customerAgg[$productPk][$cpk] = [ - 'pk' => $cpk, - 'name' => $name, - 'direct' => 0.0, - 'incl' => 0.0, - 'ext_direct' => 0.0, - 'ext_incl' => 0.0, + 'pk' => $cpk, + 'name' => $name, + // Aufschlüsselung pro Jahr, damit Zeitraum-Filter sauber angewendet werden können + 'by_year' => [], ]; } elseif ($customerAgg[$productPk][$cpk]['name'] === '' && $name !== '') { - // Nachträglich Namen ergänzen, falls anfangs leer $customerAgg[$productPk][$cpk]['name'] = $name; } - $customerAgg[$productPk][$cpk]['direct'] += $direct; - $customerAgg[$productPk][$cpk]['incl'] += $incl; - $customerAgg[$productPk][$cpk]['ext_direct'] += $extDirect; - $customerAgg[$productPk][$cpk]['ext_incl'] += $extIncl; + 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 { @@ -749,8 +1098,8 @@ function processLineItem( 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 ($revenueNet != 0.0 || $extRevenueNet != 0.0) { - addCustomerRevenue($productPk, $customer, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet); + if ($year && ($revenueNet != 0.0 || $extRevenueNet != 0.0)) { + addCustomerRevenue($productPk, $customer, (int)$year, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet); } // Peak/Histogram direct + incl (tagesbasiert) @@ -815,14 +1164,14 @@ function processLineItem( } // Kunden-Aggregation für Leafs (nur "incl", direct ist 0, da nicht direkt abgerechnet) - if (!empty($allocForCustomer) || !empty($allocExtForCustomer)) { + 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, 0.0, $leafRev, 0.0, $leafExt); + addCustomerRevenue($leafPk, $customer, (int)$year, 0.0, $leafRev, 0.0, $leafExt); } } @@ -867,75 +1216,306 @@ $pivotRows = []; $excelFileName = ''; $excelDownloadUrl = ''; -if ($computeRoi) { +/* ========================= + 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. -$slotItemsDirect = []; // productPk => list of intervals -$slotItemsIncl = []; // productPk => list of intervals + 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; -$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 : []; +if ($cacheEnabled) { + $cacheExists = file_exists($cacheFile); -foreach ($invoiceList as $inv) { - $invoicePk = (int)($inv->primary_key ?? 0); - if ($invoicePk <= 0) continue; + // 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; + } + } - // Vorab-Filter: wenn das Listen-Item bereits invoice_date hat, sparen wir den teuren Detail-Call + // 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 ($filterFrom > 0 && $listYear < $filterFrom) continue; - if ($filterTo > 0 && $listYear > $filterTo) 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 ($filterMode === 'range') { - if ($invoiceYear <= 0) continue; - if ($filterFrom > 0 && $invoiceYear < $filterFrom) continue; - if ($filterTo > 0 && $invoiceYear > $filterTo) continue; - } - - // Storno-Erkennung (Credit Note): sum_net < 0 - $invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0; - - // Kunde einmal pro Rechnung ermitteln (mit Order-Fallback + Cache) - $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); - - // 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, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit, $customer); + if ($useSet) { + if (!isset($yearSet[$listYear])) continue; + } else { + if ($filterFromEff > 0 && $listYear < $filterFromEff) continue; + if ($filterToEff > 0 && $listYear > $filterToEff) continue; } - continue; } - // Direkte Artikelposition ohne Kapitel (type=0) - 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; + $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) ========================= */ @@ -1000,8 +1580,27 @@ foreach ($meta as $productPk => $m) { 'customers' => [], ]; - // Kundenliste absteigend nach "incl" sortieren – größte Kunden zuerst - $custList = array_values($customerAgg[$productPk] ?? []); + // 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; @@ -1273,6 +1872,56 @@ function slotsToDisplay(array $slotMap): array {
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. + + + diff --git a/example.config.php b/example.config.php index 0f776d3..00edbd2 100644 --- a/example.config.php +++ b/example.config.php @@ -16,6 +16,22 @@ define('CrewBrain_TaskListID', 6); define('Enable_QR_Code_CrewBrainAufgaben', true); // @hr // @section: Epirent-Spezifische Einstellungen +// @note: -------------------- ROI Cache -------------------- +// @note: Aktiviert einen lokalen Cache für die ROI-Ansicht, weil die Live-API-Abfrage sehr langsam ist. +// @note: Der Cache enthält Rohdaten für einen definierten Zeitraum. Alles außerhalb wird live geladen. +// @note: Zulässige Werte (Integer ODER String): +// @note: -1 = alles aus dem Vorjahr UND ÄLTER (Jahre <= currentYear-1) +// @note: -2 = alles aus vor 2 Jahren UND ÄLTER (Jahre <= currentYear-2) +// @note: 0 = nur das aktuelle Jahr (Vorsicht: ändert sich täglich!) +// @note: 2024 = exakt dieses eine Kalenderjahr (>=1900) +// @note: '<2015' = alles vor 2015 (Jahre <= 2014) +// @note: '<=2015' = alles bis inkl. 2015 +// @note: '>=2018' = alles ab 2018 (kein Live-Anteil, weil Cache oben offen) +// @note: '>2018' = alles ab 2019 +// @note: '2015-2017' = konkreter Range (Jahre 2015..2017) +// @note: Ist der Parameter nicht definiert, ist der Cache DEAKTIVIERT und alles läuft live wie bisher. +define('ROI_Cache_YearRange', -1); + define('Enable_QR_Code_CheckOut', false); //Zeigt statt der Packscheinnummer einen Scanbaren QR Code für den CheckOut an define('Enable_QR_Code_CheckIn', false); //Zeigt statt der Packscheinnummer einen Scanbaren QR Code für den CheckIn an define('Vorbereitungs_Zeitvariable', 'Packen'); //Name des zu verwendenden Zeitabschnitts, der Zusätzlich zur DispoZeit beim Check Out Angezeigt werden soll