caching eingebaut: Cacht eine definierte Anzahl Jahren für die ROI Planung vor

This commit is contained in:
2026-07-20 20:33:31 +02:00
parent 0b6ae3d933
commit 5582a09e21
2 changed files with 739 additions and 74 deletions
+687 -38
View File
@@ -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", "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. "20172020, 20222025"
* 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);
@@ -409,20 +756,22 @@ function addCustomerRevenue(int $productPk, array $customer, float $direct, floa
$customerAgg[$productPk][$cpk] = [
'pk' => $cpk,
'name' => $name,
'direct' => 0.0,
'incl' => 0.0,
'ext_direct' => 0.0,
'ext_incl' => 0.0,
// 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,24 +1216,174 @@ $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) {
// 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: wenn das Listen-Item bereits invoice_date hat, sparen wir den teuren Detail-Call
if ($filterMode === 'range') {
// 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;
if ($useSet) {
if (!isset($yearSet[$listYear])) continue;
} else {
if ($filterFromEff > 0 && $listYear < $filterFromEff) continue;
if ($filterToEff > 0 && $listYear > $filterToEff) continue;
}
}
@@ -898,16 +1397,15 @@ foreach ($invoiceList as $inv) {
$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;
if ($useSet) {
if (!isset($yearSet[$invoiceYear])) continue;
} else {
if ($filterFromEff > 0 && $invoiceYear < $filterFromEff) continue;
if ($filterToEff > 0 && $invoiceYear > $filterToEff) 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 ?? [];
@@ -917,23 +1415,105 @@ foreach ($invoiceList as $inv) {
$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);
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl,
$orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear,
$chapterId, $ji, true, $invoiceIsCredit, $customer);
}
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);
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];
}
/* =========================
@@ -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 {
<br><b>Aktuell:</b> alle Jahre
<?php endif; ?>
</small>
<?php if ($cacheEnabled): ?>
<?php
$cacheFromLabel = $cacheFrom === null ? '&minus;&infin;' : (string)(int)$cacheFrom;
$cacheToLabel = $cacheTo === null ? '+&infin;' : (string)(int)$cacheTo;
$cacheTsLabel = ($cacheDataMeta && !empty($cacheDataMeta['ts']))
? date('d.m.Y H:i', (int)$cacheDataMeta['ts'])
: ($rebuildCache || !empty($missingCacheRanges) ? 'gerade aktualisiert' : 'noch keiner');
$refreshUrl = '?refresh_cache=1'
. ($filterMode !== '' ? '&mode=' . urlencode($filterMode) : '')
. ($filterFrom > 0 ? '&from=' . (int)$filterFrom : '')
. ($filterTo > 0 ? '&to=' . (int)$filterTo : '');
$coveredHuman = formatCachedRangesHuman($cachedRanges);
$incrementalAdded = !$rebuildCache && !empty($missingCacheRanges);
$usedCache = ($loadedFromCache || $rebuildCache);
$usedLive = !empty($doLiveLoop) || !empty($doLiveLoopBelow);
?>
<hr class="my-3" />
<div class="d-flex justify-content-between align-items-center flex-wrap">
<div>
<b><i class="fas fa-database mr-1"></i> Cache-Modus aktiv</b>
&nbsp;|&nbsp; Konfiguriert: <span class="mono"><?php echo $cacheFromLabel; ?>&nbsp;&nbsp;<?php echo $cacheToLabel; ?></span>
&nbsp;|&nbsp; Abgedeckt: <span class="mono"><?php echo htmlspecialchars($coveredHuman); ?></span>
&nbsp;|&nbsp; Stand: <span class="mono"><?php echo htmlspecialchars($cacheTsLabel); ?></span>
<?php if ($rebuildCache): ?>
&nbsp;<span class="badge badge-warning">komplett neu gebaut</span>
<?php elseif ($incrementalAdded): ?>
&nbsp;<span class="badge badge-warning">Cache erweitert</span>
<?php elseif ($usedCache && $usedLive): ?>
&nbsp;<span class="badge badge-primary">Cache + Live</span>
<?php elseif ($loadedFromCache): ?>
&nbsp;<span class="badge badge-success">aus Cache</span>
<?php elseif ($usedLive): ?>
&nbsp;<span class="badge badge-info">nur Live (außerhalb Cache-Zeitraum)</span>
<?php endif; ?>
</div>
<a class="btn btn-sm btn-outline-warning"
href="<?php echo htmlspecialchars($refreshUrl); ?>"
onclick="return confirm('Cache komplett neu bauen? Bestehende Daten werden verworfen und der volle Config-Zeitraum wird neu gefetcht.');">
<i class="fas fa-sync-alt"></i> Cache neu laden
</a>
</div>
<?php if ($cacheFilterApplied): ?>
<small class="text-muted d-block mt-2">
<i class="fas fa-info-circle"></i>
Zeitraum <span class="mono"><?php echo (int)$cacheFilterApplied['from']; ?>&nbsp;&nbsp;<?php echo (int)$cacheFilterApplied['to']; ?></span>
ist auf alle Kennzahlen angewendet auch Peak, Histogramm, Slots und Kunden.
</small>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
+16
View File
@@ -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