Compare commits

...
6 Commits
27 changed files with 1042 additions and 280 deletions

No files matched your search

@@ -88,14 +88,14 @@ class DateCommandController extends Command
public function execute(InputInterface $input, OutputInterface $output): int
{
$importSource = $this->importSourceFactory->get();
$path = $importSource->acquire();
if ($path === null) {
$import = $importSource->acquire();
if ($import === null) {
$output->writeln('No new import available.');
return 0;
}
$count = $this->dateImportService->import($path);
$importSource->markImported($path);
$count = $this->dateImportService->import($import->getRows());
$importSource->markImported($import);
$output->writeln(sprintf('%d rows imported.', $count ));
$settings = $this->getSettings();
$resellerPageUid = $settings['resellerExportPageUid'];
@@ -56,7 +56,7 @@ class FacebookCatalogCsvFormatter
$product['images']['image'][0],
$product['link'],
$product['name'],
$minPrice . ' EUR',
sprintf('%.2f EUR', $minPrice),
'brand-epreisen'
];
}
@@ -48,7 +48,7 @@ class GoogleBusinessFeedCsvFormatter
$product['name'],
$product['link'],
$product['images']['image'][0],
$minPrice . '.00 EUR',
sprintf('%.2f EUR', $minPrice),
];
}
}
@@ -120,9 +120,9 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
protected $busAvailable = false;
/**
* @var int
* @var float
*/
protected $busPrice = 0;
protected $busPrice = 0.0;
/**
* @var string
@@ -145,19 +145,19 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
protected $pickups = '';
/**
* @var int
* @var float
*/
protected $discount = 0;
protected $discount = 0.0;
/**
* @var int
* @var float
*/
protected $minPrice = 0;
protected $minPrice = 0.0;
/**
* @var int
* @var float
*/
protected $pseudoPrice = 0;
protected $pseudoPrice = 0.0;
/**
* @var int
@@ -438,7 +438,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @return int
* @return float
*/
public function getBusPrice()
{
@@ -446,7 +446,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @param int $busPrice
* @param float $busPrice
*/
public function setBusPrice($busPrice)
{
@@ -533,21 +533,21 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @return int
* @return float
*/
public function getDiscount() {
return $this->discount;
}
/**
* @param int $discount
* @param float $discount
*/
public function setDiscount($discount) {
$this->discount = $discount;
}
/**
* @return int
* @return float
*/
public function getMinPrice()
{
@@ -555,7 +555,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @param int $minPrice
* @param float $minPrice
*/
public function setMinPrice($minPrice)
{
@@ -563,7 +563,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @return int
* @return float
*/
public function getPseudoPrice()
{
@@ -571,7 +571,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
}
/**
* @param int $pseudoPrice
* @param float $pseudoPrice
*/
public function setPseudoPrice($pseudoPrice)
{
@@ -44,7 +44,7 @@ class Date
$date['@id'] = $array['dateBusProId'];
$date['from'] = $array['dateStart'];
$date['to'] = $array['dateEnd'];
$date['minprice'] = $array['dateMinPrice'];
$date['minprice'] = (float) $array['dateMinPrice'];
$date['nights'] = $array['dateNights'];
$date['skipassincluded'] = $array['dateSkiPassIncluded'];
$date['busincluded'] = $array['dateBusIncluded'];
@@ -40,8 +40,8 @@ class Room
$room['@pax'] = $array['roomPax'];
$room['@code'] = $array['roomCode'];
$room['name'] = $array['roomName'];
$room['price'] = $array['roomPrice'];
$room['busprice'] = $array['busPrice'];
$room['price'] = (float) $array['roomPrice'];
$room['busprice'] = (float) $array['busPrice'];
$room['nights'] = $array['roomNights'];
$room['available'] = $array['roomAvailable'];
@@ -81,7 +81,8 @@ class ApiClient implements LoggerAwareInterface
}
try {
return new \DateTimeImmutable($data['timestamp']);
// The API reports Europe/Berlin local time without an offset.
return new \DateTimeImmutable($data['timestamp'], new \DateTimeZone('Europe/Berlin'));
} catch (\Throwable $e) {
$message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]);
@@ -89,6 +90,22 @@ class ApiClient implements LoggerAwareInterface
}
}
/**
* One page of the product export: one row per travel date × hotel in `data`,
* and `meta` describing the whole export.
*
* @throws ApiException
*/
public function getProductExport(int $page, int $perPage): array
{
return $this->requestJson('GET', 'exports/products', [
'query' => [
'page' => $page,
'perPage' => $perPage,
],
]);
}
/**
* @throws ApiException
*/
@@ -0,0 +1,233 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reads the product export from GET /api/exports/products on MyE&P. The rows
* already have the shape {@see DateImportService::import()} consumes, so they
* are passed on unchanged.
*
* Change detection compares GET /api/last-update against the
* `meta.sourceGeneratedAt` of the last successfully imported export, kept in
* var/transient/ep_products_import/api-imported.info. Both carry the BusPro
* export's own timestamp.
*/
final class ApiImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* The whole export is one page at current volumes; the walk does not rely on it.
*/
private const PER_PAGE = 500;
/**
* How often the walk restarts when MyE&P replaces its copy of the export
* mid-walk before giving up until the next run.
*/
private const MAX_WALKS = 3;
private const STAGING_SUBDIR = 'ep_products_import';
private const BASELINE_FILE = 'api-imported.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
/**
* @var ApiClient|null
*/
private $apiClient;
public function __construct(?ApiClient $apiClient = null)
{
$this->apiClient = $apiClient;
}
/**
* The whole export is fetched before anything is returned, so a walk that
* has to restart never leaves a stitched dataset behind.
*
* @throws ApiException
*/
public function acquire(): ?ProductImport
{
if (!$this->isNewImportAvailable()) {
return null;
}
for ($walk = 1; $walk <= self::MAX_WALKS; $walk++) {
$export = $this->walkPages();
if ($export === false) {
continue;
}
if ($export === null) {
return null;
}
[$rows, $sourceGeneratedAt] = $export;
$baseline = $this->getImportedTimestamp();
if ($baseline !== null && $sourceGeneratedAt <= $baseline) {
return null;
}
return new ProductImport($rows, $sourceGeneratedAt);
}
throw new ApiException(sprintf(
'MyE&P replaced its product export during each of %d page walks, giving up until the next run',
self::MAX_WALKS
));
}
public function markImported(ProductImport $import): void
{
$sourceGeneratedAt = $import->getSourceGeneratedAt();
if ($sourceGeneratedAt !== null) {
$file = $this->baselinePath();
GeneralUtility::mkdir_deep(dirname($file));
GeneralUtility::writeFile($file, $sourceGeneratedAt->format(\DateTimeInterface::ATOM), false);
}
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
/**
* True when MyE&P reports an export newer than the last imported one, or
* nothing has been imported yet. A missing timestamp from MyE&P means
* "not importable now", never "import".
*
* @throws ApiException
*/
public function isNewImportAvailable(): bool
{
$lastUpdateAt = $this->getApiClient()->getLastUpdateAt();
if ($lastUpdateAt === null) {
return false;
}
$baseline = $this->getImportedTimestamp();
return $baseline === null || $lastUpdateAt > $baseline;
}
/**
* `meta.sourceGeneratedAt` of the last successfully imported export.
*/
public function getImportedTimestamp(): ?\DateTimeImmutable
{
$file = $this->baselinePath();
if (!is_file($file)) {
return null;
}
return $this->parseTimestamp(trim((string) file_get_contents($file)));
}
/**
* @return array{0: array[], 1: \DateTimeImmutable}|false|null the rows and
* the export's timestamp; null when the export is not importable now;
* false when the export changed mid-walk and the walk must restart
* @throws ApiException
*/
private function walkPages()
{
$rows = [];
$sourceGeneratedAt = null;
$page = 1;
do {
$body = $this->getApiClient()->getProductExport($page, self::PER_PAGE);
$meta = $body['meta'] ?? null;
if (!is_array($meta) || !isset($body['data']) || !is_array($body['data'])) {
throw new ApiException(sprintf('MyE&P product export page %d is malformed', $page));
}
$pageGeneratedAt = $this->parseTimestamp($meta['sourceGeneratedAt'] ?? null);
if ($page === 1) {
if ($pageGeneratedAt === null) {
$this->logWarning('MyE&P product export has no source timestamp; skipping product import');
return null;
}
$sourceGeneratedAt = $pageGeneratedAt;
} elseif ($pageGeneratedAt === null || $pageGeneratedAt != $sourceGeneratedAt) {
$this->logWarning('MyE&P product export changed during the page walk; restarting', [
'page' => $page,
'expected' => $sourceGeneratedAt->format(\DateTimeInterface::ATOM),
'actual' => $meta['sourceGeneratedAt'] ?? null,
]);
return false;
}
foreach ($body['data'] as $row) {
$rows[] = $row;
}
$totalPages = (int) ($meta['totalPages'] ?? 0);
$page++;
} while ($page <= $totalPages);
$total = (int) ($meta['total'] ?? -1);
if (count($rows) !== $total) {
throw new ApiException(sprintf(
'MyE&P product export is incomplete: %d rows received, %d announced',
count($rows),
$total
));
}
if ((int) ($meta['skipped'] ?? 0) > 0) {
$this->logWarning('MyE&P skipped travel dates with unparseable dates', ['skipped' => $meta['skipped']]);
}
if ((int) ($meta['pickupsWithoutMasterData'] ?? 0) > 0) {
$this->logWarning(
'MyE&P product export has pickups without master data',
['pickupsWithoutMasterData' => $meta['pickupsWithoutMasterData']]
);
}
return [$rows, $sourceGeneratedAt];
}
/**
* @param mixed $value
*/
private function parseTimestamp($value): ?\DateTimeImmutable
{
if (!is_string($value) || $value === '') {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception $e) {
return null;
}
}
private function getApiClient(): ApiClient
{
if ($this->apiClient === null) {
$this->apiClient = GeneralUtility::makeInstance(ApiClient::class);
}
return $this->apiClient;
}
private function baselinePath(): string
{
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR . '/' . self::BASELINE_FILE;
}
private function logWarning(string $message, array $context = []): void
{
if ($this->logger !== null) {
$this->logger->warning($message, $context);
}
}
}
@@ -50,6 +50,26 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
const STATUS_ON_REQUEST = 'Anfrage';
const STATUS_STOP = 'Buchungsstop';
/**
* BusPro ids of the selection groups whose selections make up the included
* services. Matched by id rather than by label, so a group renamed upstream
* does not silently drop out of services_included.
*/
const SELECTION_GROUP_SKIPASS = 7;
const SELECTION_GROUP_OTHER = 8;
const SELECTION_GROUP_BOARD = 71;
const SELECTION_GROUP_ACCOMMODATION = 85;
const SELECTION_GROUP_PROGRAMME = 86;
const SELECTION_GROUP_COURSES = 87;
const SELECTION_GROUP_EVENTS = 89;
const SELECTION_GROUP_TRANSPORTATION = 90;
/**
* 'Leistungen Personal' occurs in no export so far, so its id is unknown and
* it is still matched by label. Register its id once the warning fires.
*/
const SELECTION_GROUP_LABEL_PERSONNEL = 'Leistungen Personal';
/**
* @var ConfigurationManagerInterface
*/
@@ -65,11 +85,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/
protected $hotelMappings = [];
/**
* @var array
*/
protected $pickups = [];
/**
* @var Connection
*/
@@ -126,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();
@@ -151,50 +166,44 @@ 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
* Recreated on every run, so the temp tables always follow the schema of the
* live tables (a column type change would otherwise survive in them) and a
* crashed import cannot leave rows behind for the next one.
*/
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');
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
$this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_date_temp');
$this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_room_temp');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
}
public function clearTables(): void
@@ -216,13 +225,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 = '
@@ -260,8 +269,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);
}
}
@@ -269,25 +278,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
@@ -296,7 +298,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
@@ -322,17 +324,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,
@@ -375,15 +377,15 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'important' => $product['important'],
];
$includedAccommodation = $this->parseSelectionGroup($xmlDate, 'Leistungen Übernachtung');
$includedSkiPass = $this->parseSelectionGroup($xmlDate, 'Leistungen Skipass');
$includedBoard = $this->parseSelectionGroup($xmlDate, 'Leistungen Verpflegung');
$includedTransportation = $this->parseSelectionGroup($xmlDate, 'Leistungen Transport');
$includedProgramme = $this->parseSelectionGroup($xmlDate, 'Leistungen Programm');
$includedPersonnel = $this->parseSelectionGroup($xmlDate, 'Leistungen Personal');
$includedEvents = $this->parseSelectionGroup($xmlDate, 'Leistungen Events');
$includedOther = $this->parseSelectionGroup($xmlDate, 'Leistungen Sonstige');
$includedCourses = $this->parseSelectionGroup($xmlDate, 'Leistungen Kurse');
$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,
@@ -408,7 +410,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;
@@ -430,11 +432,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
if ($discount) {
$servicesTransportationOptional[] = [
'label' => 'Rabatt bei Eigenanreise',
'price' => $discount,
'price' => number_format($discount, 2, ',', '.'),
];
}
$servicesGeneral = $this->parseService($xmlDate, 'lei_sonstiges');
$servicesGeneral = $this->parseService($row['dateServices']);
$combinedServices = [];
@@ -456,29 +458,22 @@ 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'];
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) {
[$pickupDate, $pickupTime] = $this->parsePickupDateTime($pickup['datetime']);
}
$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,
];
@@ -495,21 +490,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]);
@@ -522,7 +516,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) {
@@ -532,7 +526,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'];
@@ -551,57 +545,33 @@ 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;
$minPriceOverall = 0.0;
$minPriceAvailable = 0.0;
$minPax = 0;
$availableTotal = 0;
$boardCode = '';
$count = 0;
foreach ($xmlHotel->xpath('zimmer/preis') as $roomXml) {
$code = trim((string) $roomXml->attributes()['zimmercode']);
if ($row['pseudoPrice'] !== null) {
$date['pseudo_price'] = (float) $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 = (float) $roomRow['price'];
// Skip rooms with zero price _and_ zero availability.
if (!$available && !$price) {
@@ -628,38 +598,38 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$availableTotal += $available * $pax;
// Determine lowest price of available rooms
if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0 || $price < $minPriceAvailable)) {
if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0.0 || $price < $minPriceAvailable)) {
$minPriceAvailable = $price;
}
// Determine lowest overall price
if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0 || $price < $minPriceOverall)) {
if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0.0 || $price < $minPriceOverall)) {
$minPriceOverall = $price;
}
// 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,
@@ -683,45 +653,43 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/**
* @param array $services
* @return int
* @return float
*/
protected function determineDiscount(array $services): int
protected function determineDiscount(array $services): float
{
foreach ($services as $key => $section) {
foreach ($section as $service) {
if ($service['price'] < 0) {
return (int) $service['price'];
$price = $this->parseFormattedPrice($service['price']);
if ($price < 0) {
return $price;
}
}
}
return 0;
return 0.0;
}
/**
* @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;
}
@@ -731,13 +699,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') {
@@ -748,8 +717,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$data[$type] = [];
}
$data[$type][(int)$service->attributes()['id']] = [
'idBusPro' => (int) $service->attributes()['idbuspro'],
$data[$type][(int) $service['busProId']] = [
'idBusPro' => (int) $service['busProId'],
'label' => $label,
'price' => number_format($price, 2, ',', '.'),
];
@@ -761,21 +730,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}
/**
* @param \SimpleXMLElement $xmlDate
* @param string $label
* @param array[] $selectionGroups
* @param int $busProId
* @return array
*/
protected function parseSelectionGroup(\SimpleXMLElement $xmlDate, string $label): array
protected function parseSelectionGroup(array $selectionGroups, int $busProId): array
{
$data = [];
$selectionGroup = $xmlDate->xpath('selektiongruppe[@bezeichnung="' . $label . '"]/selektion');
foreach ($selectionGroup 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);
@@ -783,6 +748,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
@@ -794,12 +793,44 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}
foreach ($services['BUS'] as $service) {
$price = $this->parseFormattedPrice($service['price']);
$date['bus_available'] = 1;
$date['bus_price'] = (int) $service['price'];
$date['bus_included'] = (int) ((int) $service['price'] === 0);
$date['bus_price'] = $price;
$date['bus_included'] = (int) ($price === 0.0);
}
}
/**
* A pickup time is the local date and time as BusPro states it
* ("2027-03-28T02:45:00"), taken over as is. An ISO-8601 time with an
* offset is still accepted and converted to Europe/Berlin, which cannot
* express a time inside the daylight saving gap.
*
* @param string $datetime
* @return string[] date as d.m.Y and time as H:i
*/
protected function parsePickupDateTime(string $datetime): array
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/', $datetime, $parts)) {
return [$parts[3] . '.' . $parts[2] . '.' . $parts[1], $parts[4] . ':' . $parts[5]];
}
$pickupDateTime = (new \DateTimeImmutable($datetime))->setTimezone(new \DateTimeZone('Europe/Berlin'));
return [$pickupDateTime->format('d.m.Y'), $pickupDateTime->format('H:i')];
}
/**
* Reads back a price parseService() formatted for display ("1.234,50").
*
* @param string $price
* @return float
*/
protected function parseFormattedPrice(string $price): float
{
return (float) str_replace(['.', ','], ['', '.'], $price);
}
protected function getHotelMappings(): void
{
$sql = '
@@ -813,7 +844,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0
UNION SELECT matchcode.code code, matchcode.entity uid
FROM tx_epproducts_domain_model_matchcode matchcode
INNER JOIN tx_epproducts_domain_model_hotel matchcode_hotel ON matchcode.entity = matchcode_hotel.uid
WHERE matchcode.deleted = 0 AND matchcode.hidden = 0
AND matchcode_hotel.deleted = 0 AND matchcode_hotel.hidden = 0
ORDER BY code
) u
LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid
@@ -870,27 +903,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,
];
}
}
}
@@ -2,6 +2,7 @@
namespace EP\EpProducts\Service;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -17,16 +18,22 @@ final class LocalImportSource implements ProductImportSource, SingletonInterface
private const UPLOAD_MARKER = 'fileadmin/xmlexport/uebertragung.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
public function acquire(): ?string
public function acquire(): ?ProductImport
{
if (!$this->isNewImportAvailable()) {
return null;
}
return GeneralUtility::getFileAbsFileName(self::XML_DIR);
$path = GeneralUtility::getFileAbsFileName(self::XML_DIR);
$reader = new XmlProductReader(
$path,
GeneralUtility::makeInstance(LogManager::class)->getLogger(XmlProductReader::class)
);
return new ProductImport($reader, $reader->getSourceGeneratedAt(), $path);
}
public function markImported(string $path): void
public function markImported(ProductImport $import): void
{
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
@@ -0,0 +1,56 @@
<?php
namespace EP\EpProducts\Service;
/**
* One acquired product export: the rows to hand to
* {@see DateImportService::import()}, the source's own timestamp, and whatever
* the source needs to find it again in {@see ProductImportSource::markImported()}.
*/
final class ProductImport
{
/**
* @var iterable<array>
*/
private $rows;
/**
* @var \DateTimeImmutable|null
*/
private $sourceGeneratedAt;
/**
* @var string|null
*/
private $location;
/**
* @param iterable<array> $rows
* @param \DateTimeImmutable|null $sourceGeneratedAt
* @param string|null $location local directory of an XML export, null otherwise
*/
public function __construct(iterable $rows, ?\DateTimeImmutable $sourceGeneratedAt, ?string $location = null)
{
$this->rows = $rows;
$this->sourceGeneratedAt = $sourceGeneratedAt;
$this->location = $location;
}
/**
* @return iterable<array>
*/
public function getRows(): iterable
{
return $this->rows;
}
public function getSourceGeneratedAt(): ?\DateTimeImmutable
{
return $this->sourceGeneratedAt;
}
public function getLocation(): ?string
{
return $this->location;
}
}
@@ -3,25 +3,26 @@
namespace EP\EpProducts\Service;
/**
* A source of the busProNet product export (Ziel_*.xml, zustiege.xml,
* uebertragung.info). Implementations either read a local drop directory or
* pull the files from a remote SFTP server; the caller does not care which.
* A source of the BusPro product export. Implementations read the XML export
* from a local drop directory or a remote SFTP server, or the JSON export from
* the MyE&P API; the caller does not care which.
*/
interface ProductImportSource
{
/**
* Returns a local directory ready to hand to {@see DateImportService::import()},
* Returns the export ready to hand to {@see DateImportService::import()},
* or null when there is nothing newer than the last successful import.
*
* @throws SftpImportException on an unrecoverable acquisition failure
* @throws SftpImportException on an unrecoverable SFTP acquisition failure
* @throws \EP\EpProducts\MyEP\ApiException on an unrecoverable API failure
*/
public function acquire(): ?string;
public function acquire(): ?ProductImport;
/**
* Called once {@see DateImportService::import()} of the acquired path has
* Called once {@see DateImportService::import()} of the acquired export has
* succeeded, so the source can advance its "last imported" bookkeeping.
*/
public function markImported(string $path): void;
public function markImported(ProductImport $import): void;
/**
* Best-effort, side-effect-free check for whether a newer export is available.
@@ -9,18 +9,24 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Picks the product import source based on the `productImportSource` extension
* configuration switch: `sftp` (default) pulls from the remote SFTP server,
* `local` reads the legacy fileadmin/xmlexport/ drop directory.
* `local` reads the legacy fileadmin/xmlexport/ drop directory, `api` reads the
* product export of the MyE&P API.
*/
final class ProductImportSourceFactory implements SingletonInterface
{
public const MODE_SFTP = 'sftp';
public const MODE_LOCAL = 'local';
public const MODE_API = 'api';
public function get(): ProductImportSource
{
if ($this->getMode() === self::MODE_LOCAL) {
$mode = $this->getMode();
if ($mode === self::MODE_LOCAL) {
return GeneralUtility::makeInstance(LocalImportSource::class);
}
if ($mode === self::MODE_API) {
return GeneralUtility::makeInstance(ApiImportSource::class);
}
return GeneralUtility::makeInstance(SftpImportSource::class);
}
@@ -30,6 +36,6 @@ final class ProductImportSourceFactory implements SingletonInterface
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
$mode = strtolower(trim((string)(is_array($config) ? ($config['productImportSource'] ?? '') : '')));
return $mode === self::MODE_LOCAL ? self::MODE_LOCAL : self::MODE_SFTP;
return in_array($mode, [self::MODE_LOCAL, self::MODE_API], true) ? $mode : self::MODE_SFTP;
}
}
@@ -12,8 +12,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Pulls the busProNet product export (Ziel_*.xml, zustiege.xml, uebertragung.info)
* from a remote SFTP server into a local staging directory so the existing
* {@see DateImportService::import()} can keep operating on a plain local folder.
* from a remote SFTP server into a local staging directory and reads it through
* an {@see XmlProductReader}.
*
* Change detection compares the remote uebertragung.info timestamp against the
* staged copy of the last successfully imported export
@@ -42,11 +42,19 @@ final class SftpImportSource implements ProductImportSource, SingletonInterface,
private $configuration;
/**
* {@see ProductImportSource::acquire()} — alias for {@see fetchIfNewer()}.
* {@see ProductImportSource::acquire()} — {@see fetchIfNewer()}, read through
* an {@see XmlProductReader}.
*/
public function acquire(): ?string
public function acquire(): ?ProductImport
{
return $this->fetchIfNewer();
$path = $this->fetchIfNewer();
if ($path === null) {
return null;
}
$reader = new XmlProductReader($path, $this->logger);
return new ProductImport($reader, $reader->getSourceGeneratedAt(), $path);
}
/**
@@ -54,9 +62,9 @@ final class SftpImportSource implements ProductImportSource, SingletonInterface,
* change-detection baseline (only reached after a successful import), then
* refresh the display-only marker for the BE module and system-info toolbar.
*/
public function markImported(string $path): void
public function markImported(ProductImport $import): void
{
$this->commit($path);
$this->commit((string)$import->getLocation());
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
@@ -0,0 +1,369 @@
<?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. Like the API, service labels and pickup
* cities and streets are trimmed with inner whitespace collapsed; every other
* text is passed on verbatim.
*/
final class XmlProductReader implements \IteratorAggregate
{
private const PRODUCT_PREFIX = 'Ziel';
private const PICKUPS_FILE = 'zustiege.xml';
private const MARKER_FILE = 'uebertragung.info';
private const MARKER_TS_FORMAT = 'd.m.Y H:i:s';
private const CODE_PSEUDOPRICE = 'PDGS';
private const TIMEZONE = 'Europe/Berlin';
private const LOCAL_DATETIME_FORMAT = 'Y-m-d\TH:i:s';
/**
* @var string
*/
private $path;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var \DateTimeZone
*/
private $timezone;
/**
* Pickup times are clock readings, not instants. They are handled in UTC,
* which has no daylight saving gaps, so a time BusPro plans inside one
* (02:45 on the night clocks go forward) is kept as stated.
*
* @var \DateTimeZone
*/
private $wallClock;
/**
* @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);
$this->wallClock = new \DateTimeZone('UTC');
}
public function getPath(): string
{
return $this->path;
}
/**
* The export's own timestamp from uebertragung.info, or null if unreadable.
*/
public function getSourceGeneratedAt(): ?\DateTimeImmutable
{
$file = $this->path . '/' . self::MARKER_FILE;
if (!is_file($file)) {
return null;
}
$line = trim((string) strtok((string) file_get_contents($file), "\r\n"));
$date = \DateTimeImmutable::createFromFormat(self::MARKER_TS_FORMAT, $line, $this->timezone);
return $date ?: null;
}
/**
* @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' => $this->normalizeWhitespace((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. The result is the local
* date and time as BusPro states it, without an offset.
*/
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->wallClock) ?: 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->wallClock) ?: 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(self::LOCAL_DATETIME_FORMAT) : 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' => $this->normalizeWhitespace((string) $xmlPickup->ort),
'street' => $this->normalizeWhitespace((string) $xmlPickup->strasse),
];
}
return $pickups;
}
private function normalizeWhitespace(string $value): string
{
return trim((string) preg_replace('/\s+/u', ' ', $value));
}
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;
}
}
@@ -44,16 +44,16 @@ class ImportDatesTask extends AbstractTask
$cacheService = GeneralUtility::makeInstance(CacheService::class);
// Acquire the export only when it is newer than the one we last imported
// (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport).
// An acquisition failure (SFTP connect/auth/transfer) is left to propagate
// so the scheduler marks the task failed.
$path = $importSource->acquire();
// (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport;
// API mode: walk the MyE&P export pages). An acquisition failure is left
// to propagate so the scheduler marks the task failed.
$import = $importSource->acquire();
if ($path !== null) {
$importService->import($path);
if ($import !== null) {
$importService->import($import->getRows());
// Advance the "last imported" bookkeeping only after a successful
// import, so a crashed import retries on the next run.
$importSource->markImported($path);
$importSource->markImported($import);
}
$settings = $this->getSettings();
@@ -52,7 +52,7 @@ epTeamApiTimeout = 5
# cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited)
epTeamApiMaxDuration = 15
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local,MyE&P API=api]; label=Product/date import source
productImportSource = sftp
# cat=SftpImport; type=string; label=SFTP host for product/date import
@@ -117,15 +117,15 @@ CREATE TABLE tx_epproducts_domain_model_date
bus_included tinyint(1) unsigned DEFAULT '0' NOT NULL,
bus_available tinyint(1) unsigned DEFAULT '0' NOT NULL,
skipass_included tinyint(1) unsigned DEFAULT '0' NOT NULL,
bus_price int(11) DEFAULT '0' NOT NULL,
bus_price decimal(10,2) DEFAULT '0.00' NOT NULL,
services_included text NOT NULL,
services_general text NOT NULL,
courses_included text NOT NULL,
pickups text NOT NULL,
guide text NOT NULL,
discount int(11) DEFAULT '0' NOT NULL,
min_price int(11) DEFAULT '0' NOT NULL,
pseudo_price int(11) DEFAULT '0' NOT NULL,
discount decimal(10,2) DEFAULT '0.00' NOT NULL,
min_price decimal(10,2) DEFAULT '0.00' NOT NULL,
pseudo_price decimal(10,2) DEFAULT '0.00' NOT NULL,
available int(11) DEFAULT '0' NOT NULL,
min_pax int(11) DEFAULT '0' NOT NULL,
product int(11) unsigned DEFAULT '0',
@@ -456,7 +456,7 @@ CREATE TABLE tx_epproducts_domain_model_room
name varchar(255) DEFAULT '' NOT NULL,
code varchar(255) DEFAULT '' NOT NULL,
type int(11) DEFAULT '0' NOT NULL,
price int(11) DEFAULT '0' NOT NULL,
price decimal(10,2) DEFAULT '0.00' NOT NULL,
services text NOT NULL,
nights int(11) DEFAULT '0' NOT NULL,
pax int(11) DEFAULT '0' NOT NULL,
@@ -0,0 +1,45 @@
<?php
namespace EP\EpTheme\ViewHelpers;
use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithContentArgumentAndRenderStatic;
/**
* Formats a euro amount the way the website always has for whole euros ("229",
* "1234"), and with German decimals where there are cents ("99,90"). Prices are
* stored with cents, and the database hands them over as strings like "229.00".
*
* Keep in sync with formatPrice() in pricetable_controller.js.
*
* Usage: {row.minPrice -> ep:price()} €
*/
class PriceViewHelper extends AbstractViewHelper
{
use CompileWithContentArgumentAndRenderStatic;
public function initializeArguments()
{
parent::initializeArguments();
$this->registerArgument('value', 'mixed', 'Amount in euros');
}
/**
* @param array $arguments
* @param \Closure $renderChildrenClosure
* @param RenderingContextInterface $renderingContext
* @return string
*/
public static function renderStatic(
array $arguments,
\Closure $renderChildrenClosure,
RenderingContextInterface $renderingContext
) {
$amount = round((float) $renderChildrenClosure(), 2);
$decimals = floor($amount) === $amount ? 0 : 2;
return number_format($amount, $decimals, ',', '');
}
}
@@ -42,13 +42,13 @@ export default class extends Controller {
modeValueChanged() {
this.priceTargets.forEach(price => {
let room = parseInt(price.dataset.room)
let bus = parseInt(price.dataset.bus)
let discount = parseInt(price.dataset.discount)
price.innerText = `${this.calculatePrice(room, bus, discount)} €`
let room = parseFloat(price.dataset.room)
let bus = parseFloat(price.dataset.bus)
let discount = parseFloat(price.dataset.discount)
price.innerText = `${this.formatPrice(this.calculatePrice(room, bus, discount))} €`
})
this.busIconTargets.forEach(icon => {
let busPrice = parseInt(icon.dataset.busPrice)
let busPrice = parseFloat(icon.dataset.busPrice)
let busIncluded = !!icon.dataset.busIncluded
icon.classList.toggle('hidden', this.isHideBusIcon(busPrice, busIncluded))
})
@@ -87,6 +87,16 @@ export default class extends Controller {
return minPrice
}
// Keep in sync with EP\EpTheme\ViewHelpers\PriceViewHelper: whole euros
// as before ("229"), German decimals where there are cents ("99,90").
formatPrice(amount) {
let cents = Math.round(amount * 100)
if (cents % 100 === 0) {
return String(cents / 100)
}
return (cents / 100).toFixed(2).replace('.', ',')
}
isHideBusIcon(busPrice, busIncluded) {
return (
busPrice && this.modeValue !== 'surcharge' ||
@@ -39,7 +39,7 @@
{hotel.teaser -> f:format.crop(maxCharacters: settings.teaserTextMaxChars) -> f:format.html()}
<f:if condition="{hotel.available} != -1">
<span class="teaserbox__price">
<strong>{f:if(condition: '{hotel.available} > 0', then: 'ab {hotel.minPrice} €', else: 'aktuell nicht buchbar')}</strong>
<strong><f:if condition="{hotel.available} > 0"><f:then>ab {hotel.minPrice -> ep:price()} €</f:then><f:else>aktuell nicht buchbar</f:else></f:if></strong>
</span>
</f:if>
</div>
@@ -53,10 +53,10 @@
<td class="px-2 py-1 border border-zinc-200">
<f:if condition="{row.available}">
<f:then>
{row.minPrice} €
{row.minPrice -> ep:price()} €
</f:then>
<f:else>
<s>{row.minPrice} €</s>
<s>{row.minPrice -> ep:price()} €</s>
</f:else>
</f:if>
</td>
@@ -104,7 +104,7 @@
</ul>
<div class="bg-ep-primary-dark text-white font-bold px-4 py-1 peer-hover:bg-badge-gradient peer-hover:text-white transition-colors">
<div class="lg:inline-block">
ab <span class="product-teaser__price mx-1">{minPrice} €</span>
ab <span class="product-teaser__price mx-1">{minPrice -> ep:price()} €</span>
</div>
<div class="product-teaser__label-price">
p. P.{f:if(condition: '{season} == "w" && {skiPassIncluded}', then: ' / inkl. Skipass')}
@@ -129,9 +129,9 @@
</td>
</f:if>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-center">
<f:if condition="{row.pseudoPrice}">
<f:if condition="{row.pseudoPrice} > 0">
<span class="block text-sm line-through">
{row.pseudoPrice} €
{row.pseudoPrice -> ep:price()} €
</span>
</f:if>
<span class="font-bold normal-case text-sm md:text-base whitespace-nowrap"
@@ -139,7 +139,7 @@
data-room="{row.minPrice}"
data-bus="{row.busPrice}"
data-discount="{row.discount}">
{row.minPrice}
{row.minPrice -> ep:price()}
</span>
</td>
<td class="px-2 py-1 md:p-2 text-center">
@@ -61,14 +61,14 @@
<f:link.typolink
parameter="{row.hotelDetailPageUid}"
class="whitespace-nowrap {ep:themeClasses(classes: '{sbw: \'text-sbw-primary\', snz: \'text-snz-primary\', uch: \'text-uch-primary\'}', key: settings.themekey)}">
{row.rooms.{type}.price}
{row.rooms.{type}.price -> ep:price()}
{f:if(condition: '{row.rooms.{type}.undersubscription}', then: '*')}
</f:link.typolink>
</f:then>
<f:else>
<f:if condition="{row.rooms.{type}.available}">
<f:else>
<span class="line-through text-zinc-500">{row.rooms.{type}.price}</span>
<span class="line-through text-zinc-500">{row.rooms.{type}.price -> ep:price()}</span>
</f:else>
</f:if>
</f:else>
@@ -266,7 +266,7 @@
data-room="{row.minPrice}"
data-bus="{row.busPrice}"
data-discount="{row.discount}">
{row.minPrice}
{row.minPrice -> ep:price()}
</span>
</a>
</f:then>
@@ -279,7 +279,7 @@
data-room="{row.minPrice}"
data-bus="{row.busPrice}"
data-discount="{row.discount}">
{row.minPrice} €
{row.minPrice -> ep:price()} €
</span>
</f:else>
</f:if>
@@ -127,7 +127,7 @@
</tr>
<tr>
<th>Abpreis</th>
<td>{date.minprice} €</td>
<td>{date.minprice -> ep:price()} €</td>
</tr>
<tr>
<th>Bus inkl.</th>
@@ -154,7 +154,7 @@
<tr>
<td>{room.name}</td>
<td>{v:variable.get(name: 'room.@pax')}</td>
<td>{room.price} €</td>
<td>{room.price -> ep:price()} €</td>
<td>{room.available}</td>
</tr>
</f:for>