From 0b6ae3d933f50ca7951d0f3e0f4d8ee8115833bc Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Mon, 20 Jul 2026 15:16:32 +0200 Subject: [PATCH] Umsatz pro Artikel pro Kund ein ROI, Pivot table angepasst --- dist/ROI.php | 291 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 275 insertions(+), 16 deletions(-) diff --git a/dist/ROI.php b/dist/ROI.php index 43f68ff..ee4b0b2 100644 --- a/dist/ROI.php +++ b/dist/ROI.php @@ -217,6 +217,78 @@ function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenue 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 ========================= */ @@ -318,6 +390,41 @@ $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])) { @@ -600,7 +707,8 @@ function processLineItem( ?int $chapterId, object $li, bool $isFromJournal, - bool $invoiceIsCredit // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0) + 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; @@ -640,6 +748,11 @@ function processLineItem( // 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) { @@ -681,9 +794,11 @@ function processLineItem( 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) { - $alloc = allocateBundleRevenue($Epi, $productPk, $revenueNet); - foreach ($alloc as $leafPk => $leafRevenue) { + foreach ($allocForCustomer as $leafPk => $leafRevenue) { $leafPk = (int)$leafPk; ensureMeta($Epi, $leafPk, 0, ''); addRevenue($pivotB, $leafPk, $year, (float)$leafRevenue); @@ -692,14 +807,25 @@ function processLineItem( // Extern-Umsatz allokieren (inkl Bundle-Anteil) if ($extRevenueNet != 0.0 && $year) { - $allocExt = allocateBundleRevenue($Epi, $productPk, $extRevenueNet); - foreach ($allocExt as $leafPk => $leafRevenue) { + 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) { @@ -713,16 +839,14 @@ function processLineItem( // Slotting allokieren (inkl Bundle) – signed qty! if ($dateStart && $dateEnd && $qtySigned != 0.0) { - $alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; - $allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; - + // 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)($alloc[$leafPk] ?? 0.0); - $leafExt = (float)($allocExt[$leafPk] ?? 0.0); + $leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0); + $leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0); $qtyLeafSigned = $qtySigned * $amt; @@ -783,6 +907,9 @@ foreach ($invoiceList as $inv) { // 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 = []; @@ -796,14 +923,14 @@ foreach ($invoiceList as $inv) { $journalItems = getJournalByChapter($Epi, $chapterId); foreach ($journalItems as $ji) { - processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit); + 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); + processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit, $customer); continue; } } @@ -870,8 +997,14 @@ foreach ($meta as $productPk => $m) { '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; @@ -1064,6 +1197,7 @@ function slotsToDisplay(array $slotMap): array { + @@ -1072,6 +1206,7 @@ function slotsToDisplay(array $slotMap): array { + @@ -1170,6 +1305,7 @@ function slotsToDisplay(array $slotMap): array { Histogramm Slots + Kunden @@ -1199,6 +1335,10 @@ function slotsToDisplay(array $slotMap): array { '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; ?> @@ -1274,6 +1414,16 @@ function slotsToDisplay(array $slotMap): array { Slots + + + + @@ -1416,13 +1566,64 @@ function slotsToDisplay(array $slotMap): array { + + +