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 + + + + Label + + input + 32 + + + + + + 1 + Button + + 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 @@ + + + + + + + + + + + + + + {ep:icon(icon: 'phone', class: 'w-10 h-10 text-white')} + + + Ihr habt Fragen? + + + Wir helfen euch gerne! + + + + + + + {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 @@ + + + + + + + + + + + + + + + + + + + + + +