0 && $filterTo > 0 && $filterTo < $filterFrom) { [$filterFrom, $filterTo] = [$filterTo, $filterFrom]; } $computeRoi = in_array($filterMode, ['range', 'all'], true); if ($filterMode === 'range' && ($filterFrom <= 0 || $filterTo <= 0)) { $computeRoi = false; $filterMode = ''; } /* ========================= Helpers ========================= */ function formatEuro(float $amount): string { $formatted = number_format(abs($amount), 2, ',', '.') . ' €'; return $amount < 0 ? '-' . $formatted : $formatted; } function yearFromDate(?string $ymd): ?int { if (!$ymd || $ymd === '0000-00-00') return null; return (int)substr($ymd, 0, 4); } function safeYmd(?string $ymd): ?string { if (!$ymd || $ymd === '0000-00-00') return null; if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $ymd)) return null; return $ymd; } function ymdToTs(string $ymd): int { return (new DateTime($ymd . ' 00:00:00', new DateTimeZone('Europe/Berlin')))->getTimestamp(); } function tsToYmd(int $ts): string { return (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('Europe/Berlin'))->format('Y-m-d'); } function base64Json($data): string { return base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } function apiJsonDecode($raw) { if (is_object($raw)) { if (method_exists($raw, 'getContents')) { $raw = $raw->getContents(); } else { $raw = (string)$raw; } } if (!is_string($raw)) $raw = (string)$raw; return json_decode($raw); } /* ========================= API Caches ========================= */ $productCache = []; // [pk => productObj] $bundleLeafCache = []; // [bundlePk => [leafPk => amount]] $rentPriceCache = []; // [pk => float] function getProduct(Epirent $Epi, int $productPk) { global $productCache; if ($productPk <= 0) return null; if (isset($productCache[$productPk])) return $productCache[$productPk]; $res = apiJsonDecode($Epi->requestEpiApi('/v1/product/' . $productPk . '?cl=' . Epirent_Mandant)); $prod = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null; $productCache[$productPk] = $prod; return $prod; } function getRentPrice(Epirent $Epi, int $productPk): float { global $rentPriceCache; if (isset($rentPriceCache[$productPk])) return $rentPriceCache[$productPk]; $p = getProduct($Epi, $productPk); $price = 0.0; if ($p && isset($p->pricing) && isset($p->pricing->price_rent)) { $price = (float)$p->pricing->price_rent; } $rentPriceCache[$productPk] = $price; return $price; } /** * WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles. * Ergebnis: [leafProductPk => amount] */ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array { global $bundleLeafCache; if ($productPk <= 0) return []; if (isset($bundleLeafCache[$productPk])) return $bundleLeafCache[$productPk]; if (isset($stack[$productPk])) { return []; } $stack[$productPk] = true; $p = getProduct($Epi, $productPk); if (!$p) { unset($stack[$productPk]); return []; } // NUR virtuelle Bundles zerlegen $isVirtual = (bool)($p->is_virtual ?? false); if (!$isVirtual) { unset($stack[$productPk]); $bundleLeafCache[$productPk] = [$productPk => 1.0]; return $bundleLeafCache[$productPk]; } $mats = []; if (!empty($p->materials_ext) && !empty($p->materials_ext->rent_fix) && is_array($p->materials_ext->rent_fix)) { $mats = $p->materials_ext->rent_fix; } elseif (!empty($p->materials) && is_array($p->materials)) { $mats = $p->materials; } // virtuell aber ohne Komponenten => leaf if (empty($mats)) { unset($stack[$productPk]); $bundleLeafCache[$productPk] = [$productPk => 1.0]; return $bundleLeafCache[$productPk]; } $leaf = []; foreach ($mats as $m) { $childPk = (int)($m->mat_product_pk ?? 0); $amt = (float)($m->amount ?? 1); if ($childPk <= 0 || $amt <= 0) continue; $childLeaf = resolveBundleLeafMap($Epi, $childPk, $stack); foreach ($childLeaf as $leafPk => $leafAmt) { if (!isset($leaf[$leafPk])) $leaf[$leafPk] = 0.0; $leaf[$leafPk] += $amt * (float)$leafAmt; } } unset($stack[$productPk]); if (empty($leaf)) $leaf = [$productPk => 1.0]; $bundleLeafCache[$productPk] = $leaf; return $leaf; } /** * Bundle-Umsatz anteilig auf leaf-Produkte verteilen. * Gewichtung: rentPrice(leaf) * amount * Fallback: gleichverteilt nach amount */ function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenueNet): array { $leafMap = resolveBundleLeafMap($Epi, $bundlePk); if (empty($leafMap)) return []; // wenn NICHT virtuell (leafMap == self) => dann keine Allocation (weil kein Bundle) if (count($leafMap) === 1 && isset($leafMap[$bundlePk])) { return []; } $weights = []; $sumW = 0.0; foreach ($leafMap as $leafPk => $amt) { $rp = getRentPrice($Epi, (int)$leafPk); $w = $rp * (float)$amt; $weights[$leafPk] = $w; $sumW += $w; } if ($sumW <= 0.0) { $sumAmt = array_sum($leafMap); if ($sumAmt <= 0.0) $sumAmt = (float)count($leafMap); $alloc = []; foreach ($leafMap as $leafPk => $amt) { $alloc[$leafPk] = $bundleRevenueNet * ((float)$amt / $sumAmt); } return $alloc; } $alloc = []; foreach ($weights as $leafPk => $w) { $alloc[$leafPk] = $bundleRevenueNet * ($w / $sumW); } return $alloc; } /* ========================= Customer Cache – Zieht den Kunden aus dem Invoice-Objekt, fällt bei Bedarf auf den Order-Endpoint zurück. – Ergebnis pro Order gecached, damit mehrere Rechnungen desselben Auftrags nur 1x API-Kosten haben. ========================= */ $orderCustomerCache = []; // [orderPk => ['pk'=>int, 'name'=>string]] function resolveInvoiceCustomer(Epirent $Epi, $invObj, int $orderPk): array { global $orderCustomerCache; $pk = 0; $name = ''; $pickInt = function($obj, array $fields) { foreach ($fields as $f) { $v = (int)($obj->{$f} ?? 0); if ($v > 0) return $v; } return 0; }; $pickStr = function($obj, array $fields) { foreach ($fields as $f) { $v = trim((string)($obj->{$f} ?? '')); if ($v !== '') return $v; } return ''; }; if (is_object($invObj)) { $pk = $pickInt($invObj, ['customer_pk','contact_pk','pk_customer','kunde_pk']); $name = $pickStr($invObj, ['customer_name','contact_name','kunde_name','name_customer']); foreach (['address_invoice','address_delivery','customer','contact'] as $af) { if ($pk > 0 && $name !== '') break; $sub = $invObj->{$af} ?? null; if (is_object($sub)) { if ($pk <= 0) $pk = $pickInt($sub, ['contact_pk','primary_key','pk','id']); if ($name === '') $name = $pickStr($sub, ['company','name','display_name','name1']); } } } if (($pk <= 0 || $name === '') && $orderPk > 0) { if (!array_key_exists($orderPk, $orderCustomerCache)) { $res = apiJsonDecode($Epi->requestEpiApi('/v1/order/' . $orderPk . '?cl=' . Epirent_Mandant)); $ord = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null; $c = ['pk' => 0, 'name' => '']; if (is_object($ord)) { $c['pk'] = $pickInt($ord, ['customer_pk','contact_pk','pk_customer']); $c['name'] = $pickStr($ord, ['customer_name','contact_name']); foreach (['address_invoice','address_delivery','customer','contact'] as $af) { if ($c['pk'] > 0 && $c['name'] !== '') break; $sub = $ord->{$af} ?? null; if (is_object($sub)) { if ($c['pk'] <= 0) $c['pk'] = $pickInt($sub, ['contact_pk','primary_key','pk','id']); if ($c['name'] === '') $c['name'] = $pickStr($sub, ['company','name','display_name','name1']); } } } $orderCustomerCache[$orderPk] = $c; } $c = $orderCustomerCache[$orderPk]; if ($pk <= 0) $pk = (int)$c['pk']; if ($name === '') $name = (string)$c['name']; } if ($pk <= 0) $pk = 0; if ($name === '') $name = $pk > 0 ? ('Kunde #' . $pk) : 'Unbekannt'; return ['pk' => $pk, 'name' => $name]; } /* ========================= Journal Cache ========================= */ $journalCache = []; function getJournalByChapter(Epirent $Epi, int $chapterId): array { global $journalCache; if ($chapterId <= 0) return []; if (isset($journalCache[$chapterId])) return $journalCache[$chapterId]; $url = '/v1/journal/filter?chid=' . $chapterId . '&cl=' . Epirent_Mandant; $res = apiJsonDecode($Epi->requestEpiApi($url)); $payload = ($res && ($res->success ?? false) && isset($res->payload) && is_array($res->payload)) ? $res->payload : []; $journalCache[$chapterId] = $payload; return $payload; } /* ========================= Interval aggregation (Peak/Histogram) – tagesbasiert ========================= */ $eventsDirect = []; // eventsDirect[productPk][ymd] += deltaQty $eventsIncl = []; // direct + bundle function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void { if ($productPk <= 0) return; $startYmd = safeYmd($startYmd); $endYmd = safeYmd($endYmd); if (!$startYmd || !$endYmd) return; if ($qty == 0.0) return; $startTs = ymdToTs($startYmd); $endTs = ymdToTs($endYmd); if ($endTs < $startTs) return; $endPlusTs = $endTs + 86400; $endPlusYmd = tsToYmd($endPlusTs); if (!isset($events[$productPk])) $events[$productPk] = []; if (!isset($events[$productPk][$startYmd])) $events[$productPk][$startYmd] = 0.0; if (!isset($events[$productPk][$endPlusYmd])) $events[$productPk][$endPlusYmd] = 0.0; $events[$productPk][$startYmd] += $qty; $events[$productPk][$endPlusYmd] -= $qty; } function computePeakAndHistogram(array $eventMap): array { if (empty($eventMap)) return ['peak' => 0, 'hist' => []]; ksort($eventMap); $dates = array_keys($eventMap); $level = 0.0; $peak = 0.0; $hist = []; for ($i = 0; $i < count($dates); $i++) { $d = $dates[$i]; $level += (float)$eventMap[$d]; // clamp, damit kein negativer Bestand "Tage" erzeugt if ($level < 0) $level = 0; $intLevel = (int)round($level); if ($intLevel > $peak) $peak = $intLevel; if ($i < count($dates) - 1) { $dTs = ymdToTs($d); $nTs = ymdToTs($dates[$i + 1]); $days = ($nTs - $dTs) / 86400.0; if ($days > 0 && $intLevel > 0) { if (!isset($hist[$intLevel])) $hist[$intLevel] = 0.0; $hist[$intLevel] += $days; } } } ksort($hist); foreach ($hist as $k => $v) $hist[$k] = (int)round($v); return ['peak' => (int)$peak, 'hist' => $hist]; } /* ========================= Data aggregation ========================= */ $rows = []; // Debug rows (HTML only) $pivot = []; // direct revenue per year (net) $pivotB = []; // bundle revenue per year allocated to leaf (net) $pivotExt = []; // extern revenue per year (net) direct $pivotExtB = []; // extern revenue per year allocated (net) for virtual bundles $meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]] $allYears = []; // Auftragsbasierte "Slots" (direct/incl) pro Produkt $slotAgg = []; // Kunden-Aggregation pro Produkt: [productPk][customerPk] = ['pk','name','direct','incl','ext_direct','ext_incl'] // Semantik analog zu $pivot / $pivotB: // direct = Umsatz aus Rechnungen die diesen productPk DIREKT abgerechnet haben // incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist $customerAgg = []; function addCustomerRevenue(int $productPk, array $customer, float $direct, float $incl, float $extDirect, float $extIncl): void { global $customerAgg; if ($productPk <= 0) return; if ($direct == 0.0 && $incl == 0.0 && $extDirect == 0.0 && $extIncl == 0.0) return; $cpk = (int)($customer['pk'] ?? 0); $name = (string)($customer['name'] ?? ''); if (!isset($customerAgg[$productPk])) $customerAgg[$productPk] = []; if (!isset($customerAgg[$productPk][$cpk])) { $customerAgg[$productPk][$cpk] = [ 'pk' => $cpk, 'name' => $name, 'direct' => 0.0, 'incl' => 0.0, 'ext_direct' => 0.0, 'ext_incl' => 0.0, ]; } 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; } function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title, string $invoiceDate = ''): void { global $meta; if (!isset($meta[$productPk])) { $p = getProduct($Epi, $productPk); $meta[$productPk] = [ 'product_pk' => $productPk, 'product_no' => $productNo ?: (int)($p->product_no ?? 0), 'title' => $title ?: (string)($p->name ?? ''), // titles: [title => letztes invoice_date, an dem dieser Titel auftauchte] 'titles' => [], ]; } // Titel-Historie: nur Titel erfassen, die tatsächlich auf einer Rechnung standen, // damit Umbenennungen (gleiche Artikelnummer, neuer Name) sichtbar bleiben. if ($title !== '' && $invoiceDate !== '') { $prev = $meta[$productPk]['titles'][$title] ?? ''; if ($invoiceDate > $prev) { $meta[$productPk]['titles'][$title] = $invoiceDate; } } } function addRevenue(array &$pivotRef, int $productPk, int $year, float $revenueNet): void { if ($productPk <= 0 || !$year) return; if (!isset($pivotRef[$productPk])) $pivotRef[$productPk] = []; if (!isset($pivotRef[$productPk][$year])) $pivotRef[$productPk][$year] = 0.0; $pivotRef[$productPk][$year] += $revenueNet; } /** * Extern-Logik: * - Amount External ist eine ANZAHL (nicht Umsatz). * - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0) */ function calcExternalRevenueNet(object $li): float { $amountTotal = (float)($li->amount_total ?? 0); $amountExternal = (float)($li->amount_external ?? 0); $net = (float)($li->sum_total_net ?? 0); if ($net == 0.0) return 0.0; if ($amountTotal <= 0.0) return 0.0; if ($amountExternal <= 0.0) return 0.0; if ($amountExternal > $amountTotal) $amountExternal = $amountTotal; return $net * ($amountExternal / $amountTotal); } /* ========================= Slot (auftragsbasiert) – Storno-sicher (SIGNED) ========================= */ /** * Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt) * qty kann positiv oder negativ sein (negativ = Storno/Rückbuchung) */ function addSlotItem( array &$slotItems, int $productPk, int $invoicePk, int $orderPk, int $invoiceNo, string $startYmd, string $endYmd, float $qty, float $revenueNet, float $extRevenueNet ): void { $startYmd = safeYmd($startYmd); $endYmd = safeYmd($endYmd); if ($productPk <= 0) return; if (!$startYmd || !$endYmd) return; if ($qty == 0.0) return; if (!isset($slotItems[$productPk])) $slotItems[$productPk] = []; $slotItems[$productPk][] = [ 'product_pk' => $productPk, 'invoice_pk' => $invoicePk, 'order_pk' => $orderPk, 'invoice_no' => $invoiceNo, 'start_ts' => ymdToTs($startYmd), 'end_ts' => ymdToTs($endYmd), 'qty' => (float)$qty, // signed erlaubt 'rev' => (float)$revenueNet, 'rev_ext' => (float)$extRevenueNet, 'start_ymd' => $startYmd, 'end_ymd' => $endYmd, ]; } /** * Slotting (SIGNED, Storno gibt Slots wieder frei): * - Positive qty belegt Slots. * - Negative qty bucht Umsatz zurück UND gibt Slots frei. * - Priorität bei Storno: zuerst Slots mit gleichem Zeitraum (gleicher release_ts) abbauen. * * Ergebnis: * - 'direct' => [slot => revenue] * - 'direct_ext' => [slot => extRevenue] */ function computeOrderBasedSlots(array $itemsForProduct): array { if (empty($itemsForProduct)) { return ['direct' => [], 'direct_ext' => []]; } // Sortierung: start ASC, end DESC, invoice_no ASC, invoice_pk ASC usort($itemsForProduct, function($a, $b){ if (($a['start_ts'] ?? 0) !== ($b['start_ts'] ?? 0)) return ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0); if (($a['end_ts'] ?? 0) !== ($b['end_ts'] ?? 0)) return ($b['end_ts'] ?? 0) <=> ($a['end_ts'] ?? 0); if (($a['invoice_no'] ?? 0) !== ($b['invoice_no'] ?? 0)) return ($a['invoice_no'] ?? 0) <=> ($b['invoice_no'] ?? 0); return ($a['invoice_pk'] ?? 0) <=> ($b['invoice_pk'] ?? 0); }); // Active slots: slotIndex => release_ts $active = []; // release_ts => [slotIndex, ...] $activeByRelease = []; // free slot indices $freeSlots = []; // Revenues per slot $slotRevenue = []; $slotRevenueExt = []; $releaseTsFor = function(int $endTs): int { // Ende inklusiv -> frei am Folgetag 00:00 return $endTs + 86400; }; $freeExpired = function(int $currentStartTs) use (&$active, &$activeByRelease, &$freeSlots) { if (empty($active)) return; foreach ($active as $slot => $relTs) { if ($relTs <= $currentStartTs) { unset($active[$slot]); if (isset($activeByRelease[$relTs])) { $activeByRelease[$relTs] = array_values(array_filter( $activeByRelease[$relTs], fn($s) => (int)$s !== (int)$slot )); if (empty($activeByRelease[$relTs])) unset($activeByRelease[$relTs]); } $freeSlots[] = (int)$slot; } } }; $takeFreeSlot = function() use (&$freeSlots, &$active): int { if (!empty($freeSlots)) { sort($freeSlots); return (int)array_shift($freeSlots); } if (empty($active)) return 1; $keys = array_keys($active); return (int)(max($keys) + 1); }; foreach ($itemsForProduct as $it) { $startTs = (int)($it['start_ts'] ?? 0); $endTs = (int)($it['end_ts'] ?? 0); if ($startTs <= 0 || $endTs <= 0) continue; if ($endTs < $startTs) continue; // vor jeder Aktion: abgelaufene Slots freigeben $freeExpired($startTs); $qtySigned = (float)($it['qty'] ?? 0.0); if ($qtySigned == 0.0) continue; $qtyAbs = (int)round(abs($qtySigned)); if ($qtyAbs <= 0) continue; $rev = (float)($it['rev'] ?? 0.0); $ext = (float)($it['rev_ext'] ?? 0.0); $revPer = $rev / $qtyAbs; $extPer = $ext / $qtyAbs; $relTs = $releaseTsFor($endTs); if ($qtySigned > 0) { // --- Belegen --- for ($k = 0; $k < $qtyAbs; $k++) { $slot = $takeFreeSlot(); $active[$slot] = $relTs; if (!isset($activeByRelease[$relTs])) $activeByRelease[$relTs] = []; $activeByRelease[$relTs][] = $slot; if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; $slotRevenue[$slot] += $revPer; $slotRevenueExt[$slot] += $extPer; } } else { // --- Storno / Freigeben --- // erst gleiche release_ts abbauen (damit "Rechnung+Storno im gleichen Zeitraum" sauber 0 Slots macht) $candidates = $activeByRelease[$relTs] ?? []; for ($k = 0; $k < $qtyAbs; $k++) { $slot = null; if (!empty($candidates)) { rsort($candidates); // höchste Slotnummer zuerst abbauen $slot = (int)array_shift($candidates); } else { if (!empty($active)) { $keys = array_keys($active); rsort($keys); $slot = (int)$keys[0]; } } if ($slot === null) break; // Umsatz auf Slot zurückbuchen if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; $slotRevenue[$slot] += $revPer; // revPer ist i.d.R. negativ $slotRevenueExt[$slot] += $extPer; // extPer ist i.d.R. negativ // Slot freigeben $oldRel = $active[$slot] ?? null; unset($active[$slot]); if ($oldRel !== null && isset($activeByRelease[$oldRel])) { $activeByRelease[$oldRel] = array_values(array_filter( $activeByRelease[$oldRel], fn($s) => (int)$s !== (int)$slot )); if (empty($activeByRelease[$oldRel])) unset($activeByRelease[$oldRel]); } $freeSlots[] = $slot; } } } // --- Cleanup: Slots ohne Umsatz entfernen + neu durchnummerieren --- $eps = 0.00001; $allSlots = array_unique(array_merge(array_keys($slotRevenue), array_keys($slotRevenueExt))); sort($allSlots); $kept = []; foreach ($allSlots as $s) { $rev = (float)($slotRevenue[$s] ?? 0.0); $ext = (float)($slotRevenueExt[$s] ?? 0.0); if (abs($rev) < $eps && abs($ext) < $eps) continue; $kept[] = $s; } $newRev = []; $newExt = []; $idx = 1; foreach ($kept as $old) { $newRev[$idx] = (float)($slotRevenue[$old] ?? 0.0); $newExt[$idx] = (float)($slotRevenueExt[$old] ?? 0.0); $idx++; } return ['direct' => $newRev, 'direct_ext' => $newExt]; } /* ========================= Prozessierung einer Artikelzeile ========================= */ function processLineItem( Epirent $Epi, array &$rows, array &$slotItemsDirect, array &$slotItemsIncl, int $orderPk, int $invoicePk, int $invoiceNo, string $invoiceDate, int $invoiceYear, ?int $chapterId, object $li, bool $isFromJournal, bool $invoiceIsCredit, // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0) array $customer = ['pk'=>0, 'name'=>'Unbekannt'] ): void { global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl; $type = (int)($li->type ?? -1); $productPk = (int)($li->product_pk ?? 0); if ($type !== 0 || $productPk <= 0) return; // nur echte Artikelpositionen $title = (string)($li->title ?? ''); $productNo = (int)($li->product_no ?? 0); $qtyRaw = (float)($li->amount_total ?? 0); if ($qtyRaw == 0.0) $qtyRaw = 0.0; // Umsatz bleibt wie geliefert (kann negativ sein) $revenueNet = (float)($li->sum_total_net ?? 0); $extRevenueNet = calcExternalRevenueNet($li); // Für Auslastung/Slots: wenn Storno-Rechnung, Menge "negativ signieren" // (weil Epi bei Storno typischerweise Menge positiv lässt, aber Preise negativ macht) $qtySigned = $qtyRaw; if ($invoiceIsCredit && $qtySigned != 0.0) { $qtySigned = -abs($qtySigned); } $dateStart = safeYmd((string)($li->date_start ?? '')) ?: null; $dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null; ensureMeta($Epi, $productPk, $productNo, $title, $invoiceDate); // Jahr: Rechnungsdatum $year = $invoiceYear; if ($year) $allYears[$year] = true; // Direct Umsatz if ($revenueNet != 0.0 && $year) addRevenue($pivot, $productPk, $year, $revenueNet); // Direct Extern-Umsatz if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet); // Kunden-Aggregation für direkten Umsatz (direct wird IMMER auch in incl gezählt) if ($revenueNet != 0.0 || $extRevenueNet != 0.0) { addCustomerRevenue($productPk, $customer, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet); } // Peak/Histogram direct + incl (tagesbasiert) // -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt if ($dateStart && $dateEnd && $qtySigned != 0.0) { addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qtySigned); addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qtySigned); } // Slotting: direct (signed qty) if ($dateStart && $dateEnd && $qtySigned != 0.0) { addSlotItem($slotItemsDirect, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet); addSlotItem($slotItemsIncl, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet); } // Debug Row $rows[] = [ 'year' => $year, 'invoice_pk' => $invoicePk, 'invoice_no' => $invoiceNo, 'invoice_date'=> $invoiceDate, 'chapter_id' => $chapterId ?? 0, 'source' => $isFromJournal ? 'journal' : 'direct', 'product_pk' => $productPk, 'product_no' => $productNo, 'title' => $title, 'date_start' => $dateStart ?? '', 'date_end' => $dateEnd ?? '', 'qty' => $qtyRaw, 'qty_signed' => $qtySigned, 'revenue_net' => $revenueNet, 'ext_rev_net' => $extRevenueNet, 'amount_ext' => (float)($li->amount_external ?? 0), 'amount_total'=> (float)($li->amount_total ?? 0), 'is_credit' => $invoiceIsCredit ? 1 : 0, ]; /* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */ $leafMap = resolveBundleLeafMap($Epi, $productPk); $isVirtualBundle = !(count($leafMap) === 1 && isset($leafMap[$productPk]) && (float)$leafMap[$productPk] === 1.0); if ($isVirtualBundle) { // Umsatz allokieren (inkl Bundle-Anteil) $allocForCustomer = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; $allocExtForCustomer = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; if ($revenueNet != 0.0 && $year) { foreach ($allocForCustomer as $leafPk => $leafRevenue) { $leafPk = (int)$leafPk; ensureMeta($Epi, $leafPk, 0, ''); addRevenue($pivotB, $leafPk, $year, (float)$leafRevenue); } } // Extern-Umsatz allokieren (inkl Bundle-Anteil) if ($extRevenueNet != 0.0 && $year) { foreach ($allocExtForCustomer as $leafPk => $leafRevenue) { $leafPk = (int)$leafPk; ensureMeta($Epi, $leafPk, 0, ''); addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue); } } // Kunden-Aggregation für Leafs (nur "incl", direct ist 0, da nicht direkt abgerechnet) if (!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); } } // Auslastung allokieren (Peak/Histogramm inkl Bundle) – signed qty! if ($dateStart && $dateEnd && $qtySigned != 0.0) { foreach ($leafMap as $leafPk => $amt) { $leafPk = (int)$leafPk; $amt = (float)$amt; if ($leafPk <= 0 || $amt <= 0) continue; ensureMeta($Epi, $leafPk, 0, ''); addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qtySigned * $amt); } } // Slotting allokieren (inkl Bundle) – signed qty! if ($dateStart && $dateEnd && $qtySigned != 0.0) { // Bundle-Allokationen aus dem Umsatz-Block wiederverwenden foreach ($leafMap as $leafPk => $amt) { $leafPk = (int)$leafPk; $amt = (float)$amt; if ($leafPk <= 0 || $amt <= 0) continue; $leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0); $leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0); $qtyLeafSigned = $qtySigned * $amt; ensureMeta($Epi, $leafPk, 0, ''); addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtyLeafSigned, $leafRev, $leafExt); } } } } /* ========================= 1) Invoices holen + Daten sammeln ========================= */ // Defaults für die HTML-Ausgabe, wenn keine Berechnung läuft $years = []; $pivotRows = []; $excelFileName = ''; $excelDownloadUrl = ''; if ($computeRoi) { $slotItemsDirect = []; // productPk => list of intervals $slotItemsIncl = []; // productPk => list of intervals $invoiceAll = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/all?ir=true&ib=true&cl=' . Epirent_Mandant)); $invoiceList = ($invoiceAll && ($invoiceAll->success ?? false) && is_array($invoiceAll->payload ?? null)) ? $invoiceAll->payload : []; foreach ($invoiceList as $inv) { $invoicePk = (int)($inv->primary_key ?? 0); if ($invoicePk <= 0) continue; // Vorab-Filter: wenn das Listen-Item bereits invoice_date hat, sparen wir den teuren Detail-Call if ($filterMode === 'range') { $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); } 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; } } } /* ========================= 2) Slot Aggregation (auftragsbasiert) ========================= */ foreach ($meta as $productPk => $m) { $productPk = (int)$productPk; $resD = computeOrderBasedSlots($slotItemsDirect[$productPk] ?? []); $resI = computeOrderBasedSlots($slotItemsIncl[$productPk] ?? []); $slotAgg[$productPk] = [ 'direct' => $resD['direct'] ?? [], 'incl' => $resI['direct'] ?? [], 'direct_ext' => $resD['direct_ext'] ?? [], 'incl_ext' => $resI['direct_ext'] ?? [], ]; } /* ========================= 3) Years + Pivot Rows bauen ========================= */ $years = array_keys($allYears); sort($years); $pivotRows = []; foreach ($meta as $productPk => $m) { $productPk = (int)$productPk; // Titel-Historie auswerten: neuester Titel als Hauptname, restliche als "früher"-Liste $titlesMap = $m['titles'] ?? []; if (!empty($titlesMap)) { arsort($titlesMap); // nach letztem invoice_date absteigend $orderedTitles = array_keys($titlesMap); $newestTitle = (string)$orderedTitles[0]; $olderTitles = array_slice($orderedTitles, 1); } else { $newestTitle = (string)($m['title'] ?? ''); $olderTitles = []; } $row = [ 'product_pk' => $productPk, 'product_no' => (int)($m['product_no'] ?? 0), 'title' => $newestTitle, 'older_titles' => $olderTitles, 'years' => [], 'years_ext' => [], 'total_direct'=> 0.0, 'total_incl' => 0.0, 'total_ext_direct'=> 0.0, 'total_ext_incl' => 0.0, 'peak_direct' => 0, 'peak_incl' => 0, 'hist_direct' => [], 'hist_incl' => [], 'slots_direct' => $slotAgg[$productPk]['direct'] ?? [], 'slots_incl' => $slotAgg[$productPk]['incl'] ?? [], 'slots_direct_ext' => $slotAgg[$productPk]['direct_ext'] ?? [], 'slots_incl_ext' => $slotAgg[$productPk]['incl_ext'] ?? [], 'customers' => [], ]; // Kundenliste absteigend nach "incl" sortieren – größte Kunden zuerst $custList = array_values($customerAgg[$productPk] ?? []); usort($custList, fn($a,$b) => ($b['incl'] <=> $a['incl'])); $row['customers'] = $custList; $sumDirect = 0.0; $sumIncl = 0.0; $sumExtDirect = 0.0; $sumExtIncl = 0.0; foreach ($years as $y) { $d = (float)($pivot[$productPk][$y] ?? 0.0); $b = (float)($pivotB[$productPk][$y] ?? 0.0); $incl = $d + $b; $de = (float)($pivotExt[$productPk][$y] ?? 0.0); $be = (float)($pivotExtB[$productPk][$y] ?? 0.0); $inclE = $de + $be; $row['years'][$y] = ['direct'=>$d, 'incl'=>$incl]; $row['years_ext'][$y] = ['direct'=>$de, 'incl'=>$inclE]; $sumDirect += $d; $sumIncl += $incl; $sumExtDirect += $de; $sumExtIncl += $inclE; } $row['total_direct'] = $sumDirect; $row['total_incl'] = $sumIncl; $row['total_ext_direct'] = $sumExtDirect; $row['total_ext_incl'] = $sumExtIncl; $pd = computePeakAndHistogram($GLOBALS['eventsDirect'][$productPk] ?? []); $pi = computePeakAndHistogram($GLOBALS['eventsIncl'][$productPk] ?? []); $row['peak_direct'] = (int)$pd['peak']; $row['peak_incl'] = (int)$pi['peak']; $row['hist_direct'] = $pd['hist']; $row['hist_incl'] = $pi['hist']; $hasAny = ( $row['total_direct'] != 0.0 || $row['total_incl'] != 0.0 || $row['total_ext_direct'] != 0.0 || $row['total_ext_incl'] != 0.0 || $row['peak_direct'] != 0 || $row['peak_incl'] != 0 ); if (!$hasAny) continue; $pivotRows[] = $row; } usort($pivotRows, fn($a,$b) => ($b['total_incl'] <=> $a['total_incl'])); /* ========================= 4) Excel Export (nur Pivot) ========================= */ $exportDir = __DIR__ . '/exports'; if (!is_dir($exportDir)) { @mkdir($exportDir, 0775, true); } $timestamp = date('Ymd_His'); $excelFileName = "ROI_Artikel_Jahre_{$timestamp}.xlsx"; $excelFilePath = $exportDir . '/' . $excelFileName; $excelDownloadUrl = 'exports/' . $excelFileName; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Pivot'); $cols = [ 'Produkt-PK','Artikel-Nr','Artikel', 'Peak','Peak (inkl. Bundle)', 'Summe','Summe (inkl. Bundle)', 'davon extern','davon extern (inkl. Bundle)' ]; foreach ($years as $y) { $cols[] = (string)$y; $cols[] = (string)$y . ' (inkl. Bundle)'; $cols[] = (string)$y . ' ext'; $cols[] = (string)$y . ' ext (inkl. Bundle)'; } $colCount = count($cols); $lastColLetter = Coordinate::stringFromColumnIndex($colCount); // Header $sheet->setCellValue('A1', 'EpiWebview – ROI / Umsatz pro Artikel & Jahr'); $sheet->mergeCells('A1:' . $lastColLetter . '1'); $sheet->getStyle('A1:' . $lastColLetter . '1')->getFont()->setBold(true)->setSize(14); $sheet->getStyle('A1:' . $lastColLetter . '1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->setCellValue('A2', 'Exportdatum: ' . date('d.m.Y H:i') . ' | Mandant: ' . (defined('Epirent_Mandant') ? Epirent_Mandant : '')); $sheet->mergeCells('A2:' . $lastColLetter . '2'); $sheet->getStyle('A2:' . $lastColLetter . '2')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $headerRow = 4; for ($i=0; $i<$colCount; $i++) { $addr = Coordinate::stringFromColumnIndex($i+1) . $headerRow; $sheet->setCellValue($addr, $cols[$i]); $sheet->getStyle($addr)->getFont()->setBold(true); $sheet->getStyle($addr)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i+1))->setAutoSize(true); } $rowIdx = $headerRow + 1; foreach ($pivotRows as $r) { $c = 1; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['product_pk'], DataType::TYPE_NUMERIC); $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['product_no'], DataType::TYPE_NUMERIC); $sheet->setCellValue(Coordinate::stringFromColumnIndex($c++).$rowIdx, (string)$r['title']); $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['peak_direct'], DataType::TYPE_NUMERIC); $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c++).$rowIdx, (int)$r['peak_incl'], DataType::TYPE_NUMERIC); $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_direct'], DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_incl'], DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_ext_direct'], DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, (float)$r['total_ext_incl'], DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; foreach ($years as $y) { $d = (float)$r['years'][$y]['direct']; $iVal = (float)$r['years'][$y]['incl']; $de = (float)$r['years_ext'][$y]['direct']; $ie = (float)$r['years_ext'][$y]['incl']; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $d, DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $iVal, DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $de, DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($c).$rowIdx, $ie, DataType::TYPE_NUMERIC); $sheet->getStyle(Coordinate::stringFromColumnIndex($c).$rowIdx)->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $c++; } $rowIdx++; } $sheet->setAutoFilter("A{$headerRow}:{$lastColLetter}{$headerRow}"); $sheet->freezePane("A" . ($headerRow + 1)); $writer = new Xlsx($spreadsheet); $writer->save($excelFilePath); } // end if ($computeRoi) /* ========================= 5) HTML Ausgabe ========================= */ function slotsToDisplay(array $slotMap): array { ksort($slotMap); $out = []; foreach ($slotMap as $slot => $rev) { $slot = (int)$slot; if ($slot <= 0) continue; $v = (float)$rev; if (abs($v) < 0.0000001) $v = 0.0; $out[] = ['slot'=>$slot, 'rev'=>$v]; } return $out; } ?> ROI – Umsatz pro Artikel / Jahr

