231 lines
9.4 KiB
PHP
231 lines
9.4 KiB
PHP
<?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.
|
|
*
|
|
* `datetime` is BusPro's clock reading, without an offset. Resolving in Europe/Berlin
|
|
* would move a bare 02:45 on the spring-forward night to 03:45, so the dates are
|
|
* resolved in UTC, where no time is skipped or repeated.
|
|
*
|
|
* @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 = [];
|
|
$clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC'));
|
|
|
|
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, 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('Y-m-d\TH:i:s'),
|
|
];
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
}
|