From 1d42565a8461546ebbc8c5401395aed5f37d7acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 30 Sep 2026 12:16:27 +0200 Subject: [PATCH] feat: unfiltered product export for the website's catalogue import --- docs/api-consumer-guide.md | 151 ++++++++++- .../XmlParser/ProductExportParser.php | 161 ++++++++++++ src/Controller/Api/ExportController.php | 27 +- src/Model/{TravelExport.php => Export.php} | 5 +- ...{TravelExportQuery.php => ExportQuery.php} | 2 +- src/Service/ProductExportBuilder.php | 225 ++++++++++++++++ src/Service/TravelExportBuilder.php | 8 +- tests/Controller/Api/ExportControllerTest.php | 79 ++++-- tests/Resources/product_export_data.xml | 98 +++++++ tests/Service/ProductExportBuilderTest.php | 243 ++++++++++++++++++ tests/Service/TravelExportBuilderTest.php | 4 +- 11 files changed, 970 insertions(+), 33 deletions(-) create mode 100644 src/BusProNet/XmlParser/ProductExportParser.php rename src/Model/{TravelExport.php => Export.php} (87%) rename src/Model/{TravelExportQuery.php => ExportQuery.php} (88%) create mode 100644 src/Service/ProductExportBuilder.php create mode 100644 tests/Resources/product_export_data.xml create mode 100644 tests/Service/ProductExportBuilderTest.php diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 0297e5f5..f8bbf549 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -12,8 +12,8 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - Access token TTL is **10 minutes** for every grant. Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned. - A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on - 401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today. + A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on + 401 and resume at the page you were on. At the default `perPage=500` either export is a single page today. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. - Each granted scope maps to a role `ROLE_OAUTH2_` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Send the token as `Authorization: Bearer `. @@ -52,7 +52,7 @@ There is no single error envelope; three shapes occur: Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. -`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone. +`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone. The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. @@ -148,7 +148,7 @@ This is a full overwrite, not a merge. Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call. -This is the only paginated endpoint and the only one with a `meta` envelope. +The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope. It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an arbitrary string, so an export hung off that collection would share a namespace with every date code. @@ -246,6 +246,149 @@ same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on page 1, you have stitched two exports together. Discard what you have and restart from page 1. +#### `GET /api/exports/products` — scope `api` + +Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product +catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists, +boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and +page-consistency rule as `GET /api/exports/travels` above; only the rows differ. + +**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services, +`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of +them a website shows, and how, is the client's rule, not this endpoint's. + +At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500` +returns it in one page. + +```json +{ + "data": [ + { + "product": { "code": "DKIWE", "busProId": 1127 }, + "date": { + "code": "DKIWE041226", + "busProId": 12437, + "from": "2026-12-04", + "to": "2026-12-06", + "type": "F" + }, + "hotel": { "code": "DKIWEKT", "busProId": 222931 }, + "pseudoPrice": null, + "selectionGroups": [ + { + "busProId": 7, + "label": "Leistungen Skipass", + "selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ] + } + ], + "transportServices": [ + { "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" } + ], + "dateServices": [ + { "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null } + ], + "hotelServices": [ + { "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null } + ], + "rooms": [ + { + "busProId": 74, + "code": "MBZET", + "label": "Bett im Mehrbettzimmer Etagendusche/-WC", + "category": "GR", + "minPax": 1, + "maxPax": 1, + "nights": 2, + "price": 229, + "available": 24, + "status": "Frei", + "boardCode": "Lt. Auss.", + "boardBusProId": 2 + } + ], + "pickups": [ + { + "busProId": 4, + "code": "FRA-Route", + "city": "Frankfurt", + "street": "Flughafen (P36 zwischen Terminal 1 und 2)", + "price": 5.9, + "datetime": "2026-12-03T23:30:00+01:00" + } + ], + "guide": null + } + ], + "meta": { + "page": 1, + "perPage": 500, + "total": 419, + "totalPages": 1, + "skipped": 0, + "pickupsWithoutMasterData": 0, + "sourceGeneratedAt": "2026-09-30T11:11:01+02:00", + "generatedAt": "2026-09-30T14:05:00+02:00" + } +} +``` + +**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. +`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on +hotel master data, so `skipped` counts only travel dates whose dates could not be parsed. + +**Types.** + +- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does + not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike. +- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are + always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable. +- All arrays are in **source order**. Sort for display yourself; do not depend on the order. + +**Rooms.** One entry per room offered on the date at that hotel. + +- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`. +- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`. +- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them + empty. + +**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code +`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows. + +**Services.** Three lists, deliberately kept apart: + +- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions. +- `dateServices` — the date's general services. +- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services + here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by + `busProId`: it identifies a service across both lists. + +`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through. +`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount, +such as for arriving by car. + +**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by +`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable: + +| `busProId` | Label | +|---|---| +| 7 | Leistungen Skipass | +| 8 | Leistungen Sonstige | +| 71 | Leistungen Verpflegung | +| 85 | Leistungen Übernachtung | +| 86 | Leistungen Programm | +| 87 | Leistungen Kurse | +| 89 | Leistungen Events | +| 90 | Leistungen Transport | + +**Pickups.** Outbound boarding points only, in source order. `datetime` follows the same resolution rules as in +`GET /api/exports/travels`, including rolling a bare time past midnight onto the next day. There is no separate +`time` field; format `datetime` for display. `price` is the outbound boarding surcharge exactly as upstream states +it: `null` where upstream states none. It is not levelled against the return leg. An id absent from +`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id +of `0` is emitted as-is but not counted. + +**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`. + --- ### Travels diff --git a/src/BusProNet/XmlParser/ProductExportParser.php b/src/BusProNet/XmlParser/ProductExportParser.php new file mode 100644 index 00000000..f17b1e99 --- /dev/null +++ b/src/BusProNet/XmlParser/ProductExportParser.php @@ -0,0 +1,161 @@ +}> + */ + public function getSelectionGroups(Crawler $dateNode): array + { + return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [ + 'busProId' => (int) $group->attr('idbuspro'), + 'label' => $group->attr('bezeichnung'), + 'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [ + 'busProId' => (int) $selection->attr('idbuspro'), + 'label' => $selection->attr('bezeichnung'), + ]), + ]); + } + + /** + * Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`. + * + * The per-list `id` attribute is not read: it restarts at 1 in every list, so it + * identifies nothing once a date-level and a hotel-level list are merged. + * + * @return list + */ + public function getServices(Crawler $serviceNodes): array + { + return $serviceNodes->each(fn (Crawler $service): array => [ + 'busProId' => (int) $service->attr('idbuspro'), + 'subType' => $service->attr('unterart'), + 'label' => $this->getStringOrNullValue($this->children($service, 'text')), + 'price' => $this->getFloatOrNullValue($this->children($service, 'preis')), + 'status' => $this->getStringOrNullValue($this->children($service, 'status')), + 'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')), + ]); + } + + /** + * The hotel's rooms, without the PDGS pseudo room. + * + * `available` is a count of rooms, not persons. Occupancy is read case-insensitively: + * the travel export writes `MinPax`/`MaxPax`. + * + * @return list> + */ + public function getRooms(Crawler $hotelNode): array + { + $rooms = []; + + $this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) { + $code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode')); + + if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) { + return; + } + + $rooms[] = [ + 'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'), + 'code' => $code, + 'label' => $this->getAttrOrNullValue($room, 'zimmertext'), + 'category' => $this->getAttrOrNullValue($room, 'kat'), + 'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'), + 'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'), + 'nights' => $this->getIntAttrOrNullValue($room, 'naechte'), + 'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')), + 'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'), + 'status' => $this->getAttrOrNullValue($room, 'status'), + 'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'), + 'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'), + ]; + }); + + return $rooms; + } + + /** + * The struck-through comparison price, carried by the PDGS pseudo room. + */ + public function getPseudoPrice(Crawler $hotelNode): ?float + { + foreach ($this->children($hotelNode, 'zimmer/preis') as $room) { + $room = new Crawler($room); + + if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) { + return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')); + } + } + + return null; + } + + /** + * The guide of the first outbound bus that has one. + * + * Not TravelParser::getGuide(), which takes the first guide of any bus, return leg + * included. + * + * @return array{name: ?string, phone: ?string}|null + */ + public function getGuide(Crawler $dateNode): ?array + { + $guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter'); + + if (0 === $guides->count()) { + return null; + } + + $guide = $guides->first(); + + return [ + 'name' => $this->getStringOrNullValue($this->children($guide, 'name')), + 'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')), + ]; + } + + /** + * A path below the node's own children, never deeper. + * + * DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds + * nothing and `.//x` searches every descendant -- which would pull a hotel's + * `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the + * node itself, whatever its name. + */ + public function children(Crawler $node, string $path): Crawler + { + return $node->filterXPath('*/'.$path); + } + + private function trimOrNull(?string $value): ?string + { + if (null === $value) { + return null; + } + + $trimmed = trim($value); + + return '' === $trimmed ? null : $trimmed; + } +} diff --git a/src/Controller/Api/ExportController.php b/src/Controller/Api/ExportController.php index f3f8c0c8..3b693506 100644 --- a/src/Controller/Api/ExportController.php +++ b/src/Controller/Api/ExportController.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Controller\Api; -use App\Model\TravelExportQuery; +use App\Model\Export; +use App\Model\ExportQuery; +use App\Service\ProductExportBuilder; use App\Service\TravelExportBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,6 +31,7 @@ class ExportController extends AbstractController { public function __construct( private readonly TravelExportBuilder $travelExportBuilder, + private readonly ProductExportBuilder $productExportBuilder, ) { } @@ -41,13 +44,31 @@ class ExportController extends AbstractController #[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])] public function travels( #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] - TravelExportQuery $query = new TravelExportQuery(), + ExportQuery $query = new ExportQuery(), ): JsonResponse { // Deliberately not wrapped in a catch that answers 200 with an error body, the way // TravelController::hotelAvailability() does: the consumer's retry logic keys on // status codes, so a failure here has to surface as a 5xx. - $export = $this->travelExportBuilder->build(); + return $this->paginate($this->travelExportBuilder->build(), $query); + } + /** + * Every travel date x hotel pair with its whole record, unfiltered, paginated. + * + * The website's product import. Same grain, envelope and failure behaviour as travels(); + * the rows carry rooms, prices, services and selection groups instead of the + * disposition subset, and nothing is dropped for the consumer. + */ + #[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])] + public function products( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ExportQuery $query = new ExportQuery(), + ): JsonResponse { + return $this->paginate($this->productExportBuilder->build(), $query); + } + + private function paginate(Export $export, ExportQuery $query): JsonResponse + { $total = count($export->rows); return $this->json([ diff --git a/src/Model/TravelExport.php b/src/Model/Export.php similarity index 87% rename from src/Model/TravelExport.php rename to src/Model/Export.php index e423ef08..04295a26 100644 --- a/src/Model/TravelExport.php +++ b/src/Model/Export.php @@ -5,15 +5,16 @@ declare(strict_types=1); namespace App\Model; /** - * The whole travel-date x hotel export plus the counters that describe how it was built. + * A 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 + * @see \App\Service\ProductExportBuilder */ -final readonly class TravelExport +final readonly class Export { /** * @param list> $rows one row per (travel, hotel) pair diff --git a/src/Model/TravelExportQuery.php b/src/Model/ExportQuery.php similarity index 88% rename from src/Model/TravelExportQuery.php rename to src/Model/ExportQuery.php index 65e12bd0..be3b733c 100644 --- a/src/Model/TravelExportQuery.php +++ b/src/Model/ExportQuery.php @@ -6,7 +6,7 @@ namespace App\Model; use Symfony\Component\Validator\Constraints as Assert; -final readonly class TravelExportQuery +final readonly class ExportQuery { public function __construct( #[Assert\Positive] diff --git a/src/Service/ProductExportBuilder.php b/src/Service/ProductExportBuilder.php new file mode 100644 index 00000000..4daf6bf0 --- /dev/null +++ b/src/Service/ProductExportBuilder.php @@ -0,0 +1,225 @@ +` 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. + * + * @param array $masterData + * @param array $pickupsWithoutMasterData + * + * @return list> + */ + private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array + { + $resolved = []; + + foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $dateFrom, 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('c'), + ]; + } + + return $resolved; + } +} diff --git a/src/Service/TravelExportBuilder.php b/src/Service/TravelExportBuilder.php index ad7d88b5..e81fd996 100644 --- a/src/Service/TravelExportBuilder.php +++ b/src/Service/TravelExportBuilder.php @@ -12,7 +12,7 @@ use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlParser\TravelParser; -use App\Model\TravelExport; +use App\Model\Export; use League\Flysystem\FilesystemOperator; use League\Flysystem\StorageAttributes; use Psr\Cache\InvalidArgumentException; @@ -52,7 +52,7 @@ class TravelExportBuilder /** * Assembles the whole export, cached as one unit alongside the sibling loaders. */ - public function build(): TravelExport + public function build(): Export { try { return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) { @@ -66,7 +66,7 @@ class TravelExportBuilder } } - private function assemble(): TravelExport + 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 @@ -97,7 +97,7 @@ class TravelExportBuilder ); } - return new TravelExport( + return new Export( array_values($rows), $skipped, count($pickupsWithoutMasterData), diff --git a/tests/Controller/Api/ExportControllerTest.php b/tests/Controller/Api/ExportControllerTest.php index 4f7aa7bf..df01085a 100644 --- a/tests/Controller/Api/ExportControllerTest.php +++ b/tests/Controller/Api/ExportControllerTest.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace App\Tests\Controller\Api; use App\Controller\Api\ExportController; -use App\Model\TravelExport; -use App\Model\TravelExportQuery; +use App\Model\Export; +use App\Model\ExportQuery; +use App\Service\ProductExportBuilder; use App\Service\TravelExportBuilder; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Container; @@ -16,7 +17,7 @@ class ExportControllerTest extends TestCase { public function testPaginatesAndReportsWholeExportFigures(): void { - $meta = $this->call(new TravelExportQuery(1, 5))['meta']; + $meta = $this->call(new ExportQuery(1, 5))['meta']; self::assertSame(1, $meta['page']); self::assertSame(5, $meta['perPage']); @@ -27,8 +28,8 @@ class ExportControllerTest extends TestCase public function testCountersAreIdenticalOnEveryPage(): void { // Whole-export figures repeated per page, so a consumer can abort after page 1. - $first = $this->call(new TravelExportQuery(1, 5))['meta']; - $last = $this->call(new TravelExportQuery(3, 5))['meta']; + $first = $this->call(new ExportQuery(1, 5))['meta']; + $last = $this->call(new ExportQuery(3, 5))['meta']; foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) { self::assertSame($first[$key], $last[$key], $key.' must not vary by page'); @@ -37,8 +38,8 @@ class ExportControllerTest extends TestCase public function testConsecutivePagesDoNotOverlap(): void { - $first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId'); - $second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId'); + $first = array_column($this->call(new ExportQuery(1, 5))['data'], 'busProId'); + $second = array_column($this->call(new ExportQuery(2, 5))['data'], 'busProId'); self::assertSame([1, 2, 3, 4, 5], $first); self::assertSame([6, 7, 8, 9, 10], $second); @@ -47,7 +48,7 @@ class ExportControllerTest extends TestCase public function testTheLastPageIsShort(): void { - $data = $this->call(new TravelExportQuery(3, 5))['data']; + $data = $this->call(new ExportQuery(3, 5))['data']; self::assertCount(2, $data); self::assertSame([11, 12], array_column($data, 'busProId')); @@ -55,7 +56,7 @@ class ExportControllerTest extends TestCase public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void { - $body = $this->call(new TravelExportQuery(99, 5)); + $body = $this->call(new ExportQuery(99, 5)); self::assertSame([], $body['data']); self::assertSame(12, $body['meta']['total']); @@ -84,9 +85,9 @@ class ExportControllerTest extends TestCase public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void { $builder = $this->createStub(TravelExportBuilder::class); - $builder->method('build')->willReturn(new TravelExport([], 0, 0, new \DateTimeImmutable())); + $builder->method('build')->willReturn(new Export([], 0, 0, new \DateTimeImmutable())); - $meta = json_decode((string) $this->controller($builder)->travels(new TravelExportQuery())->getContent(), true)['meta']; + $meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->getContent(), true)['meta']; self::assertArrayHasKey('sourceGeneratedAt', $meta); self::assertNull($meta['sourceGeneratedAt']); @@ -103,13 +104,57 @@ class ExportControllerTest extends TestCase $this->expectException(\RuntimeException::class); - $this->controller($builder)->travels(new TravelExportQuery()); + $this->controller($builder)->travels(new ExportQuery()); + } + + public function testProductsUseTheSameEnvelopeAndPaging(): void + { + $rows = []; + for ($i = 1; $i <= 3; ++$i) { + $rows[] = ['date' => ['busProId' => $i], 'hotel' => ['busProId' => 1]]; + } + + $productBuilder = $this->createStub(ProductExportBuilder::class); + $productBuilder->method('build')->willReturn(new Export( + $rows, + 2, + 5, + new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')), + new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')), + )); + + $response = $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder) + ->products(new ExportQuery(2, 2)); + $body = json_decode((string) $response->getContent(), true); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([3], array_column(array_column($body['data'], 'date'), 'busProId')); + self::assertSame([ + 'page' => 2, + 'perPage' => 2, + 'total' => 3, + 'totalPages' => 2, + 'skipped' => 2, + 'pickupsWithoutMasterData' => 5, + 'sourceGeneratedAt' => '2026-09-28T23:10:15+02:00', + 'generatedAt' => '2026-09-29T03:20:00+02:00', + ], $body['meta']); + } + + public function testAProductBuilderFailureIsNotDowngradedToATwoHundred(): void + { + $productBuilder = $this->createStub(ProductExportBuilder::class); + $productBuilder->method('build')->willThrowException(new \RuntimeException('cache exploded')); + + $this->expectException(\RuntimeException::class); + + $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)->products(new ExportQuery()); } /** * @return array */ - private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array + private function call(?ExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array { $rows = []; for ($i = 1; $i <= 12; ++$i) { @@ -117,7 +162,7 @@ class ExportControllerTest extends TestCase } $builder = $this->createStub(TravelExportBuilder::class); - $builder->method('build')->willReturn(new TravelExport( + $builder->method('build')->willReturn(new Export( $rows, $skipped, $withoutMasterData, @@ -125,16 +170,16 @@ class ExportControllerTest extends TestCase new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')), )); - $response = $this->controller($builder)->travels($query ?? new TravelExportQuery()); + $response = $this->controller($builder)->travels($query ?? new ExportQuery()); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); return json_decode((string) $response->getContent(), true); } - private function controller(TravelExportBuilder $builder): ExportController + private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController { - $controller = new ExportController($builder); + $controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class)); // No 'serializer' service registered, so AbstractController::json() falls back to // JsonResponse -- which is what this endpoint produces in production anyway. diff --git a/tests/Resources/product_export_data.xml b/tests/Resources/product_export_data.xml new file mode 100644 index 00000000..64f1eff4 --- /dev/null +++ b/tests/Resources/product_export_data.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + Davos Klosters + + + Eigene Anreise mit Rabatt + -50,00 + Frei + HIN + + + + Bus Rückfahrt + 0,00 + Buchungsstop + RUECK + + Rück Leiter000 + + + + Bus Hinfahrt + 0,00 + Frei + HIN + + Hin Leiterin0171 123 + + + + Zug + 1.234,50 + Anfrage + HIN + + + + + Keycard-Pfand (Pflichtabgabe) + 5,40 + Frei + + + Einzelzimmerzuschlag + Frei + + + + + + + Skipass Hotel + 60,00 + Frei + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Service/ProductExportBuilderTest.php b/tests/Service/ProductExportBuilderTest.php new file mode 100644 index 00000000..70db4943 --- /dev/null +++ b/tests/Service/ProductExportBuilderTest.php @@ -0,0 +1,243 @@ +build(); + + $keys = array_map( + static fn (array $row): string => $row['date']['busProId'].'-'.$row['hotel']['busProId'], + $export->rows, + ); + + self::assertSame(['900-222931', '900-222932'], $keys); + } + + public function testCarriesIdentityFromTheTravelXmlAlone(): void + { + $row = $this->rowFor('900-222931'); + + self::assertSame(['code' => 'DKIWE', 'busProId' => 1127], $row['product']); + self::assertSame([ + 'code' => 'DKIWE041226', + 'busProId' => 900, + 'from' => '2026-12-04', + 'to' => '2026-12-06', + 'type' => 'F', + ], $row['date']); + self::assertSame(['code' => 'DKIWEKT', 'busProId' => 222931], $row['hotel']); + } + + public function testDropsAndCountsRowsOfAnUnparseableDate(): void + { + self::assertSame(1, $this->build()->skipped); + } + + public function testEmitsEveryRoomButThePseudoRoomVerbatim(): void + { + $rooms = $this->rowFor('900-222931')['rooms']; + + self::assertSame(['MBZET', '3erET'], array_column($rooms, 'code'), 'PDGS is not a room; the code is trimmed'); + self::assertSame([ + 'busProId' => 74, + 'code' => 'MBZET', + 'label' => 'Bett im Mehrbettzimmer', + 'category' => 'GR', + 'minPax' => 1, + 'maxPax' => 1, + 'nights' => 2, + 'price' => 229.5, + 'available' => 24, + 'status' => 'Frei', + 'boardCode' => 'Lt. Auss.', + 'boardBusProId' => 2, + ], $rooms[0]); + + // Buchungsstop rooms reach the consumer: "ausgebucht" is its call. + self::assertSame('Buchungsstop', $rooms[1]['status']); + self::assertSame(3, $rooms[1]['maxPax'], 'MaxPax is read despite its casing'); + } + + public function testAMissingRoomPriceOrAvailabilityIsNullNotZero(): void + { + $room = $this->rowFor('900-222932')['rooms'][0]; + + self::assertNull($room['price']); + self::assertNull($room['available']); + } + + public function testThePseudoRoomBecomesThePseudoPriceOfItsHotelOnly(): void + { + self::assertSame(299.0, $this->rowFor('900-222931')['pseudoPrice']); + self::assertNull($this->rowFor('900-222932')['pseudoPrice']); + } + + public function testKeepsTheThreeServiceListsApartAndUnfiltered(): void + { + $first = $this->rowFor('900-222931'); + $second = $this->rowFor('900-222932'); + + self::assertSame([193804, 193811, 193802, 193820], array_column($first['transportServices'], 'busProId'), 'RUECK and Buchungsstop are kept'); + self::assertSame([193805, 193830], array_column($first['dateServices'], 'busProId'), 'EIN is kept; hotel services do not leak in'); + self::assertSame([193840], array_column($first['hotelServices'], 'busProId')); + self::assertSame([], $second['hotelServices'], 'a hotel without its own list gets an empty one, not the date-level one'); + self::assertSame($first['dateServices'], $second['dateServices']); + } + + public function testServicesAreNumericAndNullWhereAbsent(): void + { + $row = $this->rowFor('900-222931'); + + self::assertSame([ + 'busProId' => 193804, + 'subType' => 'PKW', + 'label' => 'Eigene Anreise mit Rabatt', + 'price' => -50.0, + 'status' => 'Frei', + 'direction' => 'HIN', + ], $row['transportServices'][0]); + self::assertSame(1234.5, $row['transportServices'][3]['price'], 'thousands separator'); + + self::assertNull($row['dateServices'][0]['direction'], 'general services have no direction'); + self::assertNull($row['dateServices'][1]['price'], 'a missing price is null, not 0'); + } + + public function testEmitsEverySelectionGroupInSourceOrder(): void + { + $groups = $this->rowFor('900-222931')['selectionGroups']; + + self::assertSame([1, 7, 99], array_column($groups, 'busProId')); + self::assertSame('Leistungen Skipass', $groups[1]['label']); + self::assertSame([ + ['busProId' => 92, 'label' => '3 Tage Skipass'], + ['busProId' => 93, 'label' => 'Skipass Region'], + ], $groups[1]['selections']); + self::assertSame([], $groups[2]['selections']); + } + + public function testTakesTheGuideFromTheOutboundBus(): void + { + self::assertSame(['name' => 'Hin Leiterin', 'phone' => '0171 123'], $this->rowFor('900-222931')['guide']); + } + + public function testTheGuideIsNullWithoutAnOutboundOne(): void + { + $xml = str_replace('Hin Leiterin', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml')); + $xml = preg_replace('##s', '', $xml); + + self::assertNull($this->build((string) $xml)->rows[0]['guide']); + } + + public function testEmitsPickupsVerbatimWithResolvedDatetimes(): void + { + $pickups = $this->rowFor('900-222931')['pickups']; + + self::assertSame([4, 27, 55, 0], array_column($pickups, 'busProId'), 'source order, id 0 kept'); + self::assertSame([ + 'busProId' => 4, + 'code' => 'Z4', + 'city' => 'Stadt 4', + 'street' => 'Strasse 4', + 'price' => 5.9, + 'datetime' => '2026-12-03T23:30:00+01:00', + ], $pickups[0]); + self::assertNull($pickups[1]['price'], 'an absent price is null'); + self::assertSame('2026-12-04T01:45:00+01:00', $pickups[1]['datetime']); + self::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0'); + self::assertNull($pickups[2]['code']); + } + + public function testDoesNotLevelBoardingPointPrices(): void + { + // Point 27 is priced on the return leg only. The booking path levels that onto the + // outbound entry; the website publishes the outbound price as exported. + self::assertNull($this->rowFor('900-222931')['pickups'][1]['price']); + } + + public function testCountsPickupsWithoutMasterDataButNotTheIdlessOne(): void + { + self::assertSame(1, $this->build()->pickupsWithoutMasterData); + } + + public function testCarriesTheSourceExportTimestamp(): void + { + $export = $this->build(null, "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"); + + self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c')); + } + + /** + * @return array + */ + private function rowFor(string $key): array + { + foreach ($this->build()->rows as $row) { + if ($row['date']['busProId'].'-'.$row['hotel']['busProId'] === $key) { + return $row; + } + } + + self::fail('No row for '.$key); + } + + private function build(?string $xml = null, ?string $marker = null): Export + { + $xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'); + + $filesystem = $this->createStub(FilesystemOperator::class); + $filesystem->method('listContents')->willReturn(new DirectoryListing([new FileAttributes('Ziel_1127.xml')])); + $filesystem->method('read')->willReturnCallback( + static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $xml + ); + $filesystem->method('fileExists')->willReturnCallback( + static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker + ); + + $pickupLoader = $this->createStub(PickupLoader::class); + $pickupLoader->method('loadAll')->willReturn([ + 4 => $this->pickup(4), + 27 => $this->pickup(27), + ]); + + return (new ProductExportBuilder( + $pickupLoader, + // Levelling on, to prove the export does not go through it. + new TravelParser(new AgeConstraintParserRegistry([]), true), + new ProductExportParser(), + $filesystem, + new TagAwareAdapter(new ArrayAdapter()), + new NullLogger(), + ))->build(); + } + + private function pickup(int $id): Pickup + { + $pickup = new Pickup(); + $pickup->id = $id; + $pickup->code = 'Z'.$id; + $pickup->city = 'Stadt '.$id; + $pickup->street = 'Strasse '.$id; + + return $pickup; + } +} diff --git a/tests/Service/TravelExportBuilderTest.php b/tests/Service/TravelExportBuilderTest.php index 384ab862..f5bbab23 100644 --- a/tests/Service/TravelExportBuilderTest.php +++ b/tests/Service/TravelExportBuilderTest.php @@ -10,7 +10,7 @@ use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlParser\AgeConstraintParserRegistry; use App\BusProNet\XmlParser\TravelParser; -use App\Model\TravelExport; +use App\Model\Export; use App\Service\TravelExportBuilder; use League\Flysystem\DirectoryListing; use League\Flysystem\FileAttributes; @@ -182,7 +182,7 @@ class TravelExportBuilderTest extends TestCase /** * @param list $files */ - private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): TravelExport + private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export { $contents = [ 'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),