ROI

Zeitraum wählen

Aktuell:  – 
Aktuell: alle Jahre
Bitte oben einen Zeitraum auswählen und auf Zeitraum berechnen klicken, oder Alles berechnen für die vollständige Auswertung.
Pivot: Artikel × Rechnungsjahr
Export nach Excel
$slotsDirect, 'incl' => $slotsIncl, 'direct_ext' => $slotsDirectExt, 'incl_ext' => $slotsInclExt, ]; $slotsB64 = base64Json($slotsPayload); // Kunden-Payload für Modal (bereits nach 'incl' desc sortiert) $customersB64 = base64Json($r['customers'] ?? []); $customersCount = is_array($r['customers'] ?? null) ? count($r['customers']) : 0; ?>
Produkt-PK Artikel-Nr Artikel Peak Summe davon extern Histogramm Slots Kunden
• ' . implode('
• ', array_map( fn($t) => htmlspecialchars((string)$t, ENT_QUOTES), $r['older_titles'] )); ?>
() () () ()
Jahr-Zuordnung Umsatz: invoice_date. Peak/Histogramm: Zeitraum date_start..date_end (tagesbasiert). Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles werden zerlegt). Extern-Umsatz: sum_total_net * (amount_external/amount_total). Slots: auftragsbasiert, SIGNED (Storno gibt Slots frei und bucht auf die gleichen Zeiträume zurück). Storno-Erkennung: Rechnung mit sum_net < 0.
Debug: verarbeitete Mietpositionen
Jahr Invoice Rechnungsnr Rechnungsdatum Quelle Chapter Produkt-PK Artikel-Nr Artikel Start Ende Menge Menge (signed) Ext-Menge Umsatz netto Umsatz extern Credit
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet. Menge(signed) ist für Peak/Slots relevant (Storno-Rechnung -> negativ).