From 8a9d7bee75c1a28c22276cb4298c5147b69ffaae Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Thu, 29 Jan 2026 16:47:05 +0100 Subject: [PATCH] =?UTF-8?q?Slot=20berechnung=20f=C3=BCr=20ROI=20hinzugef?= =?UTF-8?q?=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/ROI.php | 614 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 503 insertions(+), 111 deletions(-) diff --git a/dist/ROI.php b/dist/ROI.php index 6a6854d..679873b 100644 --- a/dist/ROI.php +++ b/dist/ROI.php @@ -1,5 +1,6 @@ getTimestamp(); + 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(array $data): string { +function base64Json($data): string { return base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } function apiJsonDecode($raw) { - // requestEpiApi kann je nach Implementierung string ODER Stream liefern if (is_object($raw)) { - // Guzzle Stream etc. if (method_exists($raw, 'getContents')) { $raw = $raw->getContents(); } else { @@ -60,8 +57,7 @@ function apiJsonDecode($raw) { } } if (!is_string($raw)) $raw = (string)$raw; - $obj = json_decode($raw); - return $obj; + return json_decode($raw); } /* ========================= @@ -97,15 +93,12 @@ function getRentPrice(Epirent $Epi, int $productPk): float { } /** - * Bundle-Komponenten rekursiv auflösen. + * WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles. + * Damit ist die anteilige Bundlepreis-Berechnung wieder wie in der funktionierenden Version. + * * Ergebnis: [leafProductPk => amount] - * - * WICHTIG (Fix für "UJ90 hat (0)"): - * - NUR virtuelle Produkte (is_virtual==true) werden expandiert. - * - Nicht-virtuelle Produkte zählen IMMER als leaf – auch wenn sie materials haben (Zubehör/Case/etc.) - * - * - bevorzugt materials_ext.rent_fix (weil rent_fix sauberer ist) - * - fallback materials + * - nur wenn is_virtual == true UND rent_fix/materials vorhanden + * - sonst: leaf = [self=>1] * - Cycle-Guard über $stack */ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array { @@ -115,7 +108,6 @@ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): if (isset($bundleLeafCache[$productPk])) return $bundleLeafCache[$productPk]; if (isset($stack[$productPk])) { - // cycle detected return []; } $stack[$productPk] = true; @@ -126,7 +118,7 @@ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): return []; } - // ✅ FIX: Nur virtuelle Produkte expandieren + // NUR virtuelle Bundles zerlegen $isVirtual = (bool)($p->is_virtual ?? false); if (!$isVirtual) { unset($stack[$productPk]); @@ -134,17 +126,14 @@ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): return $bundleLeafCache[$productPk]; } - // Komponentenliste auslesen (nur für virtuelle Produkte) $mats = []; - - // materials_ext.rent_fix bevorzugen 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; } - // Wenn keine Komponenten => leaf + // virtuell aber ohne Komponenten => leaf if (empty($mats)) { unset($stack[$productPk]); $bundleLeafCache[$productPk] = [$productPk => 1.0]; @@ -157,7 +146,6 @@ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): $amt = (float)($m->amount ?? 1); if ($childPk <= 0 || $amt <= 0) continue; - // rekursiv $childLeaf = resolveBundleLeafMap($Epi, $childPk, $stack); foreach ($childLeaf as $leafPk => $leafAmt) { if (!isset($leaf[$leafPk])) $leaf[$leafPk] = 0.0; @@ -167,7 +155,6 @@ function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): unset($stack[$productPk]); - // falls aus irgendeinem Grund leer => als leaf behandeln if (empty($leaf)) $leaf = [$productPk => 1.0]; $bundleLeafCache[$productPk] = $leaf; @@ -183,6 +170,11 @@ function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenue $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; @@ -193,7 +185,6 @@ function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenue $sumW += $w; } - // fallback: sumW=0 => gleich nach amount if ($sumW <= 0.0) { $sumAmt = array_sum($leafMap); if ($sumAmt <= 0.0) $sumAmt = (float)count($leafMap); @@ -231,7 +222,7 @@ function getJournalByChapter(Epirent $Epi, int $chapterId): array { } /* ========================= - Interval aggregation (Peak/Histogram) + Interval aggregation (Peak/Histogram) – tagesbasiert (wie gehabt) ========================= */ // eventsDirect[productPk][ymd] += deltaQty @@ -249,7 +240,6 @@ function addIntervalEvent(array &$events, int $productPk, string $startYmd, stri $endTs = ymdToTs($endYmd); if ($endTs < $startTs) return; - // end+1 day $endPlusTs = $endTs + 86400; $endPlusYmd = tsToYmd($endPlusTs); @@ -261,10 +251,6 @@ function addIntervalEvent(array &$events, int $productPk, string $startYmd, stri $events[$productPk][$endPlusYmd] -= $qty; } -/** - * Aus Events Peak + Histogramm berechnen: - * return ['peak'=>int, 'hist'=>[level=>days]] - */ function computePeakAndHistogram(array $eventMap): array { if (empty($eventMap)) return ['peak' => 0, 'hist' => []]; @@ -273,7 +259,7 @@ function computePeakAndHistogram(array $eventMap): array { $level = 0.0; $peak = 0.0; - $hist = []; // [intLevel => floatDays] + $hist = []; for ($i = 0; $i < count($dates); $i++) { $d = $dates[$i]; @@ -283,7 +269,6 @@ function computePeakAndHistogram(array $eventMap): array { $intLevel = (int)round($level); if ($intLevel > $peak) $peak = $intLevel; - // segment length until next date if ($i < count($dates) - 1) { $dTs = ymdToTs($d); $nTs = ymdToTs($dates[$i + 1]); @@ -296,7 +281,6 @@ function computePeakAndHistogram(array $eventMap): array { } ksort($hist); - // hist days runden foreach ($hist as $k => $v) $hist[$k] = (int)round($v); return ['peak' => (int)$peak, 'hist' => $hist]; @@ -306,13 +290,22 @@ function computePeakAndHistogram(array $eventMap): array { Data aggregation ========================= */ -$rows = []; // Debug rows (HTML only, no excel) -$pivot = []; // direct revenue per year -$pivotB = []; // bundle revenue per year (alloc to leaf) -$meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]] +$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[productPk]['direct'][slot] += revenue +// slotAgg[productPk]['incl'][slot] += revenue +// slotAgg[productPk]['direct_ext'][slot] += extRevenue +// slotAgg[productPk]['incl_ext'][slot] += extRevenue +$slotAgg = []; + function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title): void { global $meta; if (!isset($meta[$productPk])) { @@ -333,19 +326,143 @@ function addRevenue(array &$pivotRef, int $productPk, int $year, float $revenueN } /** - * Eine Artikelzeile (aus Journal ODER direkt) verarbeiten + * Extern-Logik: + * - Amount External ist eine ANZAHL (nicht Umsatz). + * - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0) + * - fallback: 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) – Intervall Coloring + ========================= */ + +/** + * Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt) + */ +function addSlotItem(array &$slotItems, int $productPk, int $invoicePk, int $orderNo, 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) return; + + $slotItems[$productPk][] = [ + 'invoice_pk' => $invoicePk, + 'order_no' => $orderNo, + 'start_ts' => ymdToTs($startYmd), + 'end_ts' => ymdToTs($endYmd), + 'qty' => (float)$qty, + 'rev' => (float)$revenueNet, + 'rev_ext' => (float)$extRevenueNet, + 'start_ymd' => $startYmd, + 'end_ymd' => $endYmd, + ]; +} + +/** + * Slotting-Regel: + * - Aufträge werden nach start_ts sortiert, + * - bei gleichem start: zuerst der der später endet bekommt den "zweiten" Slot (also: end_ts DESC), + * - wenn dann noch gleich: nach order_no (oder invoice_pk) ASC. + * - Belegung: für qty=2 werden 2 Slots gesucht; wenn Slots frei werden (end < start), wiederverwendbar. + * + * Umsatzverteilung: + * - pro Auftrag & Produkt wird der UMSATZ auf die belegten Slots gleichmäßig verteilt (rev/qty) + * - analog für extern-umsatz. + */ +function computeOrderBasedSlots(array $itemsForProduct): array { + if (empty($itemsForProduct)) return [ + 'direct' => [], + 'direct_ext' => [] + ]; + + usort($itemsForProduct, function($a, $b){ + if ($a['start_ts'] !== $b['start_ts']) return $a['start_ts'] <=> $b['start_ts']; + if ($a['end_ts'] !== $b['end_ts']) return $b['end_ts'] <=> $a['end_ts']; // später endend zuerst + if ($a['order_no'] !== $b['order_no']) return $a['order_no'] <=> $b['order_no']; + return $a['invoice_pk'] <=> $b['invoice_pk']; + }); + + $slotEnd = []; // slotIndex => end_ts + $slotRevenue = []; // slotIndex => revenue + $slotRevenueExt = []; // slotIndex => revenueExt + + foreach ($itemsForProduct as $it) { + $qty = (int)round($it['qty']); + if ($qty <= 0) continue; + + $revPer = ($it['rev'] ?? 0.0) / $qty; + $extPer = ($it['rev_ext'] ?? 0.0) / $qty; + + // finde freie Slots / neue Slots + $assigned = []; + for ($k=0; $k<$qty; $k++) { + $slot = null; + + // freier Slot: end < start + foreach ($slotEnd as $idx => $endTs) { + if ($endTs < $it['start_ts']) { // streng <, damit gleicher Tag als parallel zählt + $slot = (int)$idx; + break; + } + } + if ($slot === null) { + $slot = count($slotEnd) + 1; // Slots sind 1-based + } + + // reservieren + $slotEnd[$slot] = $it['end_ts']; + $assigned[] = $slot; + + // Revenue sammeln + if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; + if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; + $slotRevenue[$slot] += $revPer; + $slotRevenueExt[$slot] += $extPer; + } + } + + ksort($slotRevenue); + ksort($slotRevenueExt); + + // runden (2 Nachkommastellen für Anzeige später, intern float ok) + return [ + 'direct' => $slotRevenue, + 'direct_ext' => $slotRevenueExt + ]; +} + +/* ========================= + Prozessierung einer Artikelzeile + ========================= */ + function processLineItem( Epirent $Epi, array &$rows, + array &$slotItemsDirect, + array &$slotItemsIncl, int $invoicePk, + int $invoiceNo, string $invoiceDate, int $invoiceYear, ?int $chapterId, object $li, bool $isFromJournal ): void { - global $pivot, $pivotB, $allYears, $eventsDirect, $eventsIncl; + global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl; $type = (int)($li->type ?? -1); $productPk = (int)($li->product_pk ?? 0); @@ -357,36 +474,41 @@ function processLineItem( $qty = (float)($li->amount_total ?? 0); if ($qty <= 0) $qty = 0.0; - // Zeitraum (für Peak/Histogramm) - $dateStart = (string)($li->date_start ?? ''); - $dateEnd = (string)($li->date_end ?? ''); - $dateStart = safeYmd($dateStart) ?: null; - $dateEnd = safeYmd($dateEnd) ?: null; + $dateStart = safeYmd((string)($li->date_start ?? '')) ?: null; + $dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null; - // Umsatz netto (für Pivot) $revenueNet = (float)($li->sum_total_net ?? 0); + $extRevenueNet = calcExternalRevenueNet($li); ensureMeta($Epi, $productPk, $productNo, $title); - // Jahr: AN RECHNUNGSDATUM gebunden (dein Wunsch) + // Jahr: Rechnungsdatum $year = $invoiceYear; if ($year) $allYears[$year] = true; - // Direct Umsatz (auch wenn Zeitraum evtl. leer ist) - if ($revenueNet != 0.0 && $year) { - addRevenue($pivot, $productPk, $year, $revenueNet); - } + // Direct Umsatz + if ($revenueNet != 0.0 && $year) addRevenue($pivot, $productPk, $year, $revenueNet); - // Direct Auslastung (nur wenn Zeitraum da) + // Direct Extern-Umsatz + if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet); + + // Peak/Histogram direct + incl (tagesbasiert) if ($dateStart && $dateEnd && $qty > 0) { addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qty); addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qty); } + // Slotting: direct + if ($dateStart && $dateEnd && $qty > 0) { + addSlotItem($slotItemsDirect, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); + addSlotItem($slotItemsIncl, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); + } + // Debug Row $rows[] = [ 'year' => $year, 'invoice_pk' => $invoicePk, + 'invoice_no' => $invoiceNo, 'invoice_date'=> $invoiceDate, 'chapter_id' => $chapterId ?? 0, 'source' => $isFromJournal ? 'journal' : 'direct', @@ -397,20 +519,17 @@ function processLineItem( 'date_end' => $dateEnd ?? '', 'qty' => $qty, 'revenue_net' => $revenueNet, + 'ext_rev_net' => $extRevenueNet, + 'amount_ext' => (float)($li->amount_external ?? 0), + 'amount_total'=> (float)($li->amount_total ?? 0), ]; - /* ===== Bundle-Auflösung ===== - - Bundle-Erkennung NUR über is_virtual (Fix!) - - Auslastung inkl Bundle: Komponenten bekommen qty*amount über Zeitraum - - Umsatz inkl Bundle: Umsatz wird proportional auf Leaf-Produkte verteilt - */ - $p = getProduct($Epi, $productPk); - $isBundle = (bool)($p->is_virtual ?? false); // ✅ FIX: nicht über materials ableiten + /* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */ + $leafMap = resolveBundleLeafMap($Epi, $productPk); + $isVirtualBundle = !(count($leafMap) === 1 && isset($leafMap[$productPk]) && (float)$leafMap[$productPk] === 1.0); - if ($isBundle) { - $leafMap = resolveBundleLeafMap($Epi, $productPk); - - // Umsatz allokieren + if ($isVirtualBundle) { + // Umsatz allokieren (inkl Bundle-Anteil) if ($revenueNet != 0.0 && $year) { $alloc = allocateBundleRevenue($Epi, $productPk, $revenueNet); foreach ($alloc as $leafPk => $leafRevenue) { @@ -420,7 +539,17 @@ function processLineItem( } } - // Auslastung allokieren + // Extern-Umsatz allokieren (inkl Bundle-Anteil) + if ($extRevenueNet != 0.0 && $year) { + $allocExt = allocateBundleRevenue($Epi, $productPk, $extRevenueNet); + foreach ($allocExt as $leafPk => $leafRevenue) { + $leafPk = (int)$leafPk; + ensureMeta($Epi, $leafPk, 0, ''); + addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue); + } + } + + // Auslastung allokieren (Peak/Histogramm inkl Bundle) if ($dateStart && $dateEnd && $qty > 0) { foreach ($leafMap as $leafPk => $amt) { $leafPk = (int)$leafPk; @@ -430,13 +559,38 @@ function processLineItem( addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qty * $amt); } } + + // Slotting allokieren (inkl Bundle): wir erzeugen SlotItems für Leaf-Produkte + if ($dateStart && $dateEnd && $qty > 0) { + // Revenue pro "Bundle-Item" wird in allocateBundleRevenue bereits verteilt. + $alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; + $allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; + + foreach ($leafMap as $leafPk => $amt) { + $leafPk = (int)$leafPk; + $amt = (float)$amt; + if ($leafPk <= 0 || $amt <= 0) continue; + + $leafRev = (float)($alloc[$leafPk] ?? 0.0); + $leafExt = (float)($allocExt[$leafPk] ?? 0.0); + + // qty_leaf = qty * amt + $qtyLeaf = $qty * $amt; + + ensureMeta($Epi, $leafPk, 0, ''); + addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qtyLeaf, $leafRev, $leafExt); + } + } } } /* ========================= - 1) Invoices holen + 1) Invoices holen + Daten sammeln ========================= */ +$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 : []; @@ -450,6 +604,7 @@ foreach ($invoiceList as $inv) { $invoiceDate = (string)($invObj->invoice_date ?? ''); $invoiceYear = yearFromDate($invoiceDate) ?? 0; + $invoiceNo = (int)($invObj->invoice_no ?? 0); $orderItems = $invObj->order_items ?? []; if (!is_array($orderItems)) $orderItems = []; @@ -458,93 +613,128 @@ foreach ($invoiceList as $inv) { $oiType = (int)($oi->type ?? -1); $oiPk = (int)($oi->primary_key ?? 0); - // 1) Kapitel-Zeile (type=5): chid ist primary_key der Kapitelzeile! + // 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, $invoicePk, $invoiceDate, $invoiceYear, $chapterId, $ji, true); + processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true); } continue; } - // 2) Direkte Artikel-Zeile ohne Kapitel (type=0) + // Direkte Artikelposition ohne Kapitel (type=0) if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) { - processLineItem($Epi, $rows, $invoicePk, $invoiceDate, $invoiceYear, null, $oi, false); + processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false); continue; } - - // Andere Typen ignorieren (Zwischensumme etc.) } } /* ========================= - 2) Years + Pivot Rows bauen + 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); -// Pivot Rows: pro Produkt => revenueDirect / revenueIncl und PeakDirect / PeakIncl + Histogramme $pivotRows = []; foreach ($meta as $productPk => $m) { $productPk = (int)$productPk; - // revenue direct/incl - $sumDirect = 0.0; - $sumIncl = 0.0; - $row = [ 'product_pk' => $productPk, 'product_no' => (int)($m['product_no'] ?? 0), 'title' => (string)($m['title'] ?? ''), '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'] ?? [], ]; + $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); + $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; - // Peak + Histogram $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']; - // Nur Produkte zeigen, die überhaupt irgendwo vorkommen (direct oder incl) - if ($row['total_direct'] == 0.0 && $row['total_incl'] == 0.0 && $row['peak_direct'] == 0 && $row['peak_incl'] == 0) { - continue; - } + // nur anzeigen, wenn was los ist + $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; } -// Sort: nach Total_incl absteigend (inkl Bundle-Anteil ist meist interessanter) usort($pivotRows, fn($a,$b) => ($b['total_incl'] <=> $a['total_incl'])); /* ========================= - 3) Excel Export (nur Pivot) + 4) Excel Export (nur Pivot) ========================= */ $exportDir = __DIR__ . '/exports'; @@ -560,10 +750,18 @@ $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Pivot'); -$cols = ['Produkt-PK','Artikel-Nr','Artikel','Peak','Peak (inkl. Bundle)','Summe','Summe (inkl. Bundle)']; +$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); @@ -606,10 +804,21 @@ foreach ($pivotRows as $r) { $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++; @@ -617,6 +826,14 @@ foreach ($pivotRows as $r) { $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++; @@ -625,13 +842,25 @@ foreach ($pivotRows as $r) { $sheet->setAutoFilter("A{$headerRow}:{$lastColLetter}{$headerRow}"); $sheet->freezePane("A" . ($headerRow + 1)); -// Save $writer = new Xlsx($spreadsheet); $writer->save($excelFilePath); /* ========================= - 4) HTML Ausgabe + 5) HTML Ausgabe ========================= */ + +function slotsToDisplay(array $slotMap): array { + // slotMap: [slot=>revenue] + ksort($slotMap); + $out = []; + foreach ($slotMap as $slot => $rev) { + $slot = (int)$slot; + if ($slot <= 0) continue; + $out[] = ['slot'=>$slot, 'rev'=>(float)$rev]; + } + return $out; +} + ?> @@ -647,14 +876,11 @@ $writer->save($excelFilePath); - - -