diff --git a/README.MD b/README.MD index bb3be24..181b7d6 100644 --- a/README.MD +++ b/README.MD @@ -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 diff --git a/dist/ROI.php b/dist/ROI.php index 679873b..7cc548f 100644 --- a/dist/ROI.php +++ b/dist/ROI.php @@ -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 { - + \ No newline at end of file