feat: read the BusPro XML export through XmlProductReader

This commit is contained in:
fromme committed 2026-09-30 14:57:07 +02:00
1 parent b9851943d0
commit 1b6f480bee
4 files changed
+479 -216

No files matched your search

@@ -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();
@@ -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<array> $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<array> $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,
];
}
}
}
@@ -0,0 +1,332 @@
<?php
namespace EP\EpProducts\Service;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Reads a BusPro XML export directory (Ziel_*.xml, zustiege.xml) and yields one
* row per travel date × hotel, in exactly the shape of the rows served by
* GET /api/exports/products on MyE&P, so {@see DateImportService::import()}
* does not care which of the two it is fed.
*
* Nothing is filtered or rewritten here: every display rule stays in
* DateImportService. Prices are parsed from the German format, and a price the
* source does not state is null.
*/
final class XmlProductReader implements \IteratorAggregate
{
private const PRODUCT_PREFIX = 'Ziel';
private const PICKUPS_FILE = 'zustiege.xml';
private const CODE_PSEUDOPRICE = 'PDGS';
private const TIMEZONE = 'Europe/Berlin';
/**
* @var string
*/
private $path;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var \DateTimeZone
*/
private $timezone;
/**
* @var array|null pickup master data by BusPro id
*/
private $pickups;
public function __construct(string $path, ?LoggerInterface $logger = null)
{
$this->path = rtrim($path, '/');
$this->logger = $logger ?? new NullLogger();
$this->timezone = new \DateTimeZone(self::TIMEZONE);
}
public function getPath(): string
{
return $this->path;
}
/**
* @return \Generator<array>
*/
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<int, array{code: string, city: string, street: string}>
*/
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;
}
}
@@ -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);