From 42e02095ce616a1d060841100b86172ccacd77c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 21 Oct 2026 21:25:26 +0200 Subject: [PATCH] feat: restore the disturber content element, port the groups.swiss export The 404 page (and other pages TYPO3 13 renders) contain disturber elements, which logged 'no rendering definition' on every 404. The CType is back with its TypoScript, templates and FlexForm; the TCA uses the TYPO3 13 API. ep:groups-swiss replaces the TYPO3 10 scheduler tasks, which stopped with PHP 7.4. It reads the calendar from MyE&P (contingents/calendar, synced from bpn-connect) instead of bpn-connect directly, so it needs no new credentials, and publishes all houses from groupsSwissHouses in secrets.php (or one house given as arguments, as the scheduler tasks did). The ranges match the TYPO3 10 algorithm on 2000 random calendars; --dry-run shows them without sending. Co-Authored-By: Claude Opus 5.5 --- config/system/secrets.example.php | 5 + .../Classes/Command/GroupsSwissCommand.php | 72 ++++++++ .../ep_products/Classes/MyEP/ApiClient.php | 16 ++ .../Classes/Service/GroupsSwissService.php | 165 ++++++++++++++++++ .../FlexForms/flexform_disturber.xml | 64 +++++++ .../tt_content_element_disturber.php | 61 +++++++ .../ContentElements/disturber.typoscript | 8 + .../Private/Partials/Disturber/Large.html | 64 +++++++ .../Partials/Disturber/LastChance.html | 21 +++ .../Private/Partials/Disturber/Reminder.html | 32 ++++ .../Private/Partials/Disturber/Small.html | 42 +++++ .../FluidStyledContent/Disturber.html | 25 +++ 12 files changed, 575 insertions(+) create mode 100644 packages/ep_products/Classes/Command/GroupsSwissCommand.php create mode 100644 packages/ep_products/Classes/Service/GroupsSwissService.php create mode 100644 packages/ep_theme/Configuration/FlexForms/flexform_disturber.xml create mode 100644 packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_disturber.php create mode 100644 packages/ep_theme/Configuration/TypoScript/ContentElements/disturber.typoscript create mode 100644 packages/ep_theme/Resources/Private/Partials/Disturber/Large.html create mode 100644 packages/ep_theme/Resources/Private/Partials/Disturber/LastChance.html create mode 100644 packages/ep_theme/Resources/Private/Partials/Disturber/Reminder.html create mode 100644 packages/ep_theme/Resources/Private/Partials/Disturber/Small.html create mode 100644 packages/ep_theme/Resources/Private/Templates/FluidStyledContent/Disturber.html diff --git a/config/system/secrets.example.php b/config/system/secrets.example.php index d5dd50b..00b65da 100644 --- a/config/system/secrets.example.php +++ b/config/system/secrets.example.php @@ -28,6 +28,11 @@ return [ 'myEpApiClientSecret' => '', 'myEpApiUrlAuthorize' => '', 'myEpApiUrlToken' => '', + // ep:groups-swiss (cron, daily): availability of the group houses on groups.swiss. + // Production only; the keys are in the TYPO3 10 scheduler tasks 16-21. + // 'groupsSwissHouses' => [ + // ['hotel' => 5, 'house' => '9850', 'key' => ''], + // ], ], ], ]; diff --git a/packages/ep_products/Classes/Command/GroupsSwissCommand.php b/packages/ep_products/Classes/Command/GroupsSwissCommand.php new file mode 100644 index 0000000..3a2d561 --- /dev/null +++ b/packages/ep_products/Classes/Command/GroupsSwissCommand.php @@ -0,0 +1,72 @@ +addArgument('hotel', InputArgument::OPTIONAL, 'E&P hotel uid') + ->addArgument('house', InputArgument::OPTIONAL, 'groups.swiss house number') + ->addArgument('key', InputArgument::OPTIONAL, 'groups.swiss API key') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show the ranges, send nothing to groups.swiss'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (null !== $input->getArgument('hotel')) { + if (null === $input->getArgument('house') || null === $input->getArgument('key')) { + $output->writeln('hotel, house and key are needed together.'); + return Command::INVALID; + } + $houses = [['hotel' => $input->getArgument('hotel'), 'house' => $input->getArgument('house'), 'key' => $input->getArgument('key')]]; + } else { + $houses = $this->extensionConfiguration->get('ep_products')['groupsSwissHouses'] ?? []; + if (!is_array($houses) || [] === $houses) { + $output->writeln('No groups.swiss houses configured (groupsSwissHouses in secrets.php).'); + return Command::SUCCESS; + } + } + + $dryRun = (bool)$input->getOption('dry-run'); + $failed = 0; + foreach ($houses as $house) { + $ranges = $this->groupsSwissService->publishContingents((int)($house['hotel'] ?? 0), (string)($house['house'] ?? ''), (string)($house['key'] ?? ''), $dryRun); + if (null === $ranges) { + $output->writeln(sprintf('Hotel %s / house %s: failed, see the TYPO3 log.', $house['hotel'] ?? '?', $house['house'] ?? '?')); + $failed++; + continue; + } + $output->writeln(sprintf('Hotel %s / house %s: %d ranges%s', $house['hotel'], $house['house'], count($ranges), $dryRun ? ' (dry run)' : ' sent')); + if ($output->isVerbose() || $dryRun) { + foreach ($ranges as $range) { + $output->writeln(sprintf(' %s – %s state %d', $range['from'], $range['to'], $range['state'])); + } + } + } + + return $failed > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/packages/ep_products/Classes/MyEP/ApiClient.php b/packages/ep_products/Classes/MyEP/ApiClient.php index ca90c3b..0277e85 100644 --- a/packages/ep_products/Classes/MyEP/ApiClient.php +++ b/packages/ep_products/Classes/MyEP/ApiClient.php @@ -187,6 +187,22 @@ class ApiClient implements LoggerAwareInterface ]); } + /** + * One entry per day ('date', 'status': OK, BLOCKED or ON_REQUEST) for the given range + * + * @throws ApiException + */ + public function getContingentCalendar(string $hotelCode, \DateTimeInterface $dateFrom, \DateTimeInterface $dateTo): array + { + return $this->requestJson('GET', 'contingents/calendar', [ + 'query' => [ + 'hotelCode' => $hotelCode, + 'dateFrom' => $dateFrom->format('Y-m-d'), + 'dateTo' => $dateTo->format('Y-m-d'), + ], + ]); + } + /** * @throws ApiException */ diff --git a/packages/ep_products/Classes/Service/GroupsSwissService.php b/packages/ep_products/Classes/Service/GroupsSwissService.php new file mode 100644 index 0000000..0c59d26 --- /dev/null +++ b/packages/ep_products/Classes/Service/GroupsSwissService.php @@ -0,0 +1,165 @@ +httpClient = HttpClient::create(['timeout' => 10, 'max_duration' => 30]); + } + + /** + * @return array|null the ranges sent, null on failure + */ + public function publishContingents(int $hotelUid, string $houseNumber, string $apiKey, bool $dryRun = false): ?array + { + $hotel = $this->hotelRepository->findByUid($hotelUid); + if (null === $hotel) { + $this->logger?->error(sprintf('groups.swiss: hotel with uid %d not found', $hotelUid)); + return null; + } + + $hotelCode = trim((string)$hotel->getCode()); + if ('' === $hotelCode) { + $this->logger?->error(sprintf('groups.swiss: hotel with uid %d has no hotel code', $hotelUid)); + return null; + } + + $from = new \DateTimeImmutable('today'); + $to = $from->modify('+1 year'); + + try { + // The last day of the range is reset below but not asked for, as in TYPO3 10 + $calendarRows = $this->apiClient->getContingentCalendar($hotelCode, $from, $to->modify('-1 day')); + } catch (ApiException $e) { + $this->logger?->error(sprintf('groups.swiss: no calendar for hotel %s', $hotelCode), ['exception' => $e]); + return null; + } + + $ranges = [['from' => $from, 'to' => $to, 'state' => self::STATE_FREE]]; + foreach ($this->buildRanges($calendarRows, $from, $to) as $range) { + $ranges[] = $range; + } + + $sent = []; + foreach ($ranges as $range) { + $item = ['from' => $range['from']->format('Y-m-d'), 'to' => $range['to']->format('Y-m-d'), 'state' => $range['state']]; + if (!$dryRun) { + $this->addItem($item, $houseNumber, $apiKey); + } + $sent[] = $item; + } + + return $sent; + } + + /** + * Consecutive days with the same status other than OK become one range; a gap in the + * calendar ends a range as well + * + * @return array + */ + private function buildRanges(array $calendarRows, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $rows = []; + foreach ($calendarRows as $row) { + if (!is_array($row) || !isset($row['date'])) { + continue; + } + $date = new \DateTimeImmutable($row['date']); + if ($date < $from || $date >= $to) { + continue; + } + $rows[$date->format('Y-m-d')] = ['date' => $date, 'status' => $this->normalizeStatus((string)($row['status'] ?? 'OK'))]; + } + ksort($rows); + + $ranges = []; + $current = null; + $lastDate = null; + foreach ($rows as $row) { + $isGap = null !== $lastDate && $row['date'] > $lastDate->modify('+1 day'); + if (null !== $current && ($isGap || 'OK' === $row['status'] || $current['status'] !== $row['status'])) { + $ranges[] = $current; + $current = null; + } + if ('OK' !== $row['status']) { + if (null === $current) { + $current = ['from' => $row['date'], 'to' => $row['date'], 'status' => $row['status']]; + } else { + $current['to'] = $row['date']; + } + } + $lastDate = $row['date']; + } + if (null !== $current) { + $ranges[] = $current; + } + + return array_map(fn (array $range): array => [ + 'from' => $range['from'], + 'to' => $range['to'], + 'state' => 'ON_REQUEST' === $range['status'] ? self::STATE_ON_REQUEST : self::STATE_BLOCKED, + ], $ranges); + } + + private function addItem(array $item, string $houseNumber, string $apiKey): void + { + $this->logger?->info(sprintf('groups.swiss: state %d from %s to %s for house %s', $item['state'], $item['from'], $item['to'], $houseNumber)); + + try { + $response = $this->httpClient->request('GET', self::API_URL, [ + 'query' => [ + 'apiKey' => $apiKey, + 'method' => 'setAvailabilityChart', + 'house' => $houseNumber, + 'from' => $item['from'], + 'till' => $item['to'], + 'state' => $item['state'], + ], + ]); + $this->logger?->info('groups.swiss: ' . $response->getContent(false)); + } catch (ExceptionInterface $e) { + $this->logger?->error(sprintf('groups.swiss: request failed for house %s: %s', $houseNumber, $e->getMessage())); + } + } + + private function normalizeStatus(string $status): string + { + $status = strtoupper(trim($status)); + if ('ONREQUEST' === $status) { + return 'ON_REQUEST'; + } + + return in_array($status, ['OK', 'ON_REQUEST', 'BLOCKED'], true) ? $status : 'OK'; + } +} diff --git a/packages/ep_theme/Configuration/FlexForms/flexform_disturber.xml b/packages/ep_theme/Configuration/FlexForms/flexform_disturber.xml new file mode 100644 index 0000000..2fdfd63 --- /dev/null +++ b/packages/ep_theme/Configuration/FlexForms/flexform_disturber.xml @@ -0,0 +1,64 @@ + + + 1 + + + + + + Konfiguration + + array + + +
1
+ array + + + Button + array + + + + + 1 + + + input + trim + 30 + + <_PADDING type="integer">2 + + popup + Link + EXT:backend/Resources/Public/Images/FormFieldWizard/wizard_link.gif + + wizard_link + + wizard + + + height=300,width=500,status=0,menubar=0,scrollbars=1 + + + + + + + + +
+
+
+
+
+
diff --git a/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_disturber.php b/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_disturber.php new file mode 100644 index 0000000..f2999c2 --- /dev/null +++ b/packages/ep_theme/Configuration/TCA/Overrides/tt_content_element_disturber.php @@ -0,0 +1,61 @@ + 'Störer', + 'value' => 'disturber', + 'icon' => 'content-image' + ], + 'teaser_product', + 'after' + ); + + $GLOBALS['TCA']['tt_content']['types']['disturber'] = [ + 'showitem' => ' + --palette--;' . $frontendLanguageFilePrefix . 'palette.general;general, + --palette--;' . $frontendLanguageFilePrefix . 'palette.header;header, bodytext, pi_flexform, + --div--;Bild, + assets, + --div--;' . $frontendLanguageFilePrefix . 'tabs.appearance, + layout;' . $frontendLanguageFilePrefix . 'layout_formlabel, tx_eptheme_background, + --div--;' . $frontendLanguageFilePrefix . 'tabs.access, + hidden;' . $frontendLanguageFilePrefix . 'field.default.hidden, + --div--;' . $frontendLanguageFilePrefix . 'tabs.extended + ', + 'columnsOverrides' => [ + 'bodytext' => [ + 'label' => 'Text', + 'config' => [ + 'enableRichtext' => true, + ] + ], + 'assets' => [ + 'label' => 'Bild', + 'config' => [ + 'type' => 'file', + 'allowed' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'], + 'appearance' => [ + 'createNewRelationLinkTitle' => 'Hinzufügen' + ], + 'maxitems' => 1, + 'minitems' => 0, + ] + ] + ] + ]; + + ExtensionManagementUtility::addPiFlexFormValue( + '*', + 'FILE:EXT:ep_theme/Configuration/FlexForms/flexform_disturber.xml', + 'disturber' + ); +}); diff --git a/packages/ep_theme/Configuration/TypoScript/ContentElements/disturber.typoscript b/packages/ep_theme/Configuration/TypoScript/ContentElements/disturber.typoscript new file mode 100644 index 0000000..e1ce0de --- /dev/null +++ b/packages/ep_theme/Configuration/TypoScript/ContentElements/disturber.typoscript @@ -0,0 +1,8 @@ +tt_content.disturber =< lib.contentElement +tt_content.disturber { + templateName = Disturber + dataProcessing { + 10 = EP\EpTheme\DataProcessing\TeaserImageProcessor + 20 = EP\EpTheme\DataProcessing\FlexFormProcessor + } +} diff --git a/packages/ep_theme/Resources/Private/Partials/Disturber/Large.html b/packages/ep_theme/Resources/Private/Partials/Disturber/Large.html new file mode 100644 index 0000000..fa2f2ca --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Disturber/Large.html @@ -0,0 +1,64 @@ + + +
+ + + +
+ +
+ {data.subheader} + {data.header} +
+ {data.bodytext -> f:format.html()} +
+ +
+ + + {button.container.label} + + +
+
+
+
+ + + +
+ + diff --git a/packages/ep_theme/Resources/Private/Partials/Disturber/LastChance.html b/packages/ep_theme/Resources/Private/Partials/Disturber/LastChance.html new file mode 100644 index 0000000..c19c492 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Disturber/LastChance.html @@ -0,0 +1,21 @@ + + +
+
+ {data.header} +
+
+ {data.bodytext -> f:format.html()} +
+
+ {data.flexForm.buttons -> v:iterator.first() -> v:variable.set(name: 'button')} + + {button.container.label -> f:format.raw()} + +
+
+ + diff --git a/packages/ep_theme/Resources/Private/Partials/Disturber/Reminder.html b/packages/ep_theme/Resources/Private/Partials/Disturber/Reminder.html new file mode 100644 index 0000000..e542856 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Disturber/Reminder.html @@ -0,0 +1,32 @@ + + + +
+
+ {data.header} +
+ +
+
+ {data.bodytext -> f:format.html()} +
+
+ + {button.container.label -> f:format.raw()} + +
+
+ + diff --git a/packages/ep_theme/Resources/Private/Partials/Disturber/Small.html b/packages/ep_theme/Resources/Private/Partials/Disturber/Small.html new file mode 100644 index 0000000..4c3a560 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Disturber/Small.html @@ -0,0 +1,42 @@ + + +
+ +
+
+
+ + + +
+
+ {data.header} + {data.bodytext -> f:format.html()} +
+ + + + {button.container.label} + + + +
+
+
+
+ +
+ + diff --git a/packages/ep_theme/Resources/Private/Templates/FluidStyledContent/Disturber.html b/packages/ep_theme/Resources/Private/Templates/FluidStyledContent/Disturber.html new file mode 100644 index 0000000..9b5c115 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/FluidStyledContent/Disturber.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + +