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
Epi Webview
ROI
Umsatz / Peak / Histogramm pro Artikel
Aktuell: –
Aktuell: alle Jahre
Bitte oben einen Zeitraum auswählen und auf Zeitraum berechnen klicken,
oder Alles berechnen für die vollständige Auswertung.
Produkt-PK
Artikel-Nr
Artikel
Peak
Summe
davon extern
Histogramm
Slots
Kunden
$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;
?>
• ' .
implode(' • ', array_map(
fn($t) => htmlspecialchars((string)$t, ENT_QUOTES),
$r['older_titles']
));
?>
()
()
()
()
Histogramm
inkl. Bundle
Slots
>
Kunden 0) echo '(' . (int)$customersCount . ')'; ?>
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 .
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).
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) | Y-Achse: Tage
Slot-Umsatz ist auftragsbasiert : Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt.
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles).
Storno bucht Umsatz auf die gleichen Slots zurück und gibt die Slots frei (SIGNED).
Slot
Umsatz
Umsatz inkl. Bundle
davon extern
davon extern inkl. Bundle
Umsatz pro Kunde für diesen Artikel, gefiltert auf den oben gewählten Zeitraum.
Umsatz = direkt auf diesem Artikel abgerechnet.
inkl. Bundle = zusätzlich der Anteil, wenn der Artikel Bestandteil eines virtuellen Bundles war.
#
Kunden-PK
Kunde
Umsatz
Umsatz inkl. Bundle
davon extern
davon extern inkl. Bundle
Summe: