feat: unfiltered product export for the website's catalogue import
This commit is contained in:
1 parent
272c811f4f
commit
1d42565a84
11 files changed
+970
-33
No files matched your search
+147
-4
@@ -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_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
|
||||
*
|
||||
* Deliberately separate from TravelParser, which builds the booking path's models and
|
||||
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
|
||||
* hotel-nested services and levels boarding-point prices. The product export filters and
|
||||
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
|
||||
* and returns plain arrays in source order.
|
||||
*
|
||||
* @see \App\Service\ProductExportBuilder
|
||||
*/
|
||||
class ProductExportParser extends AbstractParser
|
||||
{
|
||||
/**
|
||||
* Every selection group on the date, not only the `Leistungen *` ones.
|
||||
*
|
||||
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
|
||||
*/
|
||||
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<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
|
||||
*/
|
||||
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<array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||
@@ -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]
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\XmlExportInfo;
|
||||
use App\BusProNet\Traits\TypeConversionTrait;
|
||||
use App\BusProNet\XmlCrawlerFactory;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlParser\ProductExportParser;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use App\Model\Export;
|
||||
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, the whole record.
|
||||
*
|
||||
* The sibling of TravelExportBuilder at the same grain, for the website's product import
|
||||
* rather than the disposition. It filters nothing: return legs, `EIN` services,
|
||||
* `Buchungsstop` rooms and pickups without an id all reach the consumer, whose display
|
||||
* rules decide what to drop. Folding those rules in here would make every change to the
|
||||
* website a change to this endpoint.
|
||||
*
|
||||
* Hotels are identified by the code and id on the travel XML's own `<hotel>` 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<string, array<string, mixed>> $rows keyed by dateId-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')->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<string, array<string, mixed>> $rows
|
||||
* @param array<int, true> $pickupsWithoutMasterData
|
||||
* @param array<int|string, Pickup> $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<int|string, Pickup> $masterData
|
||||
* @param array<int, true> $pickupsWithoutMasterData
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise id="1" idbuspro="1127" code="DKIWE">
|
||||
<!-- Two hotels on one date: one row per pair, date-level data shared, hotel-level data not. -->
|
||||
<termin id="1" idbuspro="900" idprodukt="1127" termin="04.12.2026" bis="06.12.2026" code="DKIWE041226" reiseart="F">
|
||||
<selektiongruppe id="1" idbuspro="1" bezeichnung="Reisezeitraum">
|
||||
<selektion id="1" idbuspro="863" bezeichnung="Wintersportreise" />
|
||||
</selektiongruppe>
|
||||
<selektiongruppe id="2" idbuspro="7" bezeichnung="Leistungen Skipass">
|
||||
<selektion id="1" idbuspro="92" bezeichnung="3 Tage Skipass" />
|
||||
<selektion id="2" idbuspro="93" bezeichnung="Skipass Region" />
|
||||
</selektiongruppe>
|
||||
<selektiongruppe id="3" idbuspro="99" bezeichnung="Leistungen Personal" />
|
||||
<text>Davos Klosters</text>
|
||||
<lei_befoerderung>
|
||||
<leistung id="1" idbuspro="193804" unterart="PKW">
|
||||
<text>Eigene Anreise mit Rabatt</text>
|
||||
<preis>-50,00</preis>
|
||||
<status>Frei</status>
|
||||
<richtung>HIN</richtung>
|
||||
</leistung>
|
||||
<!-- A return-leg bus with a guide: the guide must not be taken from it. -->
|
||||
<leistung id="2" idbuspro="193811" unterart="BUS">
|
||||
<text>Bus Rückfahrt</text>
|
||||
<preis>0,00</preis>
|
||||
<status>Buchungsstop</status>
|
||||
<richtung>RUECK</richtung>
|
||||
<zustiegsplanung>
|
||||
<reiseleiter><name>Rück Leiter</name><telefon>000</telefon></reiseleiter>
|
||||
</zustiegsplanung>
|
||||
</leistung>
|
||||
<leistung id="3" idbuspro="193802" unterart="BUS">
|
||||
<text>Bus Hinfahrt</text>
|
||||
<preis>0,00</preis>
|
||||
<status>Frei</status>
|
||||
<richtung>HIN</richtung>
|
||||
<zustiegsplanung>
|
||||
<reiseleiter><name>Hin Leiterin</name><telefon>0171 123</telefon></reiseleiter>
|
||||
</zustiegsplanung>
|
||||
</leistung>
|
||||
<leistung id="4" idbuspro="193820" unterart="BAH">
|
||||
<text>Zug</text>
|
||||
<preis>1.234,50</preis>
|
||||
<status>Anfrage</status>
|
||||
<richtung>HIN</richtung>
|
||||
</leistung>
|
||||
</lei_befoerderung>
|
||||
<lei_sonstiges>
|
||||
<leistung id="1" idbuspro="193805" unterart="SON">
|
||||
<text>Keycard-Pfand (Pflichtabgabe)</text>
|
||||
<preis>5,40</preis>
|
||||
<status>Frei</status>
|
||||
</leistung>
|
||||
<leistung id="2" idbuspro="193830" unterart="EIN">
|
||||
<text>Einzelzimmerzuschlag</text>
|
||||
<status>Frei</status>
|
||||
</leistung>
|
||||
</lei_sonstiges>
|
||||
<hotel id="1" code="DKIWEKT" idbuspro="222931">
|
||||
<!-- Same per-list id as the date-level Keycard entry, different service. -->
|
||||
<lei_sonstiges>
|
||||
<leistung id="1" idbuspro="193840" unterart="SPA">
|
||||
<text>Skipass Hotel</text>
|
||||
<preis>60,00</preis>
|
||||
<status>Frei</status>
|
||||
</leistung>
|
||||
</lei_sonstiges>
|
||||
<zimmer>
|
||||
<preis zimmercode="MBZET " idbuspro_zimmer="74" zimmertext="Bett im Mehrbettzimmer" MinPax="1" MaxPax="1" naechte="2" kat="GR" vpcode="Lt. Auss." idbuspro_vp="2" preis="229,50" status="Frei" verfuegbar="24" />
|
||||
<preis zimmercode="PDGS" idbuspro_zimmer="999" zimmertext="Pseudo" MinPax="1" MaxPax="1" naechte="2" preis="299,00" status="Frei" verfuegbar="0" />
|
||||
<preis zimmercode="3erET" idbuspro_zimmer="98" zimmertext="3er Zimmer" MinPax="3" MaxPax="3" naechte="2" kat="GR" vpcode="HP" idbuspro_vp="3" preis="259,00" status="Buchungsstop" verfuegbar="0" />
|
||||
</zimmer>
|
||||
</hotel>
|
||||
<hotel id="2" code="DKIWEK2" idbuspro="222932">
|
||||
<zimmer>
|
||||
<preis zimmercode="DZ" idbuspro_zimmer="11" zimmertext="Doppelzimmer" MinPax="2" MaxPax="2" naechte="2" kat="DZ" vpcode="HP" idbuspro_vp="3" preis="" status="Anfrage" verfuegbar="" />
|
||||
</zimmer>
|
||||
</hotel>
|
||||
<zustiege>
|
||||
<zustieg id="1" idbuspro="4" zeit="03.12.2026 23:30:00" preis="5,90" />
|
||||
<!-- Bare time after the 23:30 above: rolls onto the next calendar day. -->
|
||||
<zustieg id="2" idbuspro="27" zeit="01:45" />
|
||||
<!-- Referenced but absent from zustiege.xml. -->
|
||||
<zustieg id="3" idbuspro="55" zeit="03.12.2026 21:00:00" preis="0,00" />
|
||||
<!-- No id: the consumer drops it, not the export. -->
|
||||
<zustieg id="4" idbuspro="0" zeit="03.12.2026 20:00:00" />
|
||||
</zustiege>
|
||||
<zustiege_rueck>
|
||||
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
|
||||
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
|
||||
</zustiege_rueck>
|
||||
</termin>
|
||||
<!-- Unparseable date: its hotel row is dropped and counted. -->
|
||||
<termin id="2" idbuspro="901" termin="kaputt" bis="06.12.2026" code="DKIWE000000">
|
||||
<hotel id="1" code="DKIWEKT" idbuspro="222931" />
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
|
||||
use App\BusProNet\XmlParser\ProductExportParser;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use App\Model\Export;
|
||||
use App\Service\ProductExportBuilder;
|
||||
use League\Flysystem\DirectoryListing;
|
||||
use League\Flysystem\FileAttributes;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
|
||||
class ProductExportBuilderTest extends TestCase
|
||||
{
|
||||
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
|
||||
{
|
||||
$export = $this->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('<name>Hin Leiterin</name>', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'));
|
||||
$xml = preg_replace('#<leistung id="3" idbuspro="193802".*?</leistung>#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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> $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'),
|
||||
|
||||
Reference in new issue
Block a user