Compare commits
6
Commits
d338529afc
...
b41aeb7a25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b41aeb7a25 | ||
|
|
c2dc88b8ab | ||
|
|
27832d6548 | ||
|
|
b510299032 | ||
|
|
1b6f480bee | ||
|
|
b9851943d0 |
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'];
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class FacebookCatalogCsvFormatter
|
||||
$product['images']['image'][0],
|
||||
$product['link'],
|
||||
$product['name'],
|
||||
$minPrice . ' EUR',
|
||||
sprintf('%.2f EUR', $minPrice),
|
||||
'brand-epreisen'
|
||||
];
|
||||
}
|
||||
|
||||
+1
-1
@@ -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, ',', '');
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -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">
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user