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(): 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 // knows xml-sync swapped the export underneath its walk. $sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer; $hotels = $this->hotelLoader->loadAll(); $pickupMasterData = $this->pickupLoader->loadAll(); /** @var array> $rows keyed by travelId-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/termin')->each( function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) { $this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData); } ); } return new Export( array_values($rows), $skipped, count($pickupsWithoutMasterData), new \DateTimeImmutable(), $sourceGeneratedAt, ); } /** * @param array> $rows * @param array $pickupsWithoutMasterData * @param array $hotels * @param array $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. // // Resolved in UTC: the times are BusPro's clock readings, and in Europe/Berlin a // bare 02:45 on the spring-forward night would move to 03:45. $pickups = $this->buildPickups( $this->travelParser->getPickups( $node->filterXPath('.//zustiege/zustieg'), new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC')), true, ), $pickupMasterData, $pickupsWithoutMasterData, ); // The product a date belongs to, from the enclosing . Not derivable from the // date code: DWWMP runs dates coded DWWMW…, and group travels carry each group's code. $productNode = $node->getNode(0)?->parentNode; $travel = [ 'busProId' => $travelId, 'code' => $node->attr('code'), 'productCode' => $productNode instanceof \DOMElement ? $this->trimOrNull($productNode->getAttribute('code')) : null, '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 $pickups pickups indexed by id, in source order * @param array $masterData * @param array $pickupsWithoutMasterData * * @return list> */ 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. A clock // reading without an offset, like `time`. 'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'), ]; } 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; } }