diff --git a/public/typo3conf/ext/ep_products/Classes/Command/DateCommandController.php b/public/typo3conf/ext/ep_products/Classes/Command/DateCommandController.php index 450c58a7..2d411e4f 100644 --- a/public/typo3conf/ext/ep_products/Classes/Command/DateCommandController.php +++ b/public/typo3conf/ext/ep_products/Classes/Command/DateCommandController.php @@ -29,9 +29,12 @@ namespace EP\EpProducts\Command; use EP\EpProducts\Service\DateImportService; use EP\EpProducts\Service\ProductImportSourceFactory; +use EP\EpProducts\Service\XmlProductReader; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; use TYPO3\CMS\Extbase\Service\CacheService; @@ -94,7 +97,7 @@ class DateCommandController extends Command return 0; } - $count = $this->dateImportService->import($path); + $count = $this->dateImportService->import(new XmlProductReader($path, GeneralUtility::makeInstance(LogManager::class)->getLogger(XmlProductReader::class))); $importSource->markImported($path); $output->writeln(sprintf('%d rows imported.', $count )); $settings = $this->getSettings(); diff --git a/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php b/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php index 988f4ffe..12527da7 100644 --- a/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php +++ b/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php @@ -85,11 +85,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface */ protected $hotelMappings = []; - /** - * @var array - */ - protected $pickups = []; - /** * @var Connection */ @@ -146,18 +141,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } /** - * @param string $path + * @param iterable $rows one row per travel date × hotel, in the shape + * of GET /api/exports/products on MyE&P * @return int * @throws \Doctrine\DBAL\DBALException */ - public function import($path): int + public function import(iterable $rows): int { $this->db = $this->getDbConnection(); $this->getHotelMappings(); - $this->importPickups($path); $this->createTempTables(); $this->logger->info('starting product import'); - $dateCount = $this->parseXmlFilesForProducts($path); + $dateCount = $this->importProducts($rows); $this->clearTables(); $this->copyTables(); $this->clearTempTables(); @@ -171,46 +166,33 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface return $this->importSourceFactory->get()->isNewImportAvailable(); } - public function parseXmlFilesForProducts($path): int + /** + * Groups the rows by product and travel date, in the order they first + * appear, because a product code can map to more than one TYPO3 product and + * each of them imports all of the product's dates. + * + * @param iterable $rows + * @return int + */ + protected function importProducts(iterable $rows): int { - $dateCount = 0; - - $folder = opendir($path); - while (($fileName = readdir($folder)) !== false) { - if (strpos($fileName, 'Ziel') === 0) { - $filePath = $path . '/' . $fileName; - $dateCount += $this->parseProductXmlFile($filePath); + $products = []; + foreach ($rows as $row) { + $code = $row['product']['code']; + if (!isset($products[$code])) { + $products[$code] = ['busProId' => (int) $row['product']['busProId'], 'dates' => []]; } + $products[$code]['dates'][(int) $row['date']['busProId']][] = $row; + } + + $dateCount = 0; + foreach ($products as $code => $product) { + $dateCount += $this->importProduct((string) $code, $product['busProId'], $product['dates']); } return $dateCount; } - /** - * @param string $file - * @return int - */ - public function parseProductXmlFile($file): int - { - if (!file_exists($file)) { - $this->logger->error('product xml not found', ['file' => $file]); - return 0; - } - - libxml_use_internal_errors(true); - /** @var \SimpleXMLElement $xmlData */ - if (!$xmlData = simplexml_load_string(file_get_contents($file))) { - libxml_clear_errors(); - return 0; - } - - if ($xmlData->reise[0] === null) { - return 0; - } - - return $this->parseProduct($xmlData->reise[0]); - } - public function createTempTables(): void { $this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date'); @@ -236,13 +218,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } /** - * @param \SimpleXMLElement $xmlProduct + * @param string $code + * @param int $busProId + * @param array[][] $dates rows grouped by travel date * @return int */ - protected function parseProduct(\SimpleXMLElement $xmlProduct): int + protected function importProduct(string $code, int $busProId, array $dates): int { - $code = (string) $xmlProduct->attributes()['code']; - $busProId = (int) $xmlProduct->attributes()['idbuspro']; $dateCount = 0; $sql = ' @@ -280,8 +262,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $product['bus_pro_id'] = $busProId; $product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']); $product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']); - foreach ($xmlProduct->termin as $xmlDate) { - $dateCount += $this->parseDates($xmlDate, $product); + foreach ($dates as $rows) { + $dateCount += $this->importDate($rows, $product); } } @@ -289,25 +271,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } /** - * @param \SimpleXMLElement $xmlDate + * @param array[] $rows one per hotel of the travel date * @param array $product * @return int */ - protected function parseDates(\SimpleXMLElement $xmlDate, array $product): int + protected function importDate(array $rows, array $product): int { $dateCount = 0; -// $earliestDate = new \DateTimeImmutable('next year'); - foreach ($xmlDate->hotel as $xmlHotel) { - // don't import dates starting later than one year from now -// $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']); -// if ($dateStart > $earliestDate) { -// continue; -// } + foreach ($rows as $row) { + $date = $this->createDateRecord($product, $row); - $date = $this->createDateRecord($product, $xmlDate); - - $hotelCode = (string) $xmlHotel->attributes()['code']; + $hotelCode = $row['hotel']['code']; $key = $hotelCode . $date['date_start']; // Import daytrip dates only once per hotel as room information is taken @@ -316,7 +291,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface continue; } - if ($this->parseHotel($xmlHotel, $date) !== false) { + if ($this->parseHotel($row, $date) !== false) { $this->db->insert( 'tx_epproducts_domain_model_date_temp', $date @@ -342,17 +317,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface /** * @param array $product - * @param \SimpleXMLElement $xmlDate + * @param array $row * @return array */ - protected function createDateRecord(array $product, \SimpleXMLElement $xmlDate): array + protected function createDateRecord(array $product, array $row): array { - $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']); - $dateEnd = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['bis']); + $dateStart = \DateTime::createFromFormat('Y-m-d', $row['date']['from']); + $dateEnd = \DateTime::createFromFormat('Y-m-d', $row['date']['to']); $nights = $dateStart->diff($dateEnd)->format('%d'); - $busProId = (int) $xmlDate->attributes()['idbuspro']; - $code = (string) $xmlDate->attributes()['code']; + $busProId = (int) $row['date']['busProId']; + $code = (string) $row['date']['code']; $date = [ 'pid' => $this->pid, @@ -395,15 +370,15 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface 'important' => $product['important'], ]; - $includedAccommodation = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_ACCOMMODATION); - $includedSkiPass = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_SKIPASS); - $includedBoard = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_BOARD); - $includedTransportation = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_TRANSPORTATION); - $includedProgramme = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_PROGRAMME); - $includedPersonnel = $this->parsePersonnelSelectionGroup($xmlDate); - $includedEvents = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_EVENTS); - $includedOther = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_OTHER); - $includedCourses = $this->parseSelectionGroup($xmlDate, static::SELECTION_GROUP_COURSES); + $includedAccommodation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_ACCOMMODATION); + $includedSkiPass = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_SKIPASS); + $includedBoard = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_BOARD); + $includedTransportation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_TRANSPORTATION); + $includedProgramme = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_PROGRAMME); + $includedPersonnel = $this->parsePersonnelSelectionGroup($row['selectionGroups']); + $includedEvents = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_EVENTS); + $includedOther = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_OTHER); + $includedCourses = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_COURSES); $includedServices = [ ...$includedAccommodation, @@ -428,7 +403,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $skiPassIncluded = (int) (count($includedSkiPass) > 0); $date['skipass_included'] = $skiPassIncluded; - $servicesTransportation = $this->parseService($xmlDate, 'lei_befoerderung'); + $servicesTransportation = $this->parseService($row['transportServices']); $discount = $this->determineDiscount($servicesTransportation); $date['discount'] = $discount; @@ -454,7 +429,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface ]; } - $servicesGeneral = $this->parseService($xmlDate, 'lei_sonstiges'); + $servicesGeneral = $this->parseService($row['dateServices']); $combinedServices = []; @@ -476,29 +451,25 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $date['services_general'] = serialize($combinedServices); $pickups = []; - foreach ($xmlDate->xpath('zustiege/zustieg') as $pickup) { - $attributes = $pickup->attributes(); - if (null === $attributes) { - continue; - } - $busProId = (int) $attributes['idbuspro']; + $timezone = new \DateTimeZone('Europe/Berlin'); + foreach ($row['pickups'] as $pickup) { + $busProId = (int) $pickup['busProId']; if ($busProId === 0) { continue; } - $timestamp = (string) $attributes['zeit']; - $parts = explode(' ', $timestamp); - if (2 === count($parts)) { - [$pickupDate, $timePart] = $parts; - $pickupTime = substr($timePart, 0, 5); - } else { - $pickupDate = $dateStart->format('d.m.Y'); - $pickupTime = $parts[0]; + $pickupDate = $dateStart->format('d.m.Y'); + $pickupTime = ''; + if ($pickup['datetime'] !== null) { + $pickupDateTime = (new \DateTimeImmutable($pickup['datetime']))->setTimezone($timezone); + $pickupDate = $pickupDateTime->format('d.m.Y'); + $pickupTime = $pickupDateTime->format('H:i'); } $pickups[] = [ 'bus_pro_id' => $busProId, - 'city' => $this->pickups[$busProId]['city'], - 'street' => $this->pickups[$busProId]['street'], - 'price' => (float) str_replace(',', '.', (string) $attributes['preis']), + 'city' => $pickup['city'], + 'street' => $pickup['street'], + // A pickup without a stated price has always been shown as included. + 'price' => $pickup['price'] === null ? 0.0 : (float) $pickup['price'], 'date' => $pickupDate, 'time' => $pickupTime, ]; @@ -515,21 +486,20 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface }); $date['pickups'] = serialize($pickups); - $guide = $this->parseGuide($xmlDate); - $date['guide'] = serialize($guide); + $date['guide'] = serialize($row['guide'] ?? []); return $date; } /** - * @param \SimpleXMLElement $xmlHotel + * @param array $row * @param array $date * @return bool */ - protected function parseHotel(\SimpleXMLElement $xmlHotel, array &$date): bool + protected function parseHotel(array $row, array &$date): bool { - $code = (string) $xmlHotel->attributes()['code']; - $busProId = (string) $xmlHotel->attributes()['idbuspro']; + $code = $row['hotel']['code']; + $busProId = (int) $row['hotel']['busProId']; if (!array_key_exists($code, $this->hotelMappings)) { $this->logger->warning('hotel not found', ['code' => $code]); @@ -542,7 +512,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $services = []; $generalServices = unserialize($date['services_general']); - $hotelServices = $this->parseService($xmlHotel, 'lei_sonstiges', true); + $hotelServices = $this->parseService($row['hotelServices'], true); $this->addServices($generalServices, $services); $this->addServices($hotelServices, $services); foreach ($services as $key => $list) { @@ -552,7 +522,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $services[$key] = $list; } - $this->parseRooms($xmlHotel, $hotel['uid'], $date, $services); + $this->parseRooms($row, $hotel['uid'], $date, $services); $date['hotel'] = $hotel['uid']; $date['hotel_code'] = $hotel['code']; @@ -571,34 +541,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface return true; } - protected function parseGuide(\SimpleXMLElement $xmlDate) - { - $transportationServicesBus = $xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]'); - - foreach ($transportationServicesBus as $service) { - if ('RUECK' === (string) $service->richtung) { - continue; - } - if (false === isset($service->zustiegsplanung->reiseleiter)) { - continue; - } - - return [ - 'name' => (string) $service->zustiegsplanung->reiseleiter->name, - 'phone' => (string) $service->zustiegsplanung->reiseleiter->telefon, - ]; - } - - return []; - } - /** - * @param \SimpleXMLElement $xmlHotel + * @param array $row * @param int $hotelUid * @param array $date * @param array $services */ - protected function parseRooms(\SimpleXMLElement $xmlHotel, $hotelUid, array &$date, array $services): void + protected function parseRooms(array $row, $hotelUid, array &$date, array $services): void { $minPriceOverall = 0; $minPriceAvailable = 0; @@ -607,21 +556,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $boardCode = ''; $count = 0; - foreach ($xmlHotel->xpath('zimmer/preis') as $roomXml) { - $code = trim((string) $roomXml->attributes()['zimmercode']); + if ($row['pseudoPrice'] !== null) { + $date['pseudo_price'] = (int) $row['pseudoPrice']; + } - if ($code === static::CODE_PSEUDOPRICE) { - $price = (int) $roomXml->attributes()['preis']; - $date['pseudo_price'] = $price; - continue; - } + foreach ($row['rooms'] as $roomRow) { + $code = $roomRow['code']; + $label = $roomRow['label']; + $roomMinPax = (int) $roomRow['minPax']; + $pax = (int) $roomRow['maxPax']; - $label = (string) $roomXml->attributes()['zimmertext']; - $roomMinPax = (int) $roomXml->attributes()['MinPax']; - $pax = (int) $roomXml->attributes()['MaxPax']; - - $available = (int) $roomXml->attributes()['verfuegbar']; - $price = (int) $roomXml->attributes()['preis']; + $available = (int) $roomRow['available']; + $price = (int) $roomRow['price']; // Skip rooms with zero price _and_ zero availability. if (!$available && !$price) { @@ -658,28 +604,28 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } // Determine status - $statusXml = (string) $roomXml->attributes()['status']; + $sourceStatus = $roomRow['status']; - if (static::STATUS_STOP === $statusXml && $price) { + if (static::STATUS_STOP === $sourceStatus && $price) { $status = Room::ROOM_STATUS_FULLY_BOOKED; - } elseif (static::STATUS_ON_REQUEST === $statusXml) { + } elseif (static::STATUS_ON_REQUEST === $sourceStatus) { $status = Room::ROOM_STATUS_ON_REQUEST; } else { $status = Room::ROOM_STATUS_AVAILABLE; } - $boardCode = (string) $roomXml->attributes()['vpcode']; + $boardCode = $roomRow['boardCode']; $room = [ 'pid' => $this->pid, 'date' => 0, 'hotel' => $hotelUid, - 'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'], + 'bus_pro_id' => (int) $roomRow['busProId'], 'code' => $code, 'type' => $roomType, 'name' => $label, 'pax' => $pax, - 'nights' => (int) $roomXml->attributes()['naechte'], + 'nights' => (int) $roomRow['nights'], 'price' => $price, 'available' => $available * $pax, 'status' => $status, @@ -719,29 +665,26 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } /** - * @param \SimpleXMLElement $xmlData - * @param string $serviceLabel + * @param array[] $services a service list of a row * @param bool $skipWithoutPrice * @return array */ - protected function parseService(\SimpleXMLElement $xmlData, string $serviceLabel, bool $skipWithoutPrice = false): array + protected function parseService(array $services, bool $skipWithoutPrice = false): array { $data = []; - $services = $xmlData->xpath($serviceLabel . '/leistung'); foreach ($services as $service) { // Skip transportation service direction 'back' - if ($service->richtung && (string) $service->richtung === 'RUECK') { + if ($service['direction'] === 'RUECK') { continue; } // Skip services with status 'Buchungsstop' - $status = (string) $service->status; - if ($status === 'Buchungsstop') { + if ($service['status'] === static::STATUS_STOP) { continue; } // Skip services of subcategory 'EIN' - $type = (string) $service->attributes()['unterart']; + $type = $service['subType']; if ($type === 'EIN') { continue; } @@ -751,13 +694,14 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $type = 'VER'; } - $price = (float) str_replace(',', '.', $service->preis); + // A service without a stated price has always been shown as included. + $price = $service['price'] === null ? 0.0 : (float) $service['price']; if ($skipWithoutPrice && $price === 0.0) { continue; } - $label = (string) $service->text; + $label = $service['label']; if ($type === 'BUS') { $label = static::LABEL_BUS; } elseif ($type === 'BAH') { @@ -768,8 +712,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $data[$type] = []; } - $data[$type][(int)$service->attributes()['idbuspro']] = [ - 'idBusPro' => (int) $service->attributes()['idbuspro'], + $data[$type][(int) $service['busProId']] = [ + 'idBusPro' => (int) $service['busProId'], 'label' => $label, 'price' => number_format($price, 2, ',', '.'), ]; @@ -781,46 +725,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } /** - * @param \SimpleXMLElement $xmlDate + * @param array[] $selectionGroups * @param int $busProId * @return array */ - protected function parseSelectionGroup(\SimpleXMLElement $xmlDate, int $busProId): array - { - return $this->parseSelections($xmlDate->xpath('selektiongruppe[@idbuspro="' . $busProId . '"]/selektion')); - } - - /** - * @param \SimpleXMLElement $xmlDate - * @return array - */ - protected function parsePersonnelSelectionGroup(\SimpleXMLElement $xmlDate): array - { - $groups = $xmlDate->xpath('selektiongruppe[@bezeichnung="' . static::SELECTION_GROUP_LABEL_PERSONNEL . '"]'); - foreach ($groups as $group) { - $this->logger->warning( - 'selection group matched by label, register its id', - ['label' => static::SELECTION_GROUP_LABEL_PERSONNEL, 'idbuspro' => (int) $group->attributes()['idbuspro']] - ); - } - - return $this->parseSelections($xmlDate->xpath('selektiongruppe[@bezeichnung="' . static::SELECTION_GROUP_LABEL_PERSONNEL . '"]/selektion')); - } - - /** - * @param \SimpleXMLElement[] $selections - * @return array - */ - protected function parseSelections(array $selections): array + protected function parseSelectionGroup(array $selectionGroups, int $busProId): array { $data = []; - foreach ($selections as $selection) { - $attributes = $selection->attributes(); - if (null === $attributes) { - continue; + foreach ($selectionGroups as $group) { + if ((int) $group['busProId'] === $busProId) { + $this->addSelections($group['selections'], $data); } - $id = (int) $attributes['idbuspro']; - $data[$id] = (string) $attributes['bezeichnung']; } ksort($data); @@ -828,6 +743,40 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface return $data; } + /** + * @param array[] $selectionGroups + * @return array + */ + protected function parsePersonnelSelectionGroup(array $selectionGroups): array + { + $data = []; + foreach ($selectionGroups as $group) { + if ($group['label'] !== static::SELECTION_GROUP_LABEL_PERSONNEL) { + continue; + } + $this->logger->warning( + 'selection group matched by label, register its id', + ['label' => $group['label'], 'idbuspro' => (int) $group['busProId']] + ); + $this->addSelections($group['selections'], $data); + } + + ksort($data); + + return $data; + } + + /** + * @param array[] $selections + * @param array $data + */ + protected function addSelections(array $selections, array &$data): void + { + foreach ($selections as $selection) { + $data[(int) $selection['busProId']] = $selection['label']; + } + } + /** * @param array $services * @param array $date @@ -915,27 +864,4 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } } - protected function importPickups(string $path): void - { - libxml_use_internal_errors (true); - $file = $path . '/zustiege.xml'; - if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) { - libxml_clear_errors(); - $this->logger->warning('pickup xml missing or invalid', ['file' => $file]); - return; - } - - foreach ($xmlData->zustieg as $entry) { - $attributes = $entry->attributes(); - if (null === $attributes) { - continue; - } - $idBusPro = (int) $attributes['idbuspro']; - $this->pickups[$idBusPro] = [ - 'city' => (string) $entry->ort, - 'street' => (string) $entry->strasse, - ]; - } - } - } diff --git a/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php b/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php new file mode 100644 index 00000000..31b4eed3 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php @@ -0,0 +1,332 @@ +path = rtrim($path, '/'); + $this->logger = $logger ?? new NullLogger(); + $this->timezone = new \DateTimeZone(self::TIMEZONE); + } + + public function getPath(): string + { + return $this->path; + } + + /** + * @return \Generator + */ + public function getIterator(): \Generator + { + if ($this->pickups === null) { + $this->pickups = $this->readPickups(); + } + + $folder = opendir($this->path); + if ($folder === false) { + $this->logger->error('product xml directory not readable', ['path' => $this->path]); + return; + } + + try { + while (($fileName = readdir($folder)) !== false) { + if (strpos($fileName, self::PRODUCT_PREFIX) === 0) { + yield from $this->readProductFile($this->path . '/' . $fileName); + } + } + } finally { + closedir($folder); + } + } + + private function readProductFile(string $file): \Generator + { + libxml_use_internal_errors(true); + $xmlData = simplexml_load_string((string) file_get_contents($file)); + if ($xmlData === false) { + libxml_clear_errors(); + $this->logger->error('product xml invalid', ['file' => $file]); + return; + } + + $xmlProduct = $xmlData->reise[0]; + if ($xmlProduct === null) { + return; + } + + $product = [ + 'code' => (string) $xmlProduct['code'], + 'busProId' => (int) $xmlProduct['idbuspro'], + ]; + + foreach ($xmlProduct->termin as $xmlDate) { + $from = $this->parseDate((string) $xmlDate['termin']); + $to = $this->parseDate((string) $xmlDate['bis']); + if ($from === null || $to === null) { + $this->logger->warning('travel date skipped, dates unparseable', [ + 'code' => (string) $xmlDate['code'], + 'termin' => (string) $xmlDate['termin'], + 'bis' => (string) $xmlDate['bis'], + ]); + continue; + } + + $date = [ + 'code' => (string) $xmlDate['code'], + 'busProId' => (int) $xmlDate['idbuspro'], + 'from' => $from->format('Y-m-d'), + 'to' => $to->format('Y-m-d'), + 'type' => (string) $xmlDate['reiseart'], + ]; + + // Everything below is per travel date and shared by its hotels. + $selectionGroups = $this->readSelectionGroups($xmlDate); + $transportServices = $this->readServices($xmlDate, 'lei_befoerderung'); + $dateServices = $this->readServices($xmlDate, 'lei_sonstiges'); + $pickups = $this->readDatePickups($xmlDate, $from); + $guide = $this->readGuide($xmlDate); + + foreach ($xmlDate->hotel as $xmlHotel) { + [$pseudoPrice, $rooms] = $this->readRooms($xmlHotel); + + yield [ + 'product' => $product, + 'date' => $date, + 'hotel' => [ + 'code' => (string) $xmlHotel['code'], + 'busProId' => (int) $xmlHotel['idbuspro'], + ], + 'pseudoPrice' => $pseudoPrice, + 'selectionGroups' => $selectionGroups, + 'transportServices' => $transportServices, + 'dateServices' => $dateServices, + 'hotelServices' => $this->readServices($xmlHotel, 'lei_sonstiges'), + 'rooms' => $rooms, + 'pickups' => $pickups, + 'guide' => $guide, + ]; + } + } + } + + private function readSelectionGroups(\SimpleXMLElement $xmlDate): array + { + $groups = []; + foreach ($xmlDate->selektiongruppe as $xmlGroup) { + $selections = []; + foreach ($xmlGroup->selektion as $xmlSelection) { + $selections[] = [ + 'busProId' => (int) $xmlSelection['idbuspro'], + 'label' => (string) $xmlSelection['bezeichnung'], + ]; + } + $groups[] = [ + 'busProId' => (int) $xmlGroup['idbuspro'], + 'label' => (string) $xmlGroup['bezeichnung'], + 'selections' => $selections, + ]; + } + + return $groups; + } + + private function readServices(\SimpleXMLElement $xmlParent, string $list): array + { + $services = []; + foreach ($xmlParent->xpath($list . '/leistung') as $xmlService) { + $direction = trim((string) $xmlService->richtung); + $services[] = [ + 'busProId' => (int) $xmlService['idbuspro'], + 'subType' => (string) $xmlService['unterart'], + 'label' => (string) $xmlService->text, + 'price' => $this->parsePrice((string) $xmlService->preis), + 'status' => (string) $xmlService->status, + 'direction' => $direction === '' ? null : $direction, + ]; + } + + return $services; + } + + /** + * @return array{0: ?float, 1: array} + */ + private function readRooms(\SimpleXMLElement $xmlHotel): array + { + $pseudoPrice = null; + $rooms = []; + foreach ($xmlHotel->xpath('zimmer/preis') as $xmlRoom) { + $code = trim((string) $xmlRoom['zimmercode']); + if ($code === self::CODE_PSEUDOPRICE) { + $pseudoPrice = $this->parsePrice((string) $xmlRoom['preis']); + continue; + } + + $rooms[] = [ + 'busProId' => (int) $xmlRoom['idbuspro_zimmer'], + 'code' => $code, + 'label' => (string) $xmlRoom['zimmertext'], + 'category' => (string) $xmlRoom['kat'], + 'minPax' => $this->parseInt((string) $xmlRoom['MinPax']), + 'maxPax' => $this->parseInt((string) $xmlRoom['MaxPax']), + 'nights' => $this->parseInt((string) $xmlRoom['naechte']), + 'price' => $this->parsePrice((string) $xmlRoom['preis']), + 'available' => $this->parseInt((string) $xmlRoom['verfuegbar']), + 'status' => (string) $xmlRoom['status'], + 'boardCode' => (string) $xmlRoom['vpcode'], + 'boardBusProId' => $this->parseInt((string) $xmlRoom['idbuspro_vp']), + ]; + } + + return [$pseudoPrice, $rooms]; + } + + /** + * A bare HH:mm takes the travel date's start, and is rolled onto the next + * day when it would otherwise fall before the latest full date-time listed + * before it: a coach does not board backwards. + */ + private function readDatePickups(\SimpleXMLElement $xmlDate, \DateTimeImmutable $from): array + { + $pickups = []; + $latest = null; + foreach ($xmlDate->xpath('zustiege/zustieg') as $xmlPickup) { + $busProId = (int) $xmlPickup['idbuspro']; + $time = trim((string) $xmlPickup['zeit']); + + $datetime = null; + if ($time !== '' && strpos($time, ' ') !== false) { + $datetime = \DateTimeImmutable::createFromFormat('!d.m.Y H:i:s', $time, $this->timezone) ?: null; + if ($datetime !== null && ($latest === null || $datetime > $latest)) { + $latest = $datetime; + } + } elseif ($time !== '') { + $datetime = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $from->format('Y-m-d') . ' ' . $time, $this->timezone) ?: null; + if ($datetime !== null && $latest !== null && $datetime < $latest) { + $datetime = $datetime->modify('+1 day'); + } + } + + $masterData = $this->pickups[$busProId] ?? null; + $pickups[] = [ + 'busProId' => $busProId, + 'code' => $masterData['code'] ?? null, + 'city' => $masterData['city'] ?? null, + 'street' => $masterData['street'] ?? null, + 'price' => $this->parsePrice((string) $xmlPickup['preis']), + 'datetime' => $datetime !== null ? $datetime->format(\DateTimeInterface::ATOM) : null, + ]; + } + + return $pickups; + } + + private function readGuide(\SimpleXMLElement $xmlDate): ?array + { + foreach ($xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]') as $xmlService) { + if ((string) $xmlService->richtung === 'RUECK') { + continue; + } + if (!isset($xmlService->zustiegsplanung->reiseleiter)) { + continue; + } + + return [ + 'name' => (string) $xmlService->zustiegsplanung->reiseleiter->name, + 'phone' => (string) $xmlService->zustiegsplanung->reiseleiter->telefon, + ]; + } + + return null; + } + + /** + * @return array + */ + private function readPickups(): array + { + $file = $this->path . '/' . self::PICKUPS_FILE; + libxml_use_internal_errors(true); + if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) { + libxml_clear_errors(); + $this->logger->warning('pickup xml missing or invalid', ['file' => $file]); + return []; + } + + $pickups = []; + foreach ($xmlData->zustieg as $xmlPickup) { + $pickups[(int) $xmlPickup['idbuspro']] = [ + 'code' => (string) $xmlPickup['code'], + 'city' => (string) $xmlPickup->ort, + 'street' => (string) $xmlPickup->strasse, + ]; + } + + return $pickups; + } + + private function parseDate(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat('!d.m.Y', trim($value), $this->timezone); + + return $date ?: null; + } + + /** + * German format with an optional thousands separator: "1.234,50" is 1234.5. + */ + private function parsePrice(string $value): ?float + { + $value = str_replace(['.', ','], ['', '.'], trim($value)); + + return is_numeric($value) ? (float) $value : null; + } + + private function parseInt(string $value): ?int + { + $value = trim($value); + + return is_numeric($value) ? (int) $value : null; + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/Task/ImportDatesTask.php b/public/typo3conf/ext/ep_products/Classes/Task/ImportDatesTask.php index 05adac25..5c8f806e 100644 --- a/public/typo3conf/ext/ep_products/Classes/Task/ImportDatesTask.php +++ b/public/typo3conf/ext/ep_products/Classes/Task/ImportDatesTask.php @@ -29,6 +29,8 @@ namespace EP\EpProducts\Task; use EP\EpProducts\Service\DateImportService; use EP\EpProducts\Service\ProductImportSourceFactory; +use EP\EpProducts\Service\XmlProductReader; +use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Configuration\ConfigurationManager; use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; @@ -50,7 +52,7 @@ class ImportDatesTask extends AbstractTask $path = $importSource->acquire(); if ($path !== null) { - $importService->import($path); + $importService->import(new XmlProductReader($path, GeneralUtility::makeInstance(LogManager::class)->getLogger(XmlProductReader::class))); // Advance the "last imported" bookkeeping only after a successful // import, so a crashed import retries on the next run. $importSource->markImported($path);