254 lines
9.5 KiB
PHP
254 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Model\Hotel;
|
|
use App\BusProNet\Model\Pickup;
|
|
use App\BusProNet\Traits\TypeConversionTrait;
|
|
use App\BusProNet\XmlCrawlerFactory;
|
|
use App\BusProNet\XmlLoader\HotelLoader;
|
|
use App\BusProNet\XmlLoader\PickupLoader;
|
|
use App\BusProNet\XmlParser\TravelParser;
|
|
use App\Model\TravelExport;
|
|
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, pickups resolved.
|
|
*
|
|
* Built from the XML file map rather than the travel_snapshot table. The table sits at
|
|
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
|
|
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
|
|
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
|
|
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
|
|
*
|
|
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
|
|
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
|
|
*/
|
|
class TravelExportBuilder
|
|
{
|
|
use TypeConversionTrait;
|
|
|
|
private const CACHE_KEY = 'bpn_travels_export';
|
|
|
|
public function __construct(
|
|
private readonly HotelLoader $hotelLoader,
|
|
private readonly PickupLoader $pickupLoader,
|
|
private readonly TravelParser $travelParser,
|
|
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(): TravelExport
|
|
{
|
|
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 TravelExportBuilder', 0, $e);
|
|
}
|
|
}
|
|
|
|
private function assemble(): TravelExport
|
|
{
|
|
$hotels = $this->hotelLoader->loadAll();
|
|
$pickupMasterData = $this->pickupLoader->loadAll();
|
|
|
|
/** @var array<string, array<string, mixed>> $rows keyed by travelId-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/termin')->each(
|
|
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
|
|
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
|
|
}
|
|
);
|
|
}
|
|
|
|
return new TravelExport(
|
|
array_values($rows),
|
|
$skipped,
|
|
count($pickupsWithoutMasterData),
|
|
new \DateTimeImmutable(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $rows
|
|
* @param array<int, true> $pickupsWithoutMasterData
|
|
* @param array<int, Hotel> $hotels
|
|
* @param array<int|string, Pickup> $pickupMasterData
|
|
*/
|
|
private function collectTravel(
|
|
Crawler $node,
|
|
array &$rows,
|
|
int &$skipped,
|
|
array &$pickupsWithoutMasterData,
|
|
array $hotels,
|
|
array $pickupMasterData,
|
|
): void {
|
|
$hotelNodes = $node->filterXPath('.//hotel');
|
|
|
|
// Dates first: getPickups() needs a non-null default date for the time-only zeit
|
|
// shape, and parsing them up front is also what turns an unusable date into a
|
|
// dropped row rather than a row with a null date the consumer cannot key on.
|
|
$dateFrom = $this->stringToDate($node->attr('termin'));
|
|
$dateTo = $this->stringToDate($node->attr('bis'));
|
|
|
|
if (null === $dateFrom || null === $dateTo) {
|
|
$this->logger->warning('Travel export: unparseable travel date', [
|
|
'travel_id' => $node->attr('idbuspro'),
|
|
'termin' => $node->attr('termin'),
|
|
'bis' => $node->attr('bis'),
|
|
]);
|
|
$skipped += $hotelNodes->count();
|
|
|
|
return;
|
|
}
|
|
|
|
$travelId = (int) $node->attr('idbuspro');
|
|
|
|
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
|
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
|
|
// calendar day. Resolved once per travel date, then shared by its hotel rows.
|
|
$pickups = $this->buildPickups(
|
|
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
|
|
$pickupMasterData,
|
|
$pickupsWithoutMasterData,
|
|
);
|
|
|
|
$travel = [
|
|
'busProId' => $travelId,
|
|
'code' => $node->attr('code'),
|
|
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
|
|
'dateFrom' => $dateFrom->format('Y-m-d'),
|
|
'dateTo' => $dateTo->format('Y-m-d'),
|
|
];
|
|
|
|
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
|
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
|
|
|
// Defensive: no travel in the current snapshot references a hotel that
|
|
// hotel.xml does not carry. A row without master data would reach the consumer
|
|
// with no name and no country, so it is dropped and counted instead.
|
|
if (false === isset($hotels[$hotelId])) {
|
|
$this->logger->warning('Travel export: hotel missing from master data', [
|
|
'travel_id' => $travelId,
|
|
'hotel_id' => $hotelId,
|
|
]);
|
|
++$skipped;
|
|
|
|
return;
|
|
}
|
|
|
|
$hotel = $hotels[$hotelId];
|
|
|
|
// 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[$travelId.'-'.$hotelId] = $travel + [
|
|
'hotel' => [
|
|
'busProId' => $hotelId,
|
|
'code' => $hotel->code,
|
|
'name' => $hotel->name,
|
|
'costUnit' => $hotel->costUnit,
|
|
'country' => $hotel->country,
|
|
'countryCode' => $hotel->countryCode,
|
|
],
|
|
'pickups' => $pickups,
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Joins the travel XML's pickups against zustiege.xml.
|
|
*
|
|
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
|
|
* contract is source order, which is the order the disposition lists them in. Nor
|
|
* enrichPickupDetails(), which is private and passes over a miss silently.
|
|
*
|
|
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
|
|
* @param array<int|string, Pickup> $masterData
|
|
* @param array<int, true> $pickupsWithoutMasterData
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
|
|
{
|
|
$resolved = [];
|
|
|
|
foreach ($pickups as $pickup) {
|
|
$pickupId = $pickup->id;
|
|
|
|
// Defensive: none exist today. An entry with no id is not addressable by the
|
|
// consumer, so drop the entry -- never the row it belongs to.
|
|
if (null === $pickupId || 0 === $pickupId) {
|
|
continue;
|
|
}
|
|
|
|
$details = $masterData[$pickupId] ?? null;
|
|
|
|
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
|
|
// entry is emitted anyway: dropping it would hide a real departure point from
|
|
// the disposition UI, where a nameless stop is still better than a missing one.
|
|
if (null === $details) {
|
|
$pickupsWithoutMasterData[$pickupId] = true;
|
|
}
|
|
|
|
$resolved[] = [
|
|
'busProId' => $pickupId,
|
|
'code' => $details?->code,
|
|
'city' => $details?->city,
|
|
'street' => $details?->street,
|
|
'time' => $pickup->time?->format('H:i'),
|
|
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
|
// worked out the real day, and a null here would throw that away.
|
|
'datetime' => $pickup->time?->format('c'),
|
|
];
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
private function getStringOrNullValue(Crawler $node): ?string
|
|
{
|
|
return 0 < $node->count() ? $node->text() : null;
|
|
}
|
|
|
|
private function trimOrNull(?string $value): ?string
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim($value);
|
|
|
|
return '' === $trimmed ? null : $trimmed;
|
|
}
|
|
}
|