caching eingebaut: Cacht eine definierte Anzahl Jahren für die ROI Planung vor
This commit is contained in:
Vendored
+687
-38
@@ -36,6 +36,353 @@ if ($filterMode === 'range' && ($filterFrom <= 0 || $filterTo <= 0)) {
|
|||||||
$filterMode = '';
|
$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. "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
|
Helpers
|
||||||
========================= */
|
========================= */
|
||||||
@@ -396,9 +743,9 @@ $slotAgg = [];
|
|||||||
// incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist
|
// incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist
|
||||||
$customerAgg = [];
|
$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;
|
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;
|
if ($direct == 0.0 && $incl == 0.0 && $extDirect == 0.0 && $extIncl == 0.0) return;
|
||||||
|
|
||||||
$cpk = (int)($customer['pk'] ?? 0);
|
$cpk = (int)($customer['pk'] ?? 0);
|
||||||
@@ -409,20 +756,22 @@ function addCustomerRevenue(int $productPk, array $customer, float $direct, floa
|
|||||||
$customerAgg[$productPk][$cpk] = [
|
$customerAgg[$productPk][$cpk] = [
|
||||||
'pk' => $cpk,
|
'pk' => $cpk,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'direct' => 0.0,
|
// Aufschlüsselung pro Jahr, damit Zeitraum-Filter sauber angewendet werden können
|
||||||
'incl' => 0.0,
|
'by_year' => [],
|
||||||
'ext_direct' => 0.0,
|
|
||||||
'ext_incl' => 0.0,
|
|
||||||
];
|
];
|
||||||
} elseif ($customerAgg[$productPk][$cpk]['name'] === '' && $name !== '') {
|
} elseif ($customerAgg[$productPk][$cpk]['name'] === '' && $name !== '') {
|
||||||
// Nachträglich Namen ergänzen, falls anfangs leer
|
|
||||||
$customerAgg[$productPk][$cpk]['name'] = $name;
|
$customerAgg[$productPk][$cpk]['name'] = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$customerAgg[$productPk][$cpk]['direct'] += $direct;
|
if (!isset($customerAgg[$productPk][$cpk]['by_year'][$year])) {
|
||||||
$customerAgg[$productPk][$cpk]['incl'] += $incl;
|
$customerAgg[$productPk][$cpk]['by_year'][$year] = [
|
||||||
$customerAgg[$productPk][$cpk]['ext_direct'] += $extDirect;
|
'direct' => 0.0, 'incl' => 0.0, 'ext_direct' => 0.0, 'ext_incl' => 0.0,
|
||||||
$customerAgg[$productPk][$cpk]['ext_incl'] += $extIncl;
|
];
|
||||||
|
}
|
||||||
|
$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 {
|
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);
|
if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet);
|
||||||
|
|
||||||
// Kunden-Aggregation für direkten Umsatz (direct wird IMMER auch in incl gezählt)
|
// Kunden-Aggregation für direkten Umsatz (direct wird IMMER auch in incl gezählt)
|
||||||
if ($revenueNet != 0.0 || $extRevenueNet != 0.0) {
|
if ($year && ($revenueNet != 0.0 || $extRevenueNet != 0.0)) {
|
||||||
addCustomerRevenue($productPk, $customer, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet);
|
addCustomerRevenue($productPk, $customer, (int)$year, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak/Histogram direct + incl (tagesbasiert)
|
// 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)
|
// 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)));
|
$leafKeys = array_unique(array_merge(array_keys($allocForCustomer), array_keys($allocExtForCustomer)));
|
||||||
foreach ($leafKeys as $leafPk) {
|
foreach ($leafKeys as $leafPk) {
|
||||||
$leafPk = (int)$leafPk;
|
$leafPk = (int)$leafPk;
|
||||||
if ($leafPk <= 0) continue;
|
if ($leafPk <= 0) continue;
|
||||||
$leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0);
|
$leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0);
|
||||||
$leafExt = (float)($allocExtForCustomer[$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 = '';
|
$excelFileName = '';
|
||||||
$excelDownloadUrl = '';
|
$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
|
Setzt: $loadedFromCache, $rebuildCache, $doLiveLoop, $liveFrom, $liveTo,
|
||||||
$slotItemsIncl = []; // productPk => list of intervals
|
$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));
|
if ($cacheEnabled) {
|
||||||
$invoiceList = ($invoiceAll && ($invoiceAll->success ?? false) && is_array($invoiceAll->payload ?? null)) ? $invoiceAll->payload : [];
|
$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);
|
$invoicePk = (int)($inv->primary_key ?? 0);
|
||||||
if ($invoicePk <= 0) continue;
|
if ($invoicePk <= 0) continue;
|
||||||
|
|
||||||
// Vorab-Filter: wenn das Listen-Item bereits invoice_date hat, sparen wir den teuren Detail-Call
|
// Vorab-Filter aus Listen-Item (spart Detail-Call, wenn invoice_date schon dabei)
|
||||||
if ($filterMode === 'range') {
|
|
||||||
$listYear = yearFromDate((string)($inv->invoice_date ?? '')) ?? 0;
|
$listYear = yearFromDate((string)($inv->invoice_date ?? '')) ?? 0;
|
||||||
if ($listYear > 0) {
|
if ($listYear > 0) {
|
||||||
if ($filterFrom > 0 && $listYear < $filterFrom) continue;
|
if ($useSet) {
|
||||||
if ($filterTo > 0 && $listYear > $filterTo) continue;
|
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);
|
$orderPk = (int)($invObj->order_pk ?? 0);
|
||||||
|
|
||||||
// Endgültiger Year-Filter (falls Listen-Antwort kein Datum hatte)
|
// Endgültiger Year-Filter (falls Listen-Antwort kein Datum hatte)
|
||||||
if ($filterMode === 'range') {
|
|
||||||
if ($invoiceYear <= 0) continue;
|
if ($invoiceYear <= 0) continue;
|
||||||
if ($filterFrom > 0 && $invoiceYear < $filterFrom) continue;
|
if ($useSet) {
|
||||||
if ($filterTo > 0 && $invoiceYear > $filterTo) continue;
|
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;
|
$invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0;
|
||||||
|
|
||||||
// Kunde einmal pro Rechnung ermitteln (mit Order-Fallback + Cache)
|
|
||||||
$customer = resolveInvoiceCustomer($Epi, $invObj, $orderPk);
|
$customer = resolveInvoiceCustomer($Epi, $invObj, $orderPk);
|
||||||
|
|
||||||
$orderItems = $invObj->order_items ?? [];
|
$orderItems = $invObj->order_items ?? [];
|
||||||
@@ -917,23 +1415,105 @@ foreach ($invoiceList as $inv) {
|
|||||||
$oiType = (int)($oi->type ?? -1);
|
$oiType = (int)($oi->type ?? -1);
|
||||||
$oiPk = (int)($oi->primary_key ?? 0);
|
$oiPk = (int)($oi->primary_key ?? 0);
|
||||||
|
|
||||||
// Kapitel (type=5): chid ist primary_key der Kapitelzeile
|
|
||||||
if ($oiType === 5 && $oiPk > 0) {
|
if ($oiType === 5 && $oiPk > 0) {
|
||||||
$chapterId = $oiPk;
|
$chapterId = $oiPk;
|
||||||
$journalItems = getJournalByChapter($Epi, $chapterId);
|
$journalItems = getJournalByChapter($Epi, $chapterId);
|
||||||
|
|
||||||
foreach ($journalItems as $ji) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direkte Artikelposition ohne Kapitel (type=0)
|
|
||||||
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 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;
|
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' => [],
|
'customers' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Kundenliste absteigend nach "incl" sortieren – größte Kunden zuerst
|
// Kundenliste: pro Kunde die 'by_year'-Werte zu Totalen summieren,
|
||||||
$custList = array_values($customerAgg[$productPk] ?? []);
|
// 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']));
|
usort($custList, fn($a,$b) => ($b['incl'] <=> $a['incl']));
|
||||||
$row['customers'] = $custList;
|
$row['customers'] = $custList;
|
||||||
|
|
||||||
@@ -1273,6 +1872,56 @@ function slotsToDisplay(array $slotMap): array {
|
|||||||
<br><b>Aktuell:</b> alle Jahre
|
<br><b>Aktuell:</b> alle Jahre
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
|
<?php if ($cacheEnabled): ?>
|
||||||
|
<?php
|
||||||
|
$cacheFromLabel = $cacheFrom === null ? '−∞' : (string)(int)$cacheFrom;
|
||||||
|
$cacheToLabel = $cacheTo === null ? '+∞' : (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>
|
||||||
|
| Konfiguriert: <span class="mono"><?php echo $cacheFromLabel; ?> – <?php echo $cacheToLabel; ?></span>
|
||||||
|
| Abgedeckt: <span class="mono"><?php echo htmlspecialchars($coveredHuman); ?></span>
|
||||||
|
| Stand: <span class="mono"><?php echo htmlspecialchars($cacheTsLabel); ?></span>
|
||||||
|
<?php if ($rebuildCache): ?>
|
||||||
|
<span class="badge badge-warning">komplett neu gebaut</span>
|
||||||
|
<?php elseif ($incrementalAdded): ?>
|
||||||
|
<span class="badge badge-warning">Cache erweitert</span>
|
||||||
|
<?php elseif ($usedCache && $usedLive): ?>
|
||||||
|
<span class="badge badge-primary">Cache + Live</span>
|
||||||
|
<?php elseif ($loadedFromCache): ?>
|
||||||
|
<span class="badge badge-success">aus Cache</span>
|
||||||
|
<?php elseif ($usedLive): ?>
|
||||||
|
<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']; ?> – <?php echo (int)$cacheFilterApplied['to']; ?></span>
|
||||||
|
ist auf alle Kennzahlen angewendet – auch Peak, Histogramm, Slots und Kunden.
|
||||||
|
</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ define('CrewBrain_TaskListID', 6);
|
|||||||
define('Enable_QR_Code_CrewBrainAufgaben', true);
|
define('Enable_QR_Code_CrewBrainAufgaben', true);
|
||||||
// @hr
|
// @hr
|
||||||
// @section: Epirent-Spezifische Einstellungen
|
// @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_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('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
|
define('Vorbereitungs_Zeitvariable', 'Packen'); //Name des zu verwendenden Zeitabschnitts, der Zusätzlich zur DispoZeit beim Check Out Angezeigt werden soll
|
||||||
|
|||||||
Reference in New Issue
Block a user