feat: add paginated travel export endpoint for the teamer app

GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.

Built from the XML file map, not travel_snapshot: the snapshot table sits
at this grain but refreshes on its own schedule, while the loaders share
the bpn.cache pool under the xml-sync tag. That is what makes /api/hotels,
/api/pickups and this export move together for free.

Each Ziel_*.xml is walked once and TravelParser::getPickups() does the
zeit parsing, which buys both zeit shapes and adjustOvernightTimes() --
a time-only 02:15 listed after a 23:59 rolls onto the next calendar day.
Pickups join against PickupLoader::loadAll() rather than
patchPickupsDetails(), which sorts: the contract is source order.

The whole assembled export is cached, not a page, so skipped, total and
pickupsWithoutMasterData are whole-export figures identical on every page
and a consumer can abort after the first one. Errors stay real status
codes; the builder is deliberately not wrapped in a catch that would
answer 200 with an error body the way hotelAvailability() does.

The route is declared before api_travel_single_code and that route grows
a requirement, or /api/travels/export resolves to byCode('export') and
404s as though the endpoint existed with no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5 committed 2026-09-29 12:13:55 +02:00
1 parent 0a5602fd41
commit c5685e6d7c
9 files changed
+774

No files matched your search

+44
View File
@@ -6,12 +6,15 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DateCodeUtility;
use App\Model\TravelExportQuery;
use App\Service\TravelDataProvider;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapDateTime;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -22,6 +25,7 @@ class TravelController extends AbstractController
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelDataProvider $travelDataService,
private readonly TravelExportBuilder $travelExportBuilder,
) {
}
@@ -33,6 +37,45 @@ class TravelController extends AbstractController
return $this->json($mapping);
}
/**
* Bulk export of every travel date x hotel pair with its pickups resolved.
*
* Declared before api_travel_single_code on purpose: that route puts no requirement on
* {dateCode}, so /api/travels/export would otherwise resolve to byCode('export'), fail
* the code lookup and answer 404 -- a failure that reads like an endpoint with no data
* rather than a missing route.
*
* The counters in meta describe the whole export, not the page, so they repeat
* unchanged on every page and a consumer can abort after the first one.
*/
#[Route(path: '/travels/export', name: 'api_travel_export', methods: ['GET'])]
public function export(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// hotelAvailability() below does: the consumer's retry logic keys on status codes,
// so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
$total = count($export->rows);
$totalPages = (int) ceil($total / $query->perPage);
$offset = ($query->page - 1) * $query->perPage;
return $this->json([
'data' => array_slice($export->rows, $offset, $query->perPage),
'meta' => [
'page' => $query->page,
'perPage' => $query->perPage,
'total' => $total,
'totalPages' => $totalPages,
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'generatedAt' => $export->generatedAt->format('c'),
],
]);
}
#[Route(
path: '/travels/{dateId}/{hotelId}',
name: 'api_travel_single_id',
@@ -56,6 +99,7 @@ class TravelController extends AbstractController
#[Route(
path: '/travels/{dateCode}/{hotelCode}',
name: 'api_travel_single_code',
requirements: ['dateCode' => '(?!export$).+'],
defaults: ['hotelCode' => null],
)]
public function byCode(Request $request, string $dateCode, ?string $hotelCode = null): JsonResponse
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
*/
final readonly class TravelExport
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
) {
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
{
public function __construct(
#[Assert\Positive]
public int $page = 1,
#[Assert\Range(min: 1, max: 1000)]
public int $perPage = 500,
) {
}
}
+253
View File
@@ -0,0 +1,253 @@
<?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;
}
}