` 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> $rows keyed by dateId-hotelId */ $rows = []; $skipped = 0; /** @var array $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> $rows * @param array $pickupsWithoutMasterData * @param array $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 $masterData * @param array $pickupsWithoutMasterData * * @return list> */ 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; } }