ROI: Stornierungen klammern jetzt die Zugehörige Buchung aus

This commit is contained in:
2026-02-25 15:39:20 +01:00
parent 85c7108091
commit 81a7960af7
2 changed files with 251 additions and 137 deletions

View File

@@ -1,6 +1,6 @@
# EPIWebview
- **aktuellste stable Version:** 1.9.0
- **aktuellste stable Version:** 1.10.2
- **Lizenz:** Creative Commons Attribution-NonCommercial-ShareAlike 4.0 (CC BY-NC-SA 4.0). Einsehbar unter: (LICENSE.MD)
- **Kompatibilität:** Erweiterung für **Epirent** und **CrewBrain**
@@ -17,7 +17,7 @@ Die Anwendung ist speziell für den Einsatz in Lagerprozessen entwickelt.
- **Check-In / Check-Out Übersicht**: Lagermonitor
- **Integration mit Epirent API**: Vollständig kompatibel mit bestehenden Epirent-Systemen.
- **Integration mit Crewbrain**: Anzeige einer Aufgabenliste aus CrewBraingit
- **Integration mit Crewbrain**: Anzeige einer Aufgabenliste aus CrewBrain
---
@@ -26,7 +26,7 @@ Die Anwendung ist speziell für den Einsatz in Lagerprozessen entwickelt.
## Systemanforderungen
- **Server:** PHP ≥ 8.2, Apache oder Nginx
- Achtung !!: Für die Return of Invest Funktion oder den Warengruppencheck / Imagechecks sollten in der php.ini die max_execution_time und die maximale Dateigröße deutlich nach oben korrigiert werden. Die ROI Funktion kann gut und gerne 20 Minuten laden!
- Achtung !!: Für die Return of Invest Funktion oder den Warengruppencheck / Imagechecks sollten in der php.ini die max_execution_time und die maximale Dateigröße deutlich nach oben korrigiert werden. Die ROI Funktion kann gut und gerne 20 Minuten laden!. Foglende PHP Funktionen müssen aktiviert werden: curl, gzcompress, Imagick, gd
- **Client:** Aktueller Browser (Chrome, Edge, Firefox, Safari)
- **Datenquelle:** Bestehende Epirent-Installation mit aktivierter API sowie optional CrewBrain

382
dist/ROI.php vendored
View File

