Files
myep/src/BusProNet/XmlParser/TravelParser.php
T

491 lines
20 KiB
PHP

<?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
{
/**
* @var array<string>
*/
private array $ignoredRoomCodes = [
Constants::PSEUDO_PRICE_ROOM_CODE,
];
public function __construct(
private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
) {
}
/**
* 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
{
if (0 === $node->count()) {
throw new \InvalidArgumentException('Cannot parse travel from empty XML node.');
}
$hotelNode = $this->getHotelNode($node, $hotelId);
$dateFrom = $this->stringToDate($this->getRequiredAttrValue($node, 'termin', 'travel termin node'));
$dateTo = $this->stringToDate($this->getRequiredAttrValue($node, 'bis', 'travel termin node'));
$travel = new Travel();
$travel->id = (int) $this->getRequiredAttrValue($node, 'idbuspro', 'travel termin node');
$travel->hotelId = null !== $hotelNode
? (int) $this->getRequiredAttrValue($hotelNode, 'idbuspro', 'travel hotel node')
: null;
$travel->label = $this->getStringOrNullValue($node->filterXPath('.//text'));
$travel->dateFrom = $dateFrom;
$travel->dateTo = $dateTo;
$travel->code = $this->getAttrOrNullValue($node, 'code');
$travel->productId = null !== $this->getAttrOrNullValue($node, 'idprodukt')
? (int) $this->getAttrOrNullValue($node, 'idprodukt')
: null;
// Parse product code from parent reise node using DOM
$domNode = $node->getNode(0);
if (null !== $domNode && $domNode->parentNode instanceof \DOMElement) {
$travel->productCode = $domNode->parentNode->getAttribute('code');
}
$travel->type = $this->getAttrOrNullValue($node, 'reiseart');
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('.//abpreis')));
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('.//selektiongruppe'));
// Parse only termin-level additional services; exclude hotel-nested duplicates that can override flags.
$travel->additionalServices = $this
->getAdditionalServices($node->filterXPath('.//lei_sonstiges[not(ancestor::hotel)]/leistung'));
$travel->transportationServices = $this
->getTransportationServices($node->filterXPath('.//lei_befoerderung/leistung'));
$travel->rooms = null !== $hotelNode ? $this->getRooms($hotelNode) : [];
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
$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) $this->getRequiredAttrValue($groupNode, 'idbuspro', 'selection group node');
$selectionGroup = new CrmSelectionGroup();
$selectionGroup->id = $groupId;
$selectionGroup->label = $this->getAttrOrNullValue($groupNode, 'bezeichnung');
$groupNode
->filterXPath('.//selektion')
->each(function (Crawler $selectionNode) use (&$selectionGroups, &$selectionGroup, $groupId) {
$selectionId = (int) $this->getRequiredAttrValue($selectionNode, 'idbuspro', 'selection node');
$selection = new CrmSelection();
$selection->id = $selectionId;
$selection->label = $this->getAttrOrNullValue($selectionNode, 'bezeichnung');
$selectionGroups[$groupId]['selections'][$selectionId] = $this->getAttrOrNullValue($selectionNode, '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) $this->getRequiredAttrValue($serviceNode, 'idbuspro', 'additional service node');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_ADDITIONAL;
$service->id = $serviceId;
$service->subType = $this->getAttrOrNullValue($serviceNode, 'unterart');
$service->mandatory = $this->stringToBool($this->getAttrOrNullValue($serviceNode, 'pflicht'));
$autoBookValue = $this->getAttrOrNullValue($serviceNode, 'automatisch_buchen');
$service->autoBook = $this->stringToBool($autoBookValue)
|| 'true' === strtolower((string) $autoBookValue);
$service->dateFrom = $this->stringToDate($this->getAttrOrNullValue($serviceNode, 'termin'));
$service->dateTo = $this->stringToDate($this->getAttrOrNullValue($serviceNode, 'bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('.//text'));
$service->price = $this
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('.//preis')));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('.//status'));
// Parse optional description from hinweis node
$description = $this->getStringOrNullValue($serviceNode->filterXPath('.//hinweis'));
if (null !== $description && '' !== trim($description)) {
$service->description = $description;
}
// Parse age constraints
$this->parseServiceAgeConstraints($serviceNode, $service);
// Parse insurance calculation flag
$service->includeInInsuranceCalculation = $this->stringToBool($this->getAttrOrNullValue($serviceNode, 'versicherungsberechnung'));
$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) $this->getRequiredAttrValue($serviceNode, 'idbuspro', 'transportation service node');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_TRANSPORTATION;
$service->id = $serviceId;
$service->subType = $this->getAttrOrNullValue($serviceNode, 'unterart');
$service->dateFrom = $this->stringToDate($this->getAttrOrNullValue($serviceNode, 'termin'));
$service->dateTo = $this->stringToDate($this->getAttrOrNullValue($serviceNode, 'bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('.//text'));
$service->direction = $this->getStringOrNullValue($serviceNode->filterXPath('.//richtung'));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('.//status'));
$service->price = $this
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('.//preis')));
// Parse optional description from hinweis node
$description = $this->getStringOrNullValue($serviceNode->filterXPath('.//hinweis'));
if (null !== $description && '' !== trim($description)) {
$service->description = $description;
}
if (null !== $timeFrom = $this->getAttrOrNullValue($serviceNode, 'uhrzeit_von')) {
$service->timeFrom = $timeFrom;
$service->dayTime = (new DayTimeUtility())->mapTime($timeFrom);
}
// Parse insurance calculation flag
$service->includeInInsuranceCalculation = $this->stringToBool($this->getAttrOrNullValue($serviceNode, 'versicherungsberechnung'));
$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)
* @param bool $isOutbound Whether these are outbound pickups (for split pricing)
*
* @return array<int, Pickup> Array of pickup locations indexed by ID
*/
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null, bool $isOutbound = true): array
{
$pickups = [];
$timeOnlyIds = [];
$node->each(function (Crawler $pickupNode) use (&$pickups, &$timeOnlyIds, $defaultDate, $isOutbound) {
$pickupId = (int) $pickupNode->attr('idbuspro');
$pickup = new Pickup();
$pickup->id = $pickupId;
$pickup->price = $pickupNode->attr('preis') ?
$this->stringToFloat($pickupNode->attr('preis')) : null;
// Set direction-specific price for split pricing support
if ($isOutbound) {
$pickup->priceOutbound = $pickup->price;
} else {
$pickup->priceInbound = $pickup->price;
}
// parse date and time only for direction 'to' indicated by provided default date
if (null !== $defaultDate) {
$timeValue = $pickupNode->attr('zeit');
$pickup->time = $this->stringToDateTimeFuzzy($timeValue, $defaultDate);
if (null !== $timeValue && 1 === preg_match('/^\d{2}:\d{2}$/', $timeValue)) {
$timeOnlyIds[] = $pickupId;
}
}
$pickups[$pickupId] = $pickup;
});
$this->adjustOvernightTimes($pickups, $timeOnlyIds);
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|null The hotel XML node or null if none exists and no specific hotel was requested
*/
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) {
$hotelNode = $node->filterXPath('.//hotel');
if (0 === $hotelNode->count()) {
return null;
}
return $hotelNode->first();
}
$hotelNode = $node->filterXPath(sprintf('.//hotel[@idbuspro="%d"]', $hotelId));
if (0 === $hotelNode->count()) {
throw new \InvalidArgumentException(sprintf('No hotel node found for 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) {
$roomCode = $this->getAttrOrNullValue($roomNode, 'zimmercode');
if (true === in_array($roomCode, $this->ignoredRoomCodes, true)) {
return;
}
$roomId = (int) $this->getRequiredAttrValue($roomNode, 'idbuspro_zimmer', 'room price node');
$room = new Room();
$room->id = $roomId;
$room->code = $roomCode;
$room->category = $this->getAttrOrNullValue($roomNode, 'kat');
$room->boardId = (int) $this->getAttrOrNullValue($roomNode, 'idbuspro_vp');
$room->label = $this->getAttrOrNullValue($roomNode, 'zimmertext');
$room->minPax = $this->getIntAttrOrNullValue($roomNode, 'minpax');
$room->maxPax = $this->getIntAttrOrNullValue($roomNode, 'maxpax');
$room->nights = (int) $this->getAttrOrNullValue($roomNode, 'naechte');
$roomPrice = $this->getAttrOrNullValue($roomNode, 'preis');
$room->price = null !== $roomPrice && '' !== $roomPrice
? $this->stringToFloat($roomPrice)
: null;
$room->status = $this->getAttrOrNullValue($roomNode, 'status');
$room->available = (int) $this->getAttrOrNullValue($roomNode, 'verfuegbar');
$rooms[$roomId] = $room;
});
return $rooms;
}
/**
* Parse age constraints from service XML node and apply them to the service.
*
* Handles both absolute age constraints (altervon/alterbis) and extensible
* constraint data (hinweis_stamm) that may contain birth year ranges or
* future constraint types.
*
* @param Crawler $serviceNode The XML node containing service data
* @param Service $service The service object to populate with constraints
*/
private function parseServiceAgeConstraints(Crawler $serviceNode, Service $service): void
{
// Parse absolute age constraints (altervon/alterbis)
$ageFrom = $this->getIntOrNullValue($serviceNode->filterXPath('.//altervon'));
$ageTo = $this->getIntOrNullValue($serviceNode->filterXPath('.//alterbis'));
// Parse extensible constraint data (hinweis_stamm)
$constraintData = $this->getStringOrNullValue($serviceNode->filterXPath('.//hinweis_stamm'));
$constraintResult = null;
if (null !== $constraintData && '' !== trim($constraintData)) {
$constraintResult = $this->ageConstraintRegistry->parseConstraints($constraintData);
}
// Apply absolute age constraints
if (null !== $ageFrom || null !== $ageTo) {
$service->ageFrom = $ageFrom;
$service->ageTo = $ageTo;
if (null !== $constraintResult && false === $constraintResult->isEmpty()) {
// Mixed constraints scenario
$service->ageConstraintType = 'mixed';
$service->birthYearFrom = $constraintResult->birthYearFrom;
$service->birthYearTo = $constraintResult->birthYearTo;
$service->ageConstraintMetadata = array_merge(
$constraintResult->metadata,
['has_absolute_age' => true, 'has_birth_year' => true]
);
} else {
$service->ageConstraintType = 'absolute_age';
}
} elseif (null !== $constraintResult && false === $constraintResult->isEmpty()) {
// Only constraint data (birth year, etc.)
$service->ageConstraintType = $constraintResult->type;
$service->birthYearFrom = $constraintResult->birthYearFrom;
$service->birthYearTo = $constraintResult->birthYearTo;
$service->ageConstraintMetadata = $constraintResult->metadata;
}
// Always store raw data for debugging/future parsing
if (null !== $constraintData) {
$service->rawAgeConstraintData = $constraintData;
}
}
/**
* Adjusts time-only pickup entries for overnight bus routes.
*
* When a bus route crosses midnight, time-only entries (e.g. "01:00") are
* initially stamped with the departure date. This method detects overnight
* scenarios by comparing time-only entries against the latest full-datetime
* entry and adds one day where needed.
*
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int> $timeOnlyIds IDs of pickups parsed from time-only values
*/
private function adjustOvernightTimes(array $pickups, array $timeOnlyIds): void
{
if (true === empty($timeOnlyIds)) {
return;
}
// Find latest datetime among full-datetime entries
$latestFullDateTime = null;
foreach ($pickups as $id => $pickup) {
if (null !== $pickup->time && false === in_array($id, $timeOnlyIds, true)) {
if (null === $latestFullDateTime || $pickup->time > $latestFullDateTime) {
$latestFullDateTime = $pickup->time;
}
}
}
if (null === $latestFullDateTime) {
return;
}
// Adjust time-only entries that fall before the latest full-datetime
foreach ($timeOnlyIds as $id) {
if (isset($pickups[$id]) && null !== $pickups[$id]->time && $pickups[$id]->time < $latestFullDateTime) {
$pickups[$id]->time = $pickups[$id]->time->modify('+1 day');
}
}
}
}