1325 lines
51 KiB
PHP
1325 lines
51 KiB
PHP
<?php
|
||
// ROI.php – Umsatz/Peak/Histogramm pro Artikel inkl. Bundleanteil + Extern-Umsatz
|
||
// + "Slot-Umsatz" (auftragsbasiert, nicht tagesbasiert) inkl. Bundleanteil in Modal
|
||
// liegt in /dist
|
||
|
||
require('../config.php');
|
||
require('../EpiApi.php');
|
||
require_once __DIR__ . '/../vendor/autoload.php';
|
||
date_default_timezone_set('Europe/Berlin');
|
||
|
||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||
|
||
$Epi = new Epirent();
|
||
|
||
/* =========================
|
||
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.
|
||
* Damit ist die anteilige Bundlepreis-Berechnung wieder wie in der funktionierenden Version.
|
||
*
|
||
* Ergebnis: [leafProductPk => amount]
|
||
* - 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 {
|
||
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;
|
||
}
|
||
|
||
/* =========================
|
||
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 (wie gehabt)
|
||
========================= */
|
||
|
||
// eventsDirect[productPk][ymd] += deltaQty
|
||
$eventsDirect = [];
|
||
$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];
|
||
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[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])) {
|
||
$p = getProduct($Epi, $productPk);
|
||
$meta[$productPk] = [
|
||
'product_pk' => $productPk,
|
||
'product_no' => $productNo ?: (int)($p->product_no ?? 0),
|
||
'title' => $title ?: (string)($p->name ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
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)
|
||
* - 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, $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);
|
||
|
||
$qty = (float)($li->amount_total ?? 0);
|
||
if ($qty <= 0) $qty = 0.0;
|
||
|
||
$dateStart = safeYmd((string)($li->date_start ?? '')) ?: null;
|
||
$dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null;
|
||
|
||
$revenueNet = (float)($li->sum_total_net ?? 0);
|
||
$extRevenueNet = calcExternalRevenueNet($li);
|
||
|
||
ensureMeta($Epi, $productPk, $productNo, $title);
|
||
|
||
// 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);
|
||
|
||
// 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',
|
||
'product_pk' => $productPk,
|
||
'product_no' => $productNo,
|
||
'title' => $title,
|
||
'date_start' => $dateStart ?? '',
|
||
'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 (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)
|
||
if ($revenueNet != 0.0 && $year) {
|
||
$alloc = allocateBundleRevenue($Epi, $productPk, $revenueNet);
|
||
foreach ($alloc 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) {
|
||
$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;
|
||
$amt = (float)$amt;
|
||
if ($leafPk <= 0 || $amt <= 0) continue;
|
||
ensureMeta($Epi, $leafPk, 0, '');
|
||
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 + 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 : [];
|
||
|
||
foreach ($invoiceList as $inv) {
|
||
$invoicePk = (int)($inv->primary_key ?? 0);
|
||
if ($invoicePk <= 0) 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);
|
||
|
||
$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, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Direkte Artikelposition ohne Kapitel (type=0)
|
||
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) {
|
||
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false);
|
||
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;
|
||
|
||
$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);
|
||
$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'];
|
||
|
||
// 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;
|
||
}
|
||
|
||
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);
|
||
|
||
/* =========================
|
||
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;
|
||
}
|
||
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||
<title>ROI – Umsatz pro Artikel / Jahr</title>
|
||
|
||
<link href="css/styles.css" rel="stylesheet" />
|
||
<link href="https://cdn.datatables.net/1.10.20/css/dataTables.bootstrap4.min.css" rel="stylesheet" crossorigin="anonymous" />
|
||
|
||
<script src="js/jquery-3.5.1.min.js"></script>
|
||
<script src="https://kit.fontawesome.com/93d71de8bc.js" crossorigin="anonymous"></script>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
|
||
|
||
<script src="https://cdn.datatables.net/1.10.20/js/jquery.dataTables.min.js" crossorigin="anonymous"></script>
|
||
<script src="https://cdn.datatables.net/1.10.20/js/dataTables.bootstrap4.min.js" crossorigin="anonymous"></script>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||
|
||
<style>
|
||
.kpi-updated { font-size: .82rem; opacity: .85; }
|
||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||
.btn-xs { padding:.15rem .4rem; font-size:.78rem; }
|
||
.nowrap { white-space: nowrap; }
|
||
.small-muted { font-size: .82rem; opacity:.85; }
|
||
</style>
|
||
|
||
<script>
|
||
$(function () {
|
||
$('#layoutSidenav_nav').load('../sources/getSidenav.php');
|
||
$('#footerholder').load('../sources/getFooter.php');
|
||
});
|
||
</script>
|
||
</head>
|
||
|
||
<body class="sb-nav-fixed">
|
||
<nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
|
||
<a class="navbar-brand" href="index.php">Epi Webview</a>
|
||
<button class="btn btn-link btn-sm order-1 order-lg-0" id="sidebarToggle"><i class="fas fa-bars"></i></button>
|
||
</nav>
|
||
|
||
<div id="layoutSidenav">
|
||
<div id="layoutSidenav_nav"></div>
|
||
<div id="layoutSidenav_content">
|
||
<main>
|
||
<div class="container-fluid">
|
||
<h1 class="mt-4">ROI</h1>
|
||
<ol class="breadcrumb mb-4">
|
||
<li class="breadcrumb-item active">Umsatz / Peak / Histogramm pro Artikel</li>
|
||
</ol>
|
||
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<div><i class="fas fa-table mr-1"></i> Pivot: Artikel × Rechnungsjahr</div>
|
||
<a class="btn btn-sm btn-success" href="<?php echo htmlspecialchars($excelDownloadUrl); ?>">
|
||
<i class="fas fa-file-excel"></i> Export nach Excel
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered" id="pivotTable" width="100%" cellspacing="0">
|
||
<thead>
|
||
<tr>
|
||
<th class="nowrap">Produkt-PK</th>
|
||
<th class="nowrap">Artikel-Nr</th>
|
||
<th>Artikel</th>
|
||
<th class="nowrap">Peak</th>
|
||
<th class="nowrap">Summe</th>
|
||
<th class="nowrap">davon extern</th>
|
||
<?php foreach ($years as $y): ?>
|
||
<th class="nowrap"><?php echo (int)$y; ?></th>
|
||
<?php endforeach; ?>
|
||
<th class="nowrap">Histogramm</th>
|
||
<th class="nowrap">Slots</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($pivotRows as $r): ?>
|
||
<?php
|
||
$peakDirect = (int)$r['peak_direct'];
|
||
$peakIncl = (int)$r['peak_incl'];
|
||
|
||
$histDirectB64 = base64Json($r['hist_direct']);
|
||
$histInclB64 = base64Json($r['hist_incl']);
|
||
|
||
$sumDirect = (float)$r['total_direct'];
|
||
$sumIncl = (float)$r['total_incl'];
|
||
|
||
$extDirect = (float)$r['total_ext_direct'];
|
||
$extIncl = (float)$r['total_ext_incl'];
|
||
|
||
$slotsDirect = slotsToDisplay($r['slots_direct'] ?? []);
|
||
$slotsIncl = slotsToDisplay($r['slots_incl'] ?? []);
|
||
$slotsDirectExt = slotsToDisplay($r['slots_direct_ext'] ?? []);
|
||
$slotsInclExt = slotsToDisplay($r['slots_incl_ext'] ?? []);
|
||
|
||
$slotsPayload = [
|
||
'direct' => $slotsDirect,
|
||
'incl' => $slotsIncl,
|
||
'direct_ext' => $slotsDirectExt,
|
||
'incl_ext' => $slotsInclExt,
|
||
];
|
||
$slotsB64 = base64Json($slotsPayload);
|
||
?>
|
||
<tr>
|
||
<td class="mono"><?php echo (int)$r['product_pk']; ?></td>
|
||
<td class="mono"><?php echo (int)$r['product_no']; ?></td>
|
||
<td><?php echo htmlspecialchars((string)$r['title']); ?></td>
|
||
|
||
<td class="nowrap">
|
||
<?php
|
||
echo $peakDirect;
|
||
echo ' <span class="text-muted">(' . $peakIncl . ')</span>';
|
||
?>
|
||
</td>
|
||
|
||
<td class="nowrap">
|
||
<?php
|
||
echo formatEuro($sumDirect);
|
||
echo ' <span class="text-muted">(' . formatEuro($sumIncl) . ')</span>';
|
||
?>
|
||
</td>
|
||
|
||
<td class="nowrap">
|
||
<?php
|
||
echo formatEuro($extDirect);
|
||
echo ' <span class="text-muted">(' . formatEuro($extIncl) . ')</span>';
|
||
?>
|
||
</td>
|
||
|
||
<?php foreach ($years as $y): ?>
|
||
<?php
|
||
$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'];
|
||
?>
|
||
<td class="nowrap">
|
||
<div><?php echo formatEuro($d); ?> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span></div>
|
||
<!--<div class="small-muted">ext: <?php echo formatEuro($de); ?> <span class="text-muted">(<?php echo formatEuro($ie); ?>)</span></div>-->
|
||
</td>
|
||
<?php endforeach; ?>
|
||
|
||
<td class="nowrap">
|
||
<button
|
||
class="btn btn-outline-primary btn-xs js-hist"
|
||
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
|
||
data-hist="<?php echo htmlspecialchars($histDirectB64); ?>"
|
||
data-peak="<?php echo (int)$peakDirect; ?>"
|
||
data-mode="direct">
|
||
Histogramm
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-outline-secondary btn-xs js-hist"
|
||
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
|
||
data-hist="<?php echo htmlspecialchars($histInclB64); ?>"
|
||
data-peak="<?php echo (int)$peakIncl; ?>"
|
||
data-mode="incl">
|
||
Histogramm inkl. Bundle
|
||
</button>
|
||
</td>
|
||
|
||
<td class="nowrap">
|
||
<button
|
||
class="btn btn-outline-dark btn-xs js-slots"
|
||
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
|
||
data-slots="<?php echo htmlspecialchars($slotsB64); ?>">
|
||
Slots (Auftragsbasiert)
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<small class="text-muted">
|
||
Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>.
|
||
Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span> (tagesbasiert).
|
||
Werte in Klammern = inkl. Bundle-Anteil (nur <b>virtuelle</b> Bundles werden zerlegt).
|
||
Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span> (amount_external ist Menge).
|
||
Slots: auftragsbasierte Slotbelegung nach Start, dann Ende DESC, dann Auftragsnr.
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<i class="fas fa-list mr-1"></i> Debug: verarbeitete Mietpositionen
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered" id="detailTable" width="100%" cellspacing="0">
|
||
<thead>
|
||
<tr>
|
||
<th>Jahr</th>
|
||
<th>Invoice</th>
|
||
<th>Rechnungsnr</th>
|
||
<th>Rechnungsdatum</th>
|
||
<th>Quelle</th>
|
||
<th>Chapter</th>
|
||
<th>Produkt-PK</th>
|
||
<th>Artikel-Nr</th>
|
||
<th>Artikel</th>
|
||
<th>Start</th>
|
||
<th>Ende</th>
|
||
<th>Menge</th>
|
||
<th>Ext-Menge</th>
|
||
<th>Umsatz netto</th>
|
||
<th>Umsatz extern</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($rows as $r): ?>
|
||
<tr>
|
||
<td><?php echo (int)$r['year']; ?></td>
|
||
<td class="mono"><?php echo (int)$r['invoice_pk']; ?></td>
|
||
<td class="mono"><?php echo (int)$r['invoice_no']; ?></td>
|
||
<td class="mono"><?php echo htmlspecialchars((string)$r['invoice_date']); ?></td>
|
||
<td><?php echo htmlspecialchars((string)$r['source']); ?></td>
|
||
<td class="mono"><?php echo (int)$r['chapter_id']; ?></td>
|
||
<td class="mono"><?php echo (int)$r['product_pk']; ?></td>
|
||
<td class="mono"><?php echo (int)$r['product_no']; ?></td>
|
||
<td><?php echo htmlspecialchars((string)$r['title']); ?></td>
|
||
<td class="mono"><?php echo htmlspecialchars((string)$r['date_start']); ?></td>
|
||
<td class="mono"><?php echo htmlspecialchars((string)$r['date_end']); ?></td>
|
||
<td class="mono"><?php echo htmlspecialchars((string)$r['qty']); ?></td>
|
||
<td class="mono"><?php echo htmlspecialchars((string)($r['amount_ext'] ?? '0')); ?></td>
|
||
<td class="nowrap"><?php echo formatEuro((float)$r['revenue_net']); ?></td>
|
||
<td class="nowrap"><?php echo formatEuro((float)$r['ext_rev_net']); ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<small class="text-muted">
|
||
Wenn ein Artikel “ohne Kapitel” fehlt, muss er hier als Quelle <b>direct</b> auftauchen.
|
||
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet.
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
<div id="footerholder"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Histogram Modal -->
|
||
<div class="modal fade" id="histModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg" role="document">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Histogramm</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<canvas id="histChart" height="140"></canvas>
|
||
<div class="mt-2 text-muted">
|
||
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) | Y-Achse: Tage
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slots Modal -->
|
||
<div class="modal fade" id="slotsModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg" role="document">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Slots</h5>
|
||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-2 text-muted">
|
||
Slot-Umsatz ist <b>auftragsbasiert</b>: Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt.
|
||
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles).
|
||
</div>
|
||
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-bordered" id="slotsTable">
|
||
<thead>
|
||
<tr>
|
||
<th class="nowrap">Slot</th>
|
||
<th class="nowrap">Umsatz</th>
|
||
<th class="nowrap">Umsatz inkl. Bundle</th>
|
||
<th class="nowrap">davon extern</th>
|
||
<th class="nowrap">davon extern inkl. Bundle</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="mt-2">
|
||
<canvas id="slotsChart" height="130"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let histChart = null;
|
||
let slotsChart = null;
|
||
|
||
function decodeB64Json(b64) {
|
||
try {
|
||
const json = atob(b64);
|
||
return JSON.parse(json);
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function openHistogram(title, mode, peak, histObj) {
|
||
const labels = [];
|
||
const data = [];
|
||
const p = parseInt(peak || 0, 10);
|
||
|
||
for (let i = 1; i <= p; i++) {
|
||
labels.push(String(i));
|
||
data.push(histObj[String(i)] ? parseInt(histObj[String(i)], 10) : 0);
|
||
}
|
||
|
||
$('#histModal .modal-title').text(`${title} – ${mode === 'incl' ? 'inkl. Bundle' : 'ohne Bundle'}`);
|
||
|
||
const ctx = document.getElementById('histChart');
|
||
if (histChart) { histChart.destroy(); histChart = null; }
|
||
|
||
histChart = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: { labels, datasets: [{ label: 'Tage', data }] },
|
||
options: {
|
||
responsive: true,
|
||
animation: false,
|
||
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
|
||
}
|
||
});
|
||
|
||
$('#histModal').modal('show');
|
||
}
|
||
|
||
function fmtEuro(n) {
|
||
const sign = n < 0 ? '-' : '';
|
||
n = Math.abs(n);
|
||
return sign + n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||
}
|
||
|
||
function openSlots(title, payload) {
|
||
$('#slotsModal .modal-title').text(`${title} – Slot-Umsatz (auftragsbasiert)`);
|
||
|
||
const direct = payload.direct || [];
|
||
const incl = payload.incl || [];
|
||
const directExt = payload.direct_ext || [];
|
||
const inclExt = payload.incl_ext || [];
|
||
|
||
// maps slot->rev
|
||
const mapD = {};
|
||
const mapI = {};
|
||
const mapDE = {};
|
||
const mapIE = {};
|
||
|
||
direct.forEach(x => mapD[String(x.slot)] = Number(x.rev || 0));
|
||
incl.forEach(x => mapI[String(x.slot)] = Number(x.rev || 0));
|
||
directExt.forEach(x => mapDE[String(x.slot)] = Number(x.rev || 0));
|
||
inclExt.forEach(x => mapIE[String(x.slot)] = Number(x.rev || 0));
|
||
|
||
const slots = new Set([
|
||
...Object.keys(mapD), ...Object.keys(mapI),
|
||
...Object.keys(mapDE), ...Object.keys(mapIE)
|
||
]);
|
||
|
||
const slotNums = Array.from(slots).map(s => parseInt(s,10)).filter(n => n>0).sort((a,b)=>a-b);
|
||
|
||
const $tb = $('#slotsTable tbody');
|
||
$tb.empty();
|
||
|
||
const labels = [];
|
||
const dataD = [];
|
||
const dataI = [];
|
||
|
||
slotNums.forEach(s => {
|
||
const sd = mapD[String(s)] || 0;
|
||
const si = mapI[String(s)] || 0;
|
||
const sde = mapDE[String(s)] || 0;
|
||
const sie = mapIE[String(s)] || 0;
|
||
|
||
$tb.append(`
|
||
<tr>
|
||
<td class="mono">${s}</td>
|
||
<td class="nowrap">${fmtEuro(sd)}</td>
|
||
<td class="nowrap text-muted">(${fmtEuro(si)})</td>
|
||
<td class="nowrap">${fmtEuro(sde)}</td>
|
||
<td class="nowrap text-muted">(${fmtEuro(sie)})</td>
|
||
</tr>
|
||
`);
|
||
|
||
labels.push(String(s));
|
||
dataD.push(sd);
|
||
dataI.push(si);
|
||
});
|
||
|
||
const ctx = document.getElementById('slotsChart');
|
||
if (slotsChart) { slotsChart.destroy(); slotsChart = null; }
|
||
|
||
slotsChart = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{ label: 'Umsatz', data: dataD },
|
||
{ label: 'Umsatz inkl. Bundle', data: dataI }
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
animation: false,
|
||
scales: {
|
||
y: { beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
|
||
$('#slotsModal').modal('show');
|
||
}
|
||
|
||
$(function() {
|
||
$('#pivotTable').DataTable({ pageLength: 50, order: [[4,'desc']] });
|
||
$('#detailTable').DataTable({ pageLength: 50, order: [[1,'desc']] });
|
||
|
||
$(document).on('click', '.js-hist', function() {
|
||
const title = $(this).data('title') || '';
|
||
const mode = $(this).data('mode') || 'direct';
|
||
const peak = $(this).data('peak') || 0;
|
||
const b64 = $(this).data('hist') || '';
|
||
const histObj = decodeB64Json(b64);
|
||
openHistogram(title, mode, peak, histObj);
|
||
});
|
||
|
||
$(document).on('click', '.js-slots', function() {
|
||
const title = $(this).data('title') || '';
|
||
const b64 = $(this).data('slots') || '';
|
||
const payload = decodeB64Json(b64);
|
||
openSlots(title, payload);
|
||
});
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|