feat: unfiltered product export for the website's catalogue import

This commit is contained in:
fromme committed 2026-09-30 12:19:10 +02:00
1 parent 272c811f4f
commit 1d42565a84
11 files changed
+970 -33

No files matched your search

@@ -0,0 +1,161 @@
<?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;
}
}