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,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