wip: booking process

This commit is contained in:
Björn Fromme
2025-07-16 11:55:49 +02:00
parent bef18971c3
commit 47faa6b08e
26 changed files with 1123 additions and 271 deletions
+45 -11
View File
@@ -11,6 +11,7 @@ use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingEditDto;
@@ -31,7 +32,9 @@ class ApiClient
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
private array $config;
@@ -121,7 +124,7 @@ class ApiClient
'adressdaten' => $personalData->toPayload(),
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, $debug);
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug);
}
/**
@@ -196,20 +199,19 @@ class ApiClient
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, $debug);
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug);
}
/**
* @throws ApiClientException
*/
public function getMutableData(int $id): Notification|BaseData
public function getMutableData(int $travelId): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA),
'satz' => ['@typ' => static::TYPE_MUTABLE_DATA],
'art' => 'Vorgang_Details',
'idreise' => $id,
'idreise' => $travelId,
];
return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data);
@@ -218,18 +220,35 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $id): Notification|BaseData
public function getAvailabilities(int $travelId): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY),
'satz' => ['@typ' => static::TYPE_AVAILABILITY],
'idreise' => $id,
'idreise' => $travelId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $travelId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL],
'idreise' => $travelId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
];
return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data);
}
/**
* @throws ApiClientException
*/
@@ -264,7 +283,7 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $id, string $type): mixed
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
{
$data = [
'user' => $this->config['bpn_username'],
@@ -273,7 +292,7 @@ class ApiClient
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
'idbuchung' => $bookingId,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
@@ -282,7 +301,22 @@ class ApiClient
/**
* @throws ApiClientException
*/
private function sendRequest(string $type, array $data, bool $debug = false): mixed
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
'idprodukt' => $travelId,
];
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
}
/**
* @throws ApiClientException
*/
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
$requestId = date(DATE_ATOM).uniqid();
@@ -320,7 +354,7 @@ class ApiClient
}
try {
return $this->responseParser->parseXmlString($type, $xml);
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
} catch (ResponseParserException $e) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
+19 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use App\BusProNet\Constants;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
@@ -101,7 +102,7 @@ class Travel
$group = (array) $group;
$services = array_filter($this->additionalServices, function (Service $service) use ($group, $availableOnly) {
return in_array($service->subType, $group) && (false === $availableOnly || $service->available > 0);
return true === in_array($service->subType, $group) && (false === $availableOnly || 0 < $service->available);
});
usort($services, function (Service $a, Service $b) {
@@ -150,7 +151,7 @@ class Travel
$services = [];
foreach (CrmSelectionGroup::$includedServicesMapping as $id => $label) {
if (isset($this->selectionGroups[$id])) {
if (true === isset($this->selectionGroups[$id])) {
$services[] = $this->selectionGroups[$id];
}
}
@@ -171,7 +172,7 @@ class Travel
*/
public function getRoomsByIds(array $ids): array
{
if (empty($ids)) {
if (true === empty($ids)) {
return [];
}
@@ -180,4 +181,19 @@ class Travel
});
}
/**
* Retrieves all available rooms for booking.
*
* Filters the rooms collection to return only rooms that have availability
* greater than zero and have an available status. This ensures only
* bookable rooms are returned for selection.
*
* @return array<Room> The filtered array of available rooms
*/
public function getAvailableRooms(): array
{
return array_filter($this->rooms, function (Room $room) {
return $room->available > 0 && $room->status === Constants::STATUS_AVAILABLE;
});
}
}
+87 -209
View File
@@ -2,18 +2,11 @@
namespace App\BusProNet\XmlLoader;
use App\BusProNet\Constants;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\Guide;
use App\BusProNet\Model\MutableData;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DayTimeUtility;
use App\BusProNet\Utility\TravelCodeUtility;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
@@ -21,17 +14,41 @@ use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* XML loader for travel data from BusProNet XML exports.
*
* This loader processes XML files containing travel information including dates,
* hotels, services, rooms, and pricing. It provides caching and mapping functionality
* to efficiently load and parse travel data from XML exports.
*/
class TravelLoader extends AbstractLoader
{
/**
* @param HotelLoader $hotelDataLoader Hotel data loader for hotel information
* @param TravelParser $travelParser Parser for XML travel data
* @param string $travelInfoBaseUrl Base URL for travel information pages
* @param CacheInterface $cache Cache interface for performance optimization
* @param FilesystemOperator $xmlExport Filesystem operator for XML file access
*/
public function __construct(
private readonly HotelLoader $hotelDataLoader,
CacheInterface $cache,
private readonly TravelParser $travelParser,
private readonly string $travelInfoBaseUrl,
CacheInterface $cache,
FilesystemOperator $xmlExport,
) {
parent::__construct($cache, $xmlExport);
}
/**
* Generate a cached mapping of travel IDs to their file locations and metadata.
*
* Scans all XML files starting with 'Ziel_' and extracts travel information
* including ID, code, label, dates, and associated hotels. The mapping is
* cached for 3 hours to improve performance.
*
* @return array<int, array> Mapping of travel IDs to their metadata and file paths
*/
public function generateFilesMap(): array
{
try {
@@ -85,6 +102,15 @@ class TravelLoader extends AbstractLoader
}
}
/**
* Map a travel code to its corresponding travel ID.
*
* Uses the cached files mapping to find the travel ID associated with
* the given travel code. Returns null if no matching travel is found.
*
* @param string $travelCode The travel code to look up
* @return int|null The travel ID or null if not found
*/
public function mapCodeToId(string $travelCode): ?int
{
$mapping = $this->generateFilesMap();
@@ -98,6 +124,17 @@ class TravelLoader extends AbstractLoader
return $travelId;
}
/**
* Load a travel object by its ID and optional hotel ID.
*
* Retrieves travel data from XML exports. If filename is provided, loads directly
* from that file. Otherwise, uses the cached mapping to find the appropriate file.
*
* @param int $travelId The travel ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string|null $filename Optional filename to load from directly
* @return Travel|null The loaded travel object or null if not found
*/
public function loadById(int $travelId, ?int $hotelId = null, ?string $filename = null): ?Travel
{
if (null !== $filename) {
@@ -115,217 +152,41 @@ class TravelLoader extends AbstractLoader
return $this->loadXml($travelId, $hotelId, $filename);
}
private function loadXml(int $id, ?int $hotelId, string $filename): ?Travel
/**
* Load travel data from a specific XML file.
*
* Reads and parses XML file content to extract travel information for
* the specified travel ID and optional hotel ID.
*
* @param int $travelId The travel ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string $filename The XML filename to load from
* @return Travel|null The loaded travel object or null if not found
*/
private function loadXml(int $travelId, ?int $hotelId, string $filename): ?Travel
{
$xml = $this->xmlExport->read($filename);
$crawler = new Crawler($xml);
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $id));
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $travelId));
if (0 === $travelNode->count()) {
return null;
}
return $this->parseXml($travelNode->first(), $hotelId);
}
public function parseXml(Crawler $node, ?int $hotelId): Travel
{
$hotelNode = $this->getHotelNode($node, $hotelId);
$dateFrom = $this->stringToDate($node->attr('termin'));
$travel = new Travel();
$travel->id = (int) $node->attr('idbuspro');
$travel->hotelId = (int) $hotelNode->attr('idbuspro');
$travel->label = $this->getStringOrNullValue($node->filterXPath('//text'));
$travel->dateFrom = $dateFrom;
$travel->dateTo = $this->stringToDate($node->attr('bis'));
$travel->code = $node->attr('code');
$travel->type = $node->attr('reiseart');
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
$travel->additionalServices = $this
->getAdditionalServices($node->filterXPath('//lei_sonstiges/leistung'));
$travel->transportationServices = $this
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
$travel->rooms = $this->getRooms($hotelNode);
$travel->pickupsTo = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom);
$travel->pickupsFro = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'));
$travel->guide = $this->getGuide($node);
return $travel;
}
public function getSelectionGroups(Crawler $node): array
{
$selectionGroups = [];
$node->each(function (Crawler $groupNode) use (&$selectionGroups) {
$groupId = (int) $groupNode->attr('idbuspro');
$selectionGroup = new CrmSelectionGroup();
$selectionGroup->id = $groupId;
$selectionGroup->label = $groupNode->attr('bezeichnung');
$groupNode
->filterXPath('//selektion')
->each(function (Crawler $selectionNode) use (&$selectionGroups, &$selectionGroup, $groupId) {
$selectionId = (int) $selectionNode->attr('idbuspro');
$selection = new CrmSelection();
$selection->id = $selectionId;
$selection->label = $selectionNode->attr('bezeichnung');
$selectionGroups[$groupId]['selections'][$selectionId] = $selectionNode->attr('bezeichnung');
$selectionGroup->selections[] = $selection;
})
;
$selectionGroups[$groupId] = $selectionGroup;
});
return $selectionGroups;
}
public function getAdditionalServices(Crawler $node): array
{
$additionalServices = [];
$node->each(function (Crawler $serviceNode) use (&$additionalServices) {
$serviceId = (int) $serviceNode->attr('idbuspro');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_ADDITIONAL;
$service->id = $serviceId;
$service->subType = $serviceNode->attr('unterart');
$service->mandatory = $this->stringToBool($serviceNode->attr('pflicht'));
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
$service->price = $this
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('//preis')));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
$additionalServices[$serviceId] = $service;
});
return $additionalServices;
}
public function getGuide(Crawler $travelNode): ?Guide
{
$guideNodes = $travelNode
->filterXPath('//lei_befoerderung/leistung[@unterart="BUS"]/zustiegsplanung/reiseleiter');
if (0 < $guideNodes->count()) {
$guideNode = $guideNodes->first();
$guide = new Guide();
$guide->name = $this->getStringOrNullValue($guideNode->filterXPath('//name'));
$guide->phone = $this->getStringOrNullValue($guideNode->filterXPath('//telefon'));
return $guide;
}
return null;
}
public function getTransportationServices(Crawler $node): array
{
$transportationServices = [];
$node->each(function (Crawler $serviceNode) use (&$transportationServices) {
$serviceId = (int) $serviceNode->attr('idbuspro');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_TRANSPORTATION;
$service->id = $serviceId;
$service->subType = $serviceNode->attr('unterart');
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
$service->direction = $this->getStringOrNullValue($serviceNode->filterXPath('//richtung'));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
if (null !== $timeFrom = $serviceNode->attr('uhrzeit_von')) {
$service->timeFrom = $timeFrom;
$service->dayTime = (new DayTimeUtility())->mapTime($timeFrom);
}
$transportationServices[$serviceId] = $service;
});
return $transportationServices;
}
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
{
$pickups = [];
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) {
$pickupId = (int) $pickupNode->attr('idbuspro');
$pickup = new Pickup();
$pickup->id = $pickupId;
$pickup->price = $pickupNode->attr('preis') ?
$this->stringToFloat($pickupNode->attr('preis')) : null;
// parse date and time only for direction 'to' indicated by provided default date
if (null !== $defaultDate) {
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate);
}
$pickups[$pickupId] = $pickup;
});
return $pickups;
}
public function getHotelNode(Crawler $node, ?int $hotelId): Crawler
{
// In case not hotel id is provided, take the first hotel node (which is most probably the only one)
if (null === $hotelId) {
return $node->filterXPath('//hotel')->first();
}
$hotelNode = $node->filterXPath(sprintf('//hotel[@idbuspro="%d"]', $hotelId));
return $hotelNode->first();
}
public function getRooms(Crawler $node): array
{
$roomNodes = $node->filterXPath('//zimmer/preis');
if (0 === $roomNodes->count()) {
return [];
}
$rooms = [];
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
$roomId = (int) $roomNode->attr('idbuspro_zimmer');
$room = new Room();
$room->id = $roomId;
$room->code = $roomNode->attr('zimmercode');
$room->category = $roomNode->attr('kat');
$room->boardId = (int) $roomNode->attr('idbuspro_vp');
$room->label = $roomNode->attr('zimmertext');
$room->minPax = (int) $roomNode->attr('minpax');
$room->maxPax = (int) $roomNode->attr('maxpax');
$room->nights = (int) $roomNode->attr('naechte');
$room->price = $roomNode->attr('preis') ?
$this->stringToFloat($roomNode->attr('preis')) : null;
$room->status = $roomNode->attr('status');
$room->available = (int) $roomNode->attr('verfuegbar');
$rooms[$roomId] = $room;
});
return $rooms;
return $this->travelParser->parse($travelNode->first(), $hotelId);
}
/**
* Apply mutability settings to travel object.
*
* Updates the travel object's mutability flags based on the provided
* mutable data configuration. This controls which travel components
* can be modified during booking.
*
* @param Travel $travel The travel object to update
* @param BaseData $mutableData The mutability configuration data
*/
public function patchMutability(Travel $travel, BaseData $mutableData): void
{
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_ADDITIONAL_SERVICES)) {
@@ -353,6 +214,15 @@ class TravelLoader extends AbstractLoader
}
}
/**
* Apply availability data to travel services.
*
* Updates the availability status of additional and transportation
* services based on the provided availability data.
*
* @param Travel $travel The travel object to update
* @param BaseData $availabilities The availability data for services
*/
public function patchAvailabilities(Travel $travel, BaseData $availabilities): void
{
$serviceAvailabilities = $availabilities->getItems();
@@ -364,6 +234,14 @@ class TravelLoader extends AbstractLoader
}
}
/**
* Enhance booking data with travel information and URLs.
*
* Loads travel data for each booking and generates travel info URLs
* for bookings that are within 5 days of their travel date.
*
* @param BaseData $bookings The booking data to enhance
*/
public function patchBookings(BaseData $bookings): void
{
$travelCodeUtility = new TravelCodeUtility();
@@ -11,7 +11,7 @@ class ApiResponseParser extends AbstractParser
/**
* @throws ResponseParserException
*/
public function parseXmlString(string $type, string $xml): mixed
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
{
$crawler = new Crawler($xml);
@@ -19,7 +19,7 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Empty response received from server');
}
// Override type when present in XML to catch error responses
// Override type unless present in XML to catch error responses
$responseType = $this
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type;
@@ -54,9 +54,14 @@ class ApiResponseParser extends AbstractParser
case ApiClient::TYPE_MUTABLE_DATA:
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
return (new AvailabilitiesParser())->parse($resultNode);
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
$travelNode = $crawler->filterXPath('//reise/termin');
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
}
throw new ResponseParserException('Unable to parse XML response');
@@ -4,11 +4,12 @@ namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\Availability;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Room;
use Symfony\Component\DomCrawler\Crawler;
class AvailabilitiesParser extends AbstractParser
{
public function parse(Crawler $result): BaseData
public function parseServices(Crawler $result): BaseData
{
$availabilities = [];
@@ -27,4 +28,27 @@ class AvailabilitiesParser extends AbstractParser
return new BaseData($availabilities);
}
public function parseRooms(Crawler $result): BaseData
{
$availabilities = [];
$result
->filterXPath('//unterbringungen/unterbringung')
->each(function (Crawler $node) use (&$availabilities) {
$room = new Room();
$room->id = (int) $node->attr('id_zimmer');
$room->label = (string) $node->attr('bezeichnung');
$room->category = (string) $node->attr('kategorie');
$room->board = (string) $node->attr('verpflegung');
$room->available = (int) $node->attr('frei');
$room->status = (string) $node->attr('status');
$room->price = $this->stringToFloat($node->attr('preis'));
$availabilities[$room->id] = $room;
})
;
return new BaseData($availabilities);
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\Guide;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DayTimeUtility;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parser for travel XML data from BusProNet API or XML exports.
*
* This parser handles the extraction and parsing of travel information from XML
* including dates, services, rooms, pickups, and pricing. It can be used by both
* file-based loaders and API response parsers.
*/
class TravelParser extends AbstractParser
{
/**
* Parse XML node into a Travel object.
*
* Extracts all travel-related data from the XML node including dates,
* pricing, services, rooms, pickups, and guide information.
*
* @param Crawler $node The XML node containing travel data
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @return Travel The parsed travel object
*/
public function parse(Crawler $node, ?int $hotelId = null): Travel
{
$hotelNode = $this->getHotelNode($node, $hotelId);
$dateFrom = $this->stringToDate($node->attr('termin'));
$travel = new Travel();
$travel->id = (int) $node->attr('idbuspro');
$travel->hotelId = (int) $hotelNode->attr('idbuspro');
$travel->label = $this->getStringOrNullValue($node->filterXPath('//text'));
$travel->dateFrom = $dateFrom;
$travel->dateTo = $this->stringToDate($node->attr('bis'));
$travel->code = $node->attr('code');
$travel->type = $node->attr('reiseart');
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
$travel->additionalServices = $this
->getAdditionalServices($node->filterXPath('//lei_sonstiges/leistung'));
$travel->transportationServices = $this
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
$travel->rooms = $this->getRooms($hotelNode);
$travel->pickupsTo = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom);
$travel->pickupsFro = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'));
$travel->guide = $this->getGuide($node);
return $travel;
}
/**
* Parse selection groups from XML node.
*
* Extracts CRM selection groups and their associated selections from
* the XML structure. Each group contains multiple selection options.
*
* @param Crawler $node The XML node containing selection group data
* @return array<int, CrmSelectionGroup> Array of selection groups indexed by ID
*/
public function getSelectionGroups(Crawler $node): array
{
$selectionGroups = [];
$node->each(function (Crawler $groupNode) use (&$selectionGroups) {
$groupId = (int) $groupNode->attr('idbuspro');
$selectionGroup = new CrmSelectionGroup();
$selectionGroup->id = $groupId;
$selectionGroup->label = $groupNode->attr('bezeichnung');
$groupNode
->filterXPath('//selektion')
->each(function (Crawler $selectionNode) use (&$selectionGroups, &$selectionGroup, $groupId) {
$selectionId = (int) $selectionNode->attr('idbuspro');
$selection = new CrmSelection();
$selection->id = $selectionId;
$selection->label = $selectionNode->attr('bezeichnung');
$selectionGroups[$groupId]['selections'][$selectionId] = $selectionNode->attr('bezeichnung');
$selectionGroup->selections[] = $selection;
})
;
$selectionGroups[$groupId] = $selectionGroup;
});
return $selectionGroups;
}
/**
* Parse additional services from XML node.
*
* Extracts additional services like insurance, activities, or extras
* from the XML structure with pricing and availability information.
*
* @param Crawler $node The XML node containing additional service data
* @return array<int, Service> Array of additional services indexed by ID
*/
public function getAdditionalServices(Crawler $node): array
{
$additionalServices = [];
$node->each(function (Crawler $serviceNode) use (&$additionalServices) {
$serviceId = (int) $serviceNode->attr('idbuspro');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_ADDITIONAL;
$service->id = $serviceId;
$service->subType = $serviceNode->attr('unterart');
$service->mandatory = $this->stringToBool($serviceNode->attr('pflicht'));
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
$service->price = $this
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('//preis')));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
$additionalServices[$serviceId] = $service;
});
return $additionalServices;
}
/**
* Parse transportation services from XML node.
*
* Extracts transportation services like bus, train, or flight options
* with scheduling, pricing, and direction information.
*
* @param Crawler $node The XML node containing transportation service data
* @return array<int, Service> Array of transportation services indexed by ID
*/
public function getTransportationServices(Crawler $node): array
{
$transportationServices = [];
$node->each(function (Crawler $serviceNode) use (&$transportationServices) {
$serviceId = (int) $serviceNode->attr('idbuspro');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_TRANSPORTATION;
$service->id = $serviceId;
$service->subType = $serviceNode->attr('unterart');
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
$service->direction = $this->getStringOrNullValue($serviceNode->filterXPath('//richtung'));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
if (null !== $timeFrom = $serviceNode->attr('uhrzeit_von')) {
$service->timeFrom = $timeFrom;
$service->dayTime = (new DayTimeUtility())->mapTime($timeFrom);
}
$transportationServices[$serviceId] = $service;
});
return $transportationServices;
}
/**
* Extract guide information from travel XML node.
*
* Searches for guide information in bus transportation services
* and extracts name and phone contact details.
*
* @param Crawler $travelNode The XML node containing travel data
* @return Guide|null The guide object or null if no guide found
*/
public function getGuide(Crawler $travelNode): ?Guide
{
$guideNodes = $travelNode
->filterXPath('//lei_befoerderung/leistung[@unterart="BUS"]/zustiegsplanung/reiseleiter');
if (0 < $guideNodes->count()) {
$guideNode = $guideNodes->first();
$guide = new Guide();
$guide->name = $this->getStringOrNullValue($guideNode->filterXPath('//name'));
$guide->phone = $this->getStringOrNullValue($guideNode->filterXPath('//telefon'));
return $guide;
}
return null;
}
/**
* Parse pickup locations from XML node.
*
* Extracts pickup/drop-off locations with pricing and timing information.
* Date and time parsing is only performed for outbound journeys when
* a default date is provided.
*
* @param Crawler $node The XML node containing pickup data
* @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only)
* @return array<int, Pickup> Array of pickup locations indexed by ID
*/
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
{
$pickups = [];
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) {
$pickupId = (int) $pickupNode->attr('idbuspro');
$pickup = new Pickup();
$pickup->id = $pickupId;
$pickup->price = $pickupNode->attr('preis') ?
$this->stringToFloat($pickupNode->attr('preis')) : null;
// parse date and time only for direction 'to' indicated by provided default date
if (null !== $defaultDate) {
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate);
}
$pickups[$pickupId] = $pickup;
});
return $pickups;
}
/**
* Get the hotel node from the travel XML structure.
*
* Retrieves the hotel node either by specific hotel ID or returns
* the first hotel node if no ID is specified.
*
* @param Crawler $node The XML node containing hotel data
* @param int|null $hotelId Optional hotel ID to filter by
* @return Crawler The hotel XML node
*/
public function getHotelNode(Crawler $node, ?int $hotelId): Crawler
{
// In case not hotel id is provided, take the first hotel node (which is most probably the only one)
if (null === $hotelId) {
return $node->filterXPath('//hotel')->first();
}
$hotelNode = $node->filterXPath(sprintf('//hotel[@idbuspro="%d"]', $hotelId));
return $hotelNode->first();
}
/**
* Parse room information from hotel XML node.
*
* Extracts room details including pricing, capacity, availability,
* and board options from the hotel XML structure.
*
* @param Crawler $node The XML node containing room data
* @return array<int, Room> Array of rooms indexed by room ID
*/
public function getRooms(Crawler $node): array
{
$roomNodes = $node->filterXPath('//zimmer/preis');
if (0 === $roomNodes->count()) {
return [];
}
$rooms = [];
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
$roomId = (int) $roomNode->attr('idbuspro_zimmer');
$room = new Room();
$room->id = $roomId;
$room->code = $roomNode->attr('zimmercode');
$room->category = $roomNode->attr('kat');
$room->boardId = (int) $roomNode->attr('idbuspro_vp');
$room->label = $roomNode->attr('zimmertext');
$room->minPax = (int) $roomNode->attr('minpax');
$room->maxPax = (int) $roomNode->attr('maxpax');
$room->nights = (int) $roomNode->attr('naechte');
$room->price = $roomNode->attr('preis') ?
$this->stringToFloat($roomNode->attr('preis')) : null;
$room->status = $roomNode->attr('status');
$room->available = (int) $roomNode->attr('verfuegbar');
$rooms[$roomId] = $room;
});
return $rooms;
}
}