feat: unfiltered product export for the website's catalogue import
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user