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:
1 parent
0a5602fd41
commit
c5685e6d7c
9 files changed
+774
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user