ageConstraintRegistry = new AgeConstraintParserRegistry(); } /** * 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 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 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')); // 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); $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 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')); // Parse optional description from hinweis node $description = $this->getStringOrNullValue($serviceNode->filterXPath('//hinweis')); if (null !== $description && '' !== trim($description)) { $service->description = $description; } 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 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 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; } /** * 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; } } }