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;
}
}
+24 -3
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -29,6 +31,7 @@ class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
private readonly ProductExportBuilder $productExportBuilder,
) {
}
@@ -41,13 +44,31 @@ class ExportController extends AbstractController
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
ExportQuery $query = new ExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
return $this->paginate($this->travelExportBuilder->build(), $query);
}
/**
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
*
* The website's product import. Same grain, envelope and failure behaviour as travels();
* the rows carry rooms, prices, services and selection groups instead of the
* disposition subset, and nothing is dropped for the consumer.
*/
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
public function products(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ExportQuery $query = new ExportQuery(),
): JsonResponse {
return $this->paginate($this->productExportBuilder->build(), $query);
}
private function paginate(Export $export, ExportQuery $query): JsonResponse
{
$total = count($export->rows);
return $this->json([
@@ -5,15 +5,16 @@ declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
* A whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
* @see \App\Service\ProductExportBuilder
*/
final readonly class TravelExport
final readonly class Export
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
@@ -6,7 +6,7 @@ namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
final readonly class ExportQuery
{
public function __construct(
#[Assert\Positive]
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\ProductExportParser;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\Export;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Flattens the BusPro XML export into one row per travel date x hotel, the whole record.
*
* The sibling of TravelExportBuilder at the same grain, for the website's product import
* rather than the disposition. It filters nothing: return legs, `EIN` services,
* `Buchungsstop` rooms and pickups without an id all reach the consumer, whose display
* rules decide what to drop. Folding those rules in here would make every change to the
* website a change to this endpoint.
*
* Hotels are identified by the code and id on the travel XML's own `<hotel>` node, so
* unlike the travel export no row depends on hotel.xml master data.
*
* Boarding-point prices are the export's, verbatim. APP_BPN_LEVEL_BOARDING_POINT_PRICES
* is a booking-path compensation and is deliberately not applied: TravelParser::getPickups()
* does not level, only TravelParser::parse() does.
*/
class ProductExportBuilder
{
use TypeConversionTrait;
private const CACHE_KEY = 'bpn_products_export';
public function __construct(
private readonly PickupLoader $pickupLoader,
private readonly TravelParser $travelParser,
private readonly ProductExportParser $productExportParser,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): Export
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
return $this->assemble();
});
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Cache key error in ProductExportBuilder', 0, $e);
}
}
private function assemble(): Export
{
// Read before the walk and cached with the rows; see TravelExportBuilder::assemble().
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
$pickupMasterData = $this->pickupLoader->loadAll();
/** @var array<string, array<string, mixed>> $rows keyed by dateId-hotelId */
$rows = [];
$skipped = 0;
/** @var array<int, true> $pickupsWithoutMasterData */
$pickupsWithoutMasterData = [];
$files = $this
->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
foreach ($files as $file) {
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
$crawler->filterXPath('//reisen/reise')->each(
function (Crawler $productNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData) {
$product = [
'code' => $productNode->attr('code'),
'busProId' => null !== $productNode->attr('idbuspro') ? (int) $productNode->attr('idbuspro') : null,
];
$this->productExportParser->children($productNode, 'termin')->each(
function (Crawler $dateNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData, $product) {
$this->collectDate($dateNode, $product, $rows, $skipped, $pickupsWithoutMasterData, $pickupMasterData);
}
);
}
);
}
return new Export(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
$sourceGeneratedAt,
);
}
/**
* @param array{code: ?string, busProId: ?int} $product
* @param array<string, array<string, mixed>> $rows
* @param array<int, true> $pickupsWithoutMasterData
* @param array<int|string, Pickup> $pickupMasterData
*/
private function collectDate(
Crawler $dateNode,
array $product,
array &$rows,
int &$skipped,
array &$pickupsWithoutMasterData,
array $pickupMasterData,
): void {
$hotelNodes = $this->productExportParser->children($dateNode, 'hotel');
$dateFrom = $this->stringToDate($dateNode->attr('termin'));
$dateTo = $this->stringToDate($dateNode->attr('bis'));
if (null === $dateFrom || null === $dateTo) {
$this->logger->warning('Product export: unparseable travel date', [
'travel_id' => $dateNode->attr('idbuspro'),
'termin' => $dateNode->attr('termin'),
'bis' => $dateNode->attr('bis'),
]);
$skipped += $hotelNodes->count();
return;
}
$dateId = (int) $dateNode->attr('idbuspro');
// Everything above the hotel is shared by the date's hotel rows, so it is read once.
$shared = [
'product' => $product,
'date' => [
'code' => $dateNode->attr('code'),
'busProId' => $dateId,
'from' => $dateFrom->format('Y-m-d'),
'to' => $dateTo->format('Y-m-d'),
'type' => $dateNode->attr('reiseart'),
],
];
$dateLevel = [
'selectionGroups' => $this->productExportParser->getSelectionGroups($dateNode),
'transportServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_befoerderung/leistung')),
'dateServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_sonstiges/leistung')),
];
$pickups = $this->buildPickups($dateNode, $dateFrom, $pickupMasterData, $pickupsWithoutMasterData);
$guide = $this->productExportParser->getGuide($dateNode);
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, $shared, $dateLevel, $pickups, $guide, $dateId) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// Keyed by the pair the consumer upserts on, so a date repeated across files
// collapses last-wins instead of reaching it as a duplicate.
$rows[$dateId.'-'.$hotelId] = $shared + [
'hotel' => [
'code' => $hotelNode->attr('code'),
'busProId' => $hotelId,
],
'pseudoPrice' => $this->productExportParser->getPseudoPrice($hotelNode),
] + $dateLevel + [
// The list nested in this hotel, not the date-level one: the consumer
// merges the two per hotel.
'hotelServices' => $this->productExportParser->getServices($this->productExportParser->children($hotelNode, 'lei_sonstiges/leistung')),
'rooms' => $this->productExportParser->getRooms($hotelNode),
'pickups' => $pickups,
'guide' => $guide,
];
});
}
/**
* Outbound boarding points joined against zustiege.xml, in source order.
*
* getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the
* next day. Entries with id 0 are kept: the consumer drops them itself.
*
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
* @return list<array<string, mixed>>
*/
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $dateFrom, true) as $pickup) {
$pickupId = (int) $pickup->id;
$details = $masterData[$pickupId] ?? null;
if (null === $details && 0 !== $pickupId) {
$pickupsWithoutMasterData[$pickupId] = true;
}
$resolved[] = [
'busProId' => $pickupId,
'code' => $details?->code,
'city' => $details?->city,
'street' => $details?->street,
'price' => $pickup->price,
'datetime' => $pickup->time?->format('c'),
];
}
return $resolved;
}
}
+4 -4
View File
@@ -12,7 +12,7 @@ use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Model\Export;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
@@ -52,7 +52,7 @@ class TravelExportBuilder
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): TravelExport
public function build(): Export
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
@@ -66,7 +66,7 @@ class TravelExportBuilder
}
}
private function assemble(): TravelExport
private function assemble(): Export
{
// Read before the walk and cached with the rows, so every page served from this
// copy names the same source export. A client that sees it change between pages
@@ -97,7 +97,7 @@ class TravelExportBuilder
);
}
return new TravelExport(
return new Export(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),