162 lines
6.1 KiB
PHP
162 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\BusProNet\XmlParser;
|
|
|
|
use App\BusProNet\Constants;
|
|
use Symfony\Component\DomCrawler\Crawler;
|
|
|
|
/**
|
|
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
|
|
*
|
|
* Deliberately separate from TravelParser, which builds the booking path's models and
|
|
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
|
|
* hotel-nested services and levels boarding-point prices. The product export filters and
|
|
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
|
|
* and returns plain arrays in source order.
|
|
*
|
|
* @see \App\Service\ProductExportBuilder
|
|
*/
|
|
class ProductExportParser extends AbstractParser
|
|
{
|
|
/**
|
|
* Every selection group on the date, not only the `Leistungen *` ones.
|
|
*
|
|
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
|
|
*/
|
|
public function getSelectionGroups(Crawler $dateNode): array
|
|
{
|
|
return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [
|
|
'busProId' => (int) $group->attr('idbuspro'),
|
|
'label' => $group->attr('bezeichnung'),
|
|
'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [
|
|
'busProId' => (int) $selection->attr('idbuspro'),
|
|
'label' => $selection->attr('bezeichnung'),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`.
|
|
*
|
|
* The per-list `id` attribute is not read: it restarts at 1 in every list, so it
|
|
* identifies nothing once a date-level and a hotel-level list are merged.
|
|
*
|
|
* @return list<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
|
|
*/
|
|
public function getServices(Crawler $serviceNodes): array
|
|
{
|
|
return $serviceNodes->each(fn (Crawler $service): array => [
|
|
'busProId' => (int) $service->attr('idbuspro'),
|
|
'subType' => $service->attr('unterart'),
|
|
'label' => $this->getStringOrNullValue($this->children($service, 'text')),
|
|
'price' => $this->getFloatOrNullValue($this->children($service, 'preis')),
|
|
'status' => $this->getStringOrNullValue($this->children($service, 'status')),
|
|
'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The hotel's rooms, without the PDGS pseudo room.
|
|
*
|
|
* `available` is a count of rooms, not persons. Occupancy is read case-insensitively:
|
|
* the travel export writes `MinPax`/`MaxPax`.
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function getRooms(Crawler $hotelNode): array
|
|
{
|
|
$rooms = [];
|
|
|
|
$this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) {
|
|
$code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'));
|
|
|
|
if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) {
|
|
return;
|
|
}
|
|
|
|
$rooms[] = [
|
|
'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'),
|
|
'code' => $code,
|
|
'label' => $this->getAttrOrNullValue($room, 'zimmertext'),
|
|
'category' => $this->getAttrOrNullValue($room, 'kat'),
|
|
'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'),
|
|
'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'),
|
|
'nights' => $this->getIntAttrOrNullValue($room, 'naechte'),
|
|
'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')),
|
|
'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'),
|
|
'status' => $this->getAttrOrNullValue($room, 'status'),
|
|
'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'),
|
|
'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'),
|
|
];
|
|
});
|
|
|
|
return $rooms;
|
|
}
|
|
|
|
/**
|
|
* The struck-through comparison price, carried by the PDGS pseudo room.
|
|
*/
|
|
public function getPseudoPrice(Crawler $hotelNode): ?float
|
|
{
|
|
foreach ($this->children($hotelNode, 'zimmer/preis') as $room) {
|
|
$room = new Crawler($room);
|
|
|
|
if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) {
|
|
return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis'));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The guide of the first outbound bus that has one.
|
|
*
|
|
* Not TravelParser::getGuide(), which takes the first guide of any bus, return leg
|
|
* included.
|
|
*
|
|
* @return array{name: ?string, phone: ?string}|null
|
|
*/
|
|
public function getGuide(Crawler $dateNode): ?array
|
|
{
|
|
$guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter');
|
|
|
|
if (0 === $guides->count()) {
|
|
return null;
|
|
}
|
|
|
|
$guide = $guides->first();
|
|
|
|
return [
|
|
'name' => $this->getStringOrNullValue($this->children($guide, 'name')),
|
|
'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A path below the node's own children, never deeper.
|
|
*
|
|
* DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds
|
|
* nothing and `.//x` searches every descendant -- which would pull a hotel's
|
|
* `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the
|
|
* node itself, whatever its name.
|
|
*/
|
|
public function children(Crawler $node, string $path): Crawler
|
|
{
|
|
return $node->filterXPath('*/'.$path);
|
|
}
|
|
|
|
private function trimOrNull(?string $value): ?string
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim($value);
|
|
|
|
return '' === $trimmed ? null : $trimmed;
|
|
}
|
|
}
|