@@ -94,12 +94,7 @@ function getRentPrice(Epirent $Epi, int $productPk): float {
/**
* 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;
@@ -222,11 +217,10 @@ function getJournalByChapter(Epirent $Epi, int $chapterId): array {
}
/* =========================
Interval aggregation (Peak/Histogram) tagesbasiert (wie gehabt)
Interval aggregation (Peak/Histogram) tagesbasiert
========================= */
// eventsDirect[productPk][ymd] += deltaQty
$eventsDirect = [];
$eventsDirect = []; // eventsDirect[productPk][ymd] += deltaQty
$eventsIncl = []; // direct + bundle
function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void {
@@ -264,6 +258,8 @@ function computePeakAndHistogram(array $eventMap): array {
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);
@@ -300,10 +296,6 @@ $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 {
@@ -329,7 +321,6 @@ function addRevenue(array &$pivotRef, int $productPk, int $year, float $revenueN
* 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);
@@ -346,103 +337,219 @@ function calcExternalRevenueNet(object $li): float {
}
/* =========================
Slot (auftragsbasiert) Intervall Coloring
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 $orderNo, string $startYmd, string $endYmd, float $qty, float $revenueNet, float $extRevenueNet): void {
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) return;
if ($qty == 0.0) return;
if (!isset($slotItems[$productPk])) $slotItems[$productPk] = [];
$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,
'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-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.
* 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.
*
* Umsatzverteilung:
* - pro Auftrag & Produkt wird der UMSATZ auf die belegten Slots gleichmäßig verteilt (rev/qty)
* - analog für extern-umsatz.
* Ergebnis:
* - 'direct' => [slot => revenue]
* - 'direct_ext' => [slot => extRevenue]
*/
function computeOrderBasedSlots(array $itemsForProduct): array {
if (empty($itemsForProduct)) return [
'direct' => [],
'direct_ext' => []
];
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'] !== $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'];
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);
});
$slotEnd = []; // slotIndex => end_ts
$slotRevenue = []; // slotIndex => revenue
$slotRevenueExt = []; // slotIndex => revenueExt
// 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) {
$qty = (int)round($it['qty']);
if ($qty <= 0) continue;
$startTs = (int)($it['start_ts'] ?? 0);
$endTs = (int)($it['end_ts'] ?? 0);
if ($startTs <= 0 || $endTs <= 0) continue;
if ($endTs < $startTs) continue;
$revPer = ($it['rev'] ?? 0.0) / $qty;
$extPer = ($it['rev_ext'] ?? 0.0) / $qty;
// vor jeder Aktion: abgelaufene Slots freigeben
$freeExpired($startTs);
// finde freie Slots / neue Slots
$assigned = [];
for ($k=0; $k<$qty; $k++) {
$slot = null;
$qtySigned = (float)($it['qty'] ?? 0.0);
if ($qtySigned == 0.0) continue;
// 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;
$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) {
$slot = count($slotEnd) + 1; // Slots sind 1-based
}
// reservieren
$slotEnd[$slot] = $it['end_ts'];
$assigned[] = $slot;
if ($slot === null) break;
// Revenue sammeln
if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0;
if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0;
$slotRevenue[$slot] += $revPer;
$slotRevenueExt[$slot] += $extPer;
// 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;
}
}
}
ksort($slotRevenue);
ksort($slotRevenueExt);
// --- Cleanup: Slots ohne Umsatz entfernen + neu durchnummerieren ---
$eps = 0.00001;
$allSlots = array_unique(array_merge(array_keys($slotRevenue), array_keys($slotRevenueExt)));
sort($allSlots);
// runden (2 Nachkommastellen für Anzeige später, intern float ok)
return [
'direct' => $slotRevenue,
'direct_ext' => $slotRevenueExt
];
$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];
}
/* =========================
@@ -454,13 +561,15 @@ function processLineItem(
array &$rows,
array &$slotItemsDirect,
array &$slotItemsIncl,
int $orderPk,
int $invoicePk,
int $invoiceNo,
string $invoiceDate,
int $invoiceYear,
?int $chapterId,
object $li,
bool $isFromJournal
bool $isFromJournal,
bool $invoiceIsCredit // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0)
): void {
global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl;
@@ -471,15 +580,23 @@ function processLineItem(
$title = (string)($li->title ?? '');
$productNo = (int)($li->product_no ?? 0);
$qty = (float)($li->amount_total ?? 0);
if ($qty <= 0) $qty = 0.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;
$revenueNet = (float)($li->sum_total_net ?? 0);
$extRevenueNet = calcExternalRevenueNet($li);
ensureMeta($Epi, $productPk, $productNo, $title);
// Jahr: Rechnungsdatum
@@ -493,15 +610,16 @@ function processLineItem(
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);
// -> 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
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);
// 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
@@ -517,11 +635,13 @@ function processLineItem(
'title' => $title,
'date_start' => $dateStart ?? '',
'date_end' => $dateEnd ?? '',
'qty' => $qty,
'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!) ===== */
@@ -549,20 +669,19 @@ function processLineItem(
}
}
// Auslastung allokieren (Peak/Histogramm inkl Bundle)
if ($dateStart && $dateEnd && $qty > 0) {
// 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, $qty * $amt);
addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qtySigned * $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.
// 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) : [];
@@ -574,11 +693,10 @@ function processLineItem(
$leafRev = (float)($alloc[$leafPk] ?? 0.0);
$leafExt = (float)($allocExt[$leafPk] ?? 0.0);
// qty_leaf = qty * amt
$qtyLeaf = $qty * $amt;
$qtyLeafSigned = $qtySigned * $amt;
ensureMeta($Epi, $leafPk, 0, '');
addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qtyLeaf, $leafRev, $leafExt);
addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtyLeafSigned, $leafRev, $leafExt);
}
}
}
@@ -605,6 +723,10 @@ foreach ($invoiceList as $inv) {
$invoiceDate = (string)($invObj->invoice_date ?? '');
$invoiceYear = yearFromDate($invoiceDate) ?? 0;
$invoiceNo = (int)($invObj->invoice_no ?? 0);
$orderPk = (int)($invObj->order_pk ?? 0);
// Storno-Erkennung (Credit Note): sum_net < 0
$invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0;
$orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = [];
@@ -619,14 +741,14 @@ foreach ($invoiceList as $inv) {
$journalItems = getJournalByChapter($Epi, $chapterId);
foreach ($journalItems as $ji) {
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true);
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit);
}
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);
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit);
continue;
}
}
@@ -717,7 +839,6 @@ foreach ($meta as $productPk => $m) {
$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 ||
@@ -850,13 +971,14 @@ $writer->save($excelFilePath);
========================= */
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];
$v = (float)$rev;
if (abs($v) < 0.0000001) $v = 0.0;
$out[] = ['slot'=>$slot, 'rev'=>$v];
}
return $out;
}
@@ -884,11 +1006,9 @@ function slotsToDisplay(array $slotMap): array {
<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>
@@ -974,37 +1094,28 @@ function slotsToDisplay(array $slotMap): array {
<td><?php echo htmlspecialchars((string)$r['title']); ?></td>
<td class="nowrap">
<?php
echo $peakDirect;
echo ' <span class="text-muted">(' . $peakIncl . ')</span>';
?>
<?php echo $peakDirect; ?>
<span class="text-muted">(<?php echo $peakIncl; ?>)</span>
</td>
<td class="nowrap">
<?php
echo formatEuro($sumDirect);
echo ' <span class="text-muted">(' . formatEuro($sumIncl) . ')</span>';
?>
<?php echo formatEuro($sumDirect); ?>
<span class="text-muted">(<?php echo formatEuro($sumIncl); ?>)</span>
</td>
<td class="nowrap">
<?php
echo formatEuro($extDirect);
echo ' <span class="text-muted">(' . formatEuro($extIncl) . ')</span>';
?>
<?php echo formatEuro($extDirect); ?>
<span class="text-muted">(<?php echo 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>-->
<?php echo formatEuro($d); ?>
<span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span>
</td>
<?php endforeach; ?>
@@ -1024,7 +1135,7 @@ function slotsToDisplay(array $slotMap): array {
data-hist="<?php echo htmlspecialchars($histInclB64); ?>"
data-peak="<?php echo (int)$peakIncl; ?>"
data-mode="incl">
Histogramm inkl. Bundle
inkl. Bundle
</button>
</td>
@@ -1033,7 +1144,7 @@ function slotsToDisplay(array $slotMap): array {
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)
Slots
</button>
</td>
</tr>
@@ -1045,8 +1156,9 @@ function slotsToDisplay(array $slotMap): array {
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.
Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span>.
Slots: auftragsbasiert, SIGNED (Storno gibt Slots frei und bucht auf die gleichen Zeiträume zurück).
Storno-Erkennung: Rechnung mit <span class="mono">sum_net &lt; 0</span>.
</small>
</div>
</div>
@@ -1073,9 +1185,11 @@ function slotsToDisplay(array $slotMap): array {
<th>Start</th>
<th>Ende</th>
<th>Menge</th>
<th>Menge (signed)</th>
<th>Ext-Menge</th>
<th>Umsatz netto</th>
<th>Umsatz extern</th>
<th>Credit</th>
</tr>
</thead>
<tbody>
@@ -1093,16 +1207,18 @@ function slotsToDisplay(array $slotMap): array {
<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['qty_signed'] ?? $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>
<td class="mono"><?php echo (int)($r['is_credit'] ?? 0); ?></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.
Menge(signed) ist für Peak/Slots relevant (Storno-Rechnung -> negativ).
</small>
</div>
</div>
@@ -1127,7 +1243,7 @@ function slotsToDisplay(array $slotMap): array {
<div class="modal-body">
<canvas id="histChart" height="140"></canvas>
<div class="mt-2 text-muted">
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) &nbsp;|&nbsp; Y-Achse: Tage
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) | Y-Achse: Tage
</div>
</div>
</div>
@@ -1148,6 +1264,7 @@ function slotsToDisplay(array $slotMap): array {
<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).
Storno bucht Umsatz auf die gleichen Slots zurück und gibt die Slots frei (SIGNED).
</div>
<div class="table-responsive">
@@ -1228,7 +1345,6 @@ function openSlots(title, payload) {
const directExt = payload.direct_ext || [];
const inclExt = payload.incl_ext || [];
// maps slot->rev
const mapD = {};
const mapI = {};
const mapDE = {};
@@ -1289,9 +1405,7 @@ function openSlots(title, payload) {
options: {
responsive: true,
animation: false,
scales: {
y: { beginAtZero: true }
}
scales: { y: { beginAtZero: true } }
}
});
@@ -1321,4 +1435,4 @@ $(function() {
</script>
</body>
</html>
</html>