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 # 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) - **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** - **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 - **Check-In / Check-Out Übersicht**: Lagermonitor
- **Integration mit Epirent API**: Vollständig kompatibel mit bestehenden Epirent-Systemen. - **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 ## Systemanforderungen
- **Server:** PHP ≥ 8.2, Apache oder Nginx - **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) - **Client:** Aktueller Browser (Chrome, Edge, Firefox, Safari)
- **Datenquelle:** Bestehende Epirent-Installation mit aktivierter API sowie optional CrewBrain - **Datenquelle:** Bestehende Epirent-Installation mit aktivierter API sowie optional CrewBrain

380
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. * WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles.
* Damit ist die anteilige Bundlepreis-Berechnung wieder wie in der funktionierenden Version.
*
* Ergebnis: [leafProductPk => amount] * 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 { function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array {
global $bundleLeafCache; 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[productPk][ymd] += deltaQty
$eventsDirect = [];
$eventsIncl = []; // direct + bundle $eventsIncl = []; // direct + bundle
function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void { 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++) { for ($i = 0; $i < count($dates); $i++) {
$d = $dates[$i]; $d = $dates[$i];
$level += (float)$eventMap[$d]; $level += (float)$eventMap[$d];
// clamp, damit kein negativer Bestand "Tage" erzeugt
if ($level < 0) $level = 0; if ($level < 0) $level = 0;
$intLevel = (int)round($level); $intLevel = (int)round($level);
@@ -300,10 +296,6 @@ $meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]]
$allYears = []; $allYears = [];
// Auftragsbasierte "Slots" (direct/incl) pro Produkt // 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 = []; $slotAgg = [];
function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title): void { 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: * Extern-Logik:
* - Amount External ist eine ANZAHL (nicht Umsatz). * - Amount External ist eine ANZAHL (nicht Umsatz).
* - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0) * - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0)
* - fallback: 0
*/ */
function calcExternalRevenueNet(object $li): float { function calcExternalRevenueNet(object $li): float {
$amountTotal = (float)($li->amount_total ?? 0); $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) * 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); $startYmd = safeYmd($startYmd);
$endYmd = safeYmd($endYmd); $endYmd = safeYmd($endYmd);
if ($productPk <= 0) return; if ($productPk <= 0) return;
if (!$startYmd || !$endYmd) return; if (!$startYmd || !$endYmd) return;
if ($qty <= 0) return; if ($qty == 0.0) return;
if (!isset($slotItems[$productPk])) $slotItems[$productPk] = [];
$slotItems[$productPk][] = [ $slotItems[$productPk][] = [
'invoice_pk' => $invoicePk, 'product_pk' => $productPk,
'order_no' => $orderNo, 'invoice_pk' => $invoicePk,
'start_ts' => ymdToTs($startYmd), 'order_pk' => $orderPk,
'end_ts' => ymdToTs($endYmd), 'invoice_no' => $invoiceNo,
'qty' => (float)$qty, 'start_ts' => ymdToTs($startYmd),
'rev' => (float)$revenueNet, 'end_ts' => ymdToTs($endYmd),
'rev_ext' => (float)$extRevenueNet, 'qty' => (float)$qty, // signed erlaubt
'start_ymd' => $startYmd, 'rev' => (float)$revenueNet,
'end_ymd' => $endYmd, 'rev_ext' => (float)$extRevenueNet,
'start_ymd' => $startYmd,
'end_ymd' => $endYmd,
]; ];
} }
/** /**
* Slotting-Regel: * Slotting (SIGNED, Storno gibt Slots wieder frei):
* - Aufträge werden nach start_ts sortiert, * - Positive qty belegt Slots.
* - bei gleichem start: zuerst der der später endet bekommt den "zweiten" Slot (also: end_ts DESC), * - Negative qty bucht Umsatz zurück UND gibt Slots frei.
* - wenn dann noch gleich: nach order_no (oder invoice_pk) ASC. * - Priorität bei Storno: zuerst Slots mit gleichem Zeitraum (gleicher release_ts) abbauen.
* - Belegung: für qty=2 werden 2 Slots gesucht; wenn Slots frei werden (end < start), wiederverwendbar.
* *
* Umsatzverteilung: * Ergebnis:
* - pro Auftrag & Produkt wird der UMSATZ auf die belegten Slots gleichmäßig verteilt (rev/qty) * - 'direct' => [slot => revenue]
* - analog für extern-umsatz. * - 'direct_ext' => [slot => extRevenue]
*/ */
function computeOrderBasedSlots(array $itemsForProduct): array { function computeOrderBasedSlots(array $itemsForProduct): array {
if (empty($itemsForProduct)) return [ if (empty($itemsForProduct)) {
'direct' => [], return ['direct' => [], 'direct_ext' => []];
'direct_ext' => [] }
];
// Sortierung: start ASC, end DESC, invoice_no ASC, invoice_pk ASC
usort($itemsForProduct, function($a, $b){ usort($itemsForProduct, function($a, $b){
if ($a['start_ts'] !== $b['start_ts']) return $a['start_ts'] <=> $b['start_ts']; if (($a['start_ts'] ?? 0) !== ($b['start_ts'] ?? 0)) return ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0);
if ($a['end_ts'] !== $b['end_ts']) return $b['end_ts'] <=> $a['end_ts']; // später endend zuerst if (($a['end_ts'] ?? 0) !== ($b['end_ts'] ?? 0)) return ($b['end_ts'] ?? 0) <=> ($a['end_ts'] ?? 0);
if ($a['order_no'] !== $b['order_no']) return $a['order_no'] <=> $b['order_no']; if (($a['invoice_no'] ?? 0) !== ($b['invoice_no'] ?? 0)) return ($a['invoice_no'] ?? 0) <=> ($b['invoice_no'] ?? 0);
return $a['invoice_pk'] <=> $b['invoice_pk']; return ($a['invoice_pk'] ?? 0) <=> ($b['invoice_pk'] ?? 0);
}); });
$slotEnd = []; // slotIndex => end_ts // Active slots: slotIndex => release_ts
$slotRevenue = []; // slotIndex => revenue $active = [];
$slotRevenueExt = []; // slotIndex => revenueExt // 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) { foreach ($itemsForProduct as $it) {
$qty = (int)round($it['qty']); $startTs = (int)($it['start_ts'] ?? 0);
if ($qty <= 0) continue; $endTs = (int)($it['end_ts'] ?? 0);
if ($startTs <= 0 || $endTs <= 0) continue;
if ($endTs < $startTs) continue;
$revPer = ($it['rev'] ?? 0.0) / $qty; // vor jeder Aktion: abgelaufene Slots freigeben
$extPer = ($it['rev_ext'] ?? 0.0) / $qty; $freeExpired($startTs);
// finde freie Slots / neue Slots $qtySigned = (float)($it['qty'] ?? 0.0);
$assigned = []; if ($qtySigned == 0.0) continue;
for ($k=0; $k<$qty; $k++) {
$slot = null;
// freier Slot: end < start $qtyAbs = (int)round(abs($qtySigned));
foreach ($slotEnd as $idx => $endTs) { if ($qtyAbs <= 0) continue;
if ($endTs < $it['start_ts']) { // streng <, damit gleicher Tag als parallel zählt
$slot = (int)$idx; $rev = (float)($it['rev'] ?? 0.0);
break; $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 if ($slot === null) break;
$slotEnd[$slot] = $it['end_ts'];
$assigned[] = $slot;
// Revenue sammeln // Umsatz auf Slot zurückbuchen
if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0;
if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0;
$slotRevenue[$slot] += $revPer; $slotRevenue[$slot] += $revPer; // revPer ist i.d.R. negativ
$slotRevenueExt[$slot] += $extPer; $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); // --- Cleanup: Slots ohne Umsatz entfernen + neu durchnummerieren ---
ksort($slotRevenueExt); $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) $kept = [];
return [ foreach ($allSlots as $s) {
'direct' => $slotRevenue, $rev = (float)($slotRevenue[$s] ?? 0.0);
'direct_ext' => $slotRevenueExt $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 &$rows,
array &$slotItemsDirect, array &$slotItemsDirect,
array &$slotItemsIncl, array &$slotItemsIncl,
int $orderPk,
int $invoicePk, int $invoicePk,
int $invoiceNo, int $invoiceNo,
string $invoiceDate, string $invoiceDate,
int $invoiceYear, int $invoiceYear,
?int $chapterId, ?int $chapterId,
object $li, object $li,
bool $isFromJournal bool $isFromJournal,
bool $invoiceIsCredit // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0)
): void { ): void {
global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl; global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl;
@@ -471,15 +580,23 @@ function processLineItem(
$title = (string)($li->title ?? ''); $title = (string)($li->title ?? '');
$productNo = (int)($li->product_no ?? 0); $productNo = (int)($li->product_no ?? 0);
$qty = (float)($li->amount_total ?? 0); $qtyRaw = (float)($li->amount_total ?? 0);
if ($qty <= 0) $qty = 0.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; $dateStart = safeYmd((string)($li->date_start ?? '')) ?: null;
$dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null; $dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null;
$revenueNet = (float)($li->sum_total_net ?? 0);
$extRevenueNet = calcExternalRevenueNet($li);
ensureMeta($Epi, $productPk, $productNo, $title); ensureMeta($Epi, $productPk, $productNo, $title);
// Jahr: Rechnungsdatum // Jahr: Rechnungsdatum
@@ -493,15 +610,16 @@ function processLineItem(
if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet); if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet);
// Peak/Histogram direct + incl (tagesbasiert) // Peak/Histogram direct + incl (tagesbasiert)
if ($dateStart && $dateEnd && $qty > 0) { // -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt
addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qty); if ($dateStart && $dateEnd && $qtySigned != 0.0) {
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qty); addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qtySigned);
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qtySigned);
} }
// Slotting: direct // Slotting: direct (signed qty)
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
addSlotItem($slotItemsDirect, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); addSlotItem($slotItemsDirect, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet);
addSlotItem($slotItemsIncl, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); addSlotItem($slotItemsIncl, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet);
} }
// Debug Row // Debug Row
@@ -517,11 +635,13 @@ function processLineItem(
'title' => $title, 'title' => $title,
'date_start' => $dateStart ?? '', 'date_start' => $dateStart ?? '',
'date_end' => $dateEnd ?? '', 'date_end' => $dateEnd ?? '',
'qty' => $qty, 'qty' => $qtyRaw,
'qty_signed' => $qtySigned,
'revenue_net' => $revenueNet, 'revenue_net' => $revenueNet,
'ext_rev_net' => $extRevenueNet, 'ext_rev_net' => $extRevenueNet,
'amount_ext' => (float)($li->amount_external ?? 0), 'amount_ext' => (float)($li->amount_external ?? 0),
'amount_total'=> (float)($li->amount_total ?? 0), 'amount_total'=> (float)($li->amount_total ?? 0),
'is_credit' => $invoiceIsCredit ? 1 : 0,
]; ];
/* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */ /* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */
@@ -549,20 +669,19 @@ function processLineItem(
} }
} }
// Auslastung allokieren (Peak/Histogramm inkl Bundle) // Auslastung allokieren (Peak/Histogramm inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
foreach ($leafMap as $leafPk => $amt) { foreach ($leafMap as $leafPk => $amt) {
$leafPk = (int)$leafPk; $leafPk = (int)$leafPk;
$amt = (float)$amt; $amt = (float)$amt;
if ($leafPk <= 0 || $amt <= 0) continue; if ($leafPk <= 0 || $amt <= 0) continue;
ensureMeta($Epi, $leafPk, 0, ''); 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 // Slotting allokieren (inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
// Revenue pro "Bundle-Item" wird in allocateBundleRevenue bereits verteilt.
$alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; $alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : [];
$allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; $allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : [];
@@ -574,11 +693,10 @@ function processLineItem(
$leafRev = (float)($alloc[$leafPk] ?? 0.0); $leafRev = (float)($alloc[$leafPk] ?? 0.0);
$leafExt = (float)($allocExt[$leafPk] ?? 0.0); $leafExt = (float)($allocExt[$leafPk] ?? 0.0);
// qty_leaf = qty * amt $qtyLeafSigned = $qtySigned * $amt;
$qtyLeaf = $qty * $amt;
ensureMeta($Epi, $leafPk, 0, ''); 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 ?? ''); $invoiceDate = (string)($invObj->invoice_date ?? '');
$invoiceYear = yearFromDate($invoiceDate) ?? 0; $invoiceYear = yearFromDate($invoiceDate) ?? 0;
$invoiceNo = (int)($invObj->invoice_no ?? 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 ?? []; $orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = []; if (!is_array($orderItems)) $orderItems = [];
@@ -619,14 +741,14 @@ foreach ($invoiceList as $inv) {
$journalItems = getJournalByChapter($Epi, $chapterId); $journalItems = getJournalByChapter($Epi, $chapterId);
foreach ($journalItems as $ji) { 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; continue;
} }
// Direkte Artikelposition ohne Kapitel (type=0) // Direkte Artikelposition ohne Kapitel (type=0)
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 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; continue;
} }
} }
@@ -717,7 +839,6 @@ foreach ($meta as $productPk => $m) {
$row['hist_direct'] = $pd['hist']; $row['hist_direct'] = $pd['hist'];
$row['hist_incl'] = $pi['hist']; $row['hist_incl'] = $pi['hist'];
// nur anzeigen, wenn was los ist
$hasAny = ( $hasAny = (
$row['total_direct'] != 0.0 || $row['total_direct'] != 0.0 ||
$row['total_incl'] != 0.0 || $row['total_incl'] != 0.0 ||
@@ -850,13 +971,14 @@ $writer->save($excelFilePath);
========================= */ ========================= */
function slotsToDisplay(array $slotMap): array { function slotsToDisplay(array $slotMap): array {
// slotMap: [slot=>revenue]
ksort($slotMap); ksort($slotMap);
$out = []; $out = [];
foreach ($slotMap as $slot => $rev) { foreach ($slotMap as $slot => $rev) {
$slot = (int)$slot; $slot = (int)$slot;
if ($slot <= 0) continue; 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; 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> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<style> <style>
.kpi-updated { font-size: .82rem; opacity: .85; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.btn-xs { padding:.15rem .4rem; font-size:.78rem; } .btn-xs { padding:.15rem .4rem; font-size:.78rem; }
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
.small-muted { font-size: .82rem; opacity:.85; }
</style> </style>
<script> <script>
@@ -974,37 +1094,28 @@ function slotsToDisplay(array $slotMap): array {
<td><?php echo htmlspecialchars((string)$r['title']); ?></td> <td><?php echo htmlspecialchars((string)$r['title']); ?></td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo $peakDirect; ?>
echo $peakDirect; <span class="text-muted">(<?php echo $peakIncl; ?>)</span>
echo ' <span class="text-muted">(' . $peakIncl . ')</span>';
?>
</td> </td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo formatEuro($sumDirect); ?>
echo formatEuro($sumDirect); <span class="text-muted">(<?php echo formatEuro($sumIncl); ?>)</span>
echo ' <span class="text-muted">(' . formatEuro($sumIncl) . ')</span>';
?>
</td> </td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo formatEuro($extDirect); ?>
echo formatEuro($extDirect); <span class="text-muted">(<?php echo formatEuro($extIncl); ?>)</span>
echo ' <span class="text-muted">(' . formatEuro($extIncl) . ')</span>';
?>
</td> </td>
<?php foreach ($years as $y): ?> <?php foreach ($years as $y): ?>
<?php <?php
$d = (float)$r['years'][$y]['direct']; $d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$y]['incl']; $iVal = (float)$r['years'][$y]['incl'];
$de = (float)$r['years_ext'][$y]['direct'];
$ie = (float)$r['years_ext'][$y]['incl'];
?> ?>
<td class="nowrap"> <td class="nowrap">
<div><?php echo formatEuro($d); ?> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span></div> <?php echo formatEuro($d); ?>
<!--<div class="small-muted">ext: <?php echo formatEuro($de); ?> <span class="text-muted">(<?php echo formatEuro($ie); ?>)</span></div>--> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span>
</td> </td>
<?php endforeach; ?> <?php endforeach; ?>
@@ -1024,7 +1135,7 @@ function slotsToDisplay(array $slotMap): array {
data-hist="<?php echo htmlspecialchars($histInclB64); ?>" data-hist="<?php echo htmlspecialchars($histInclB64); ?>"
data-peak="<?php echo (int)$peakIncl; ?>" data-peak="<?php echo (int)$peakIncl; ?>"
data-mode="incl"> data-mode="incl">
Histogramm inkl. Bundle inkl. Bundle
</button> </button>
</td> </td>
@@ -1033,7 +1144,7 @@ function slotsToDisplay(array $slotMap): array {
class="btn btn-outline-dark btn-xs js-slots" class="btn btn-outline-dark btn-xs js-slots"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>" data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-slots="<?php echo htmlspecialchars($slotsB64); ?>"> data-slots="<?php echo htmlspecialchars($slotsB64); ?>">
Slots (Auftragsbasiert) Slots
</button> </button>
</td> </td>
</tr> </tr>
@@ -1045,8 +1156,9 @@ function slotsToDisplay(array $slotMap): array {
Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>. Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>.
Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span> (tagesbasiert). 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). 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). Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span>.
Slots: auftragsbasierte Slotbelegung nach Start, dann Ende DESC, dann Auftragsnr. 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> </small>
</div> </div>
</div> </div>
@@ -1073,9 +1185,11 @@ function slotsToDisplay(array $slotMap): array {
<th>Start</th> <th>Start</th>
<th>Ende</th> <th>Ende</th>
<th>Menge</th> <th>Menge</th>
<th>Menge (signed)</th>
<th>Ext-Menge</th> <th>Ext-Menge</th>
<th>Umsatz netto</th> <th>Umsatz netto</th>
<th>Umsatz extern</th> <th>Umsatz extern</th>
<th>Credit</th>
</tr> </tr>
</thead> </thead>
<tbody> <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_start']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['date_end']); ?></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']); ?></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="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['revenue_net']); ?></td>
<td class="nowrap"><?php echo formatEuro((float)$r['ext_rev_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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<small class="text-muted"> <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. Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet.
Menge(signed) ist für Peak/Slots relevant (Storno-Rechnung -> negativ).
</small> </small>
</div> </div>
</div> </div>
@@ -1127,7 +1243,7 @@ function slotsToDisplay(array $slotMap): array {
<div class="modal-body"> <div class="modal-body">
<canvas id="histChart" height="140"></canvas> <canvas id="histChart" height="140"></canvas>
<div class="mt-2 text-muted"> <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> </div>
</div> </div>
@@ -1148,6 +1264,7 @@ function slotsToDisplay(array $slotMap): array {
<div class="mb-2 text-muted"> <div class="mb-2 text-muted">
Slot-Umsatz ist <b>auftragsbasiert</b>: Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt. 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). 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>
<div class="table-responsive"> <div class="table-responsive">
@@ -1228,7 +1345,6 @@ function openSlots(title, payload) {
const directExt = payload.direct_ext || []; const directExt = payload.direct_ext || [];
const inclExt = payload.incl_ext || []; const inclExt = payload.incl_ext || [];
// maps slot->rev
const mapD = {}; const mapD = {};
const mapI = {}; const mapI = {};
const mapDE = {}; const mapDE = {};
@@ -1289,9 +1405,7 @@ function openSlots(title, payload) {
options: { options: {
responsive: true, responsive: true,
animation: false, animation: false,
scales: { scales: { y: { beginAtZero: true } }
y: { beginAtZero: true }
}
} }
}); });