Compare commits

..
Author SHA1 Message Date
frommeandClaude Opus 5 3221008f90 refactor: move the bulk export to its own /api/exports resource
/api/travels/{dateCode} accepts an arbitrary string, so /api/travels/export
shared a namespace with every date code and stayed reachable only because
byCode() carried a negative lookahead ruling that one string out. Nothing
in the URL announced the constraint, and getting it wrong mis-routed
silently: the first attempt widened the segment to `.+` and swallowed
/api/travels/{dateId}/{hotelId}/{dateTo}/availability whole.

/api/exports never takes a parameterised segment, the same shape as
/api/contingents, so the collision stops existing instead of being
guarded. TravelController returns byte-for-byte to its state on master,
lookahead included, and the export moves to its own resource controller.

The routing test now also asserts that nothing is reintroduced under the
date code namespace.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:23:36 +02:00
frommeandClaude Opus 5 b27639c7fa fix: keep the travel date code to a single path segment
The dateCode requirement added with the export route was `.+`, which
replaces Symfony's default `[^/]+` and lets the segment span slashes.
`/api/travels/{dateId}/{hotelId}/{dateTo}/availability` was then matched
by byCode() -- declared above it -- with dateCode swallowing all four
segments, so availability answered 404 "Not found" instead of reaching
the upstream call.

Adds a routing test that reflects the controller's own attributes, so
both the declaration order and the segment class stay guarded. Neither
fails loudly on its own: a path falling through to the wrong route reads
like an endpoint that exists and holds no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:19:24 +02:00
frommeandClaude Opus 5 ca1ab17595 docs: document the travel export endpoint and the new hotel fields
Also corrects the token TTL: the guide claimed one hour for
client_credentials, which stopped being true when the tagged grant
service was dropped. Every grant now issues a PT10M token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:14:59 +02:00
frommeandClaude Opus 5 c5685e6d7c feat: add paginated travel export endpoint for the teamer app
GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:13:55 +02:00
frommeandClaude Opus 5 0a5602fd41 fix: skip travels whose hotel is missing from the master data
generateFilesMap() read ->name straight off $hotels[$hotelId] with no
guard, so a travel referencing a hotel absent from hotel.xml produced an
undefined-key warning and an entry with a null label -- a hotel in
/api/travels that resolves nowhere. No row hits this in the current
snapshot, which is why it has never surfaced.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:08:07 +02:00
frommeandClaude Opus 5 f3745f918e feat: expose hotel cost unit and iso country code via the api
The Teamer app groups hotels by cost unit and renders a flag per
destination, and cannot derive either from what /api/hotels returns
today.

costUnit is the raw kostenstelle and stays a string: only 281 of 1931
hotels carry one and some hold a space-separated list such as "84 85".

countryCode goes through CountryCodeMapper, whose table is part of the
API contract: BusPro's land column mixes ISO-2, ISO-3 and European
vehicle registration codes for the same country, and a wrong entry
mislabels a destination's flag silently. An unmappable token is null and
warns on the bpn channel, never a guess. country itself is untouched --
it remains the join key into /api/countries[].token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:07:13 +02:00
frommeandClaude Opus 5 65c1a7bd12 feat: enable client credentials grant for machine-to-machine api clients
The Teamer app imports travel and hotel master data nightly with no user
in the loop. AuthorizationCodeListener is bound to
AUTHORIZATION_REQUEST_RESOLVE, which only the auth code grant dispatches,
so a userless token never reaches the required_roles gate.

Drops the tagged grant service added in 79709e3a. That tag registered
client_credentials through the bundle's @deprecated grant configurator,
which runs regardless of enable_client_credentials_grant and re-enabled
the grant a second time with its own PT1H. Two enableGrantType() calls
for one grant left the lifetime decided by call order. M2M tokens now use
the shared access_token_ttl of PT10M.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:04:05 +02:00
19 changed files with 214 additions and 1756 deletions

No files matched your search

Generated
+164 -164
View File
@@ -4938,16 +4938,16 @@
},
{
"name": "symfony/cache",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8"
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/e7e604eab58639503495a46f5c194cc69a9c80a8",
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8",
"url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
"shasum": ""
},
"require": {
@@ -5017,7 +5017,7 @@
"psr6"
],
"support": {
"source": "https://github.com/symfony/cache/tree/v7.4.20"
"source": "https://github.com/symfony/cache/tree/v7.4.19"
},
"funding": [
{
@@ -5037,7 +5037,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/cache-contracts",
@@ -5278,16 +5278,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d"
"reference": "3a1973458b153f566a8d7499bc06ece323d60063"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
"url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063",
"reference": "3a1973458b153f566a8d7499bc06ece323d60063",
"shasum": ""
},
"require": {
@@ -5352,7 +5352,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.20"
"source": "https://github.com/symfony/console/tree/v7.4.19"
},
"funding": [
{
@@ -5372,20 +5372,20 @@
"type": "tidelift"
}
],
"time": "2026-09-18T15:55:00+00:00"
"time": "2026-09-13T10:38:41+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2"
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a7faee783a87b55f27460fc571f8c639bf9d93a2",
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
"shasum": ""
},
"require": {
@@ -5436,7 +5436,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.20"
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.17"
},
"funding": [
{
@@ -5456,7 +5456,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -5531,16 +5531,16 @@
},
{
"name": "symfony/doctrine-bridge",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/doctrine-bridge.git",
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7"
"reference": "d89437a59232a7e9b294652faea2469f2af66b63"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/03d285cfe0f633a856e7acb68cecf5964f8494a7",
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7",
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/d89437a59232a7e9b294652faea2469f2af66b63",
"reference": "d89437a59232a7e9b294652faea2469f2af66b63",
"shasum": ""
},
"require": {
@@ -5620,7 +5620,7 @@
"description": "Provides integration for Doctrine with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.20"
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.19"
},
"funding": [
{
@@ -5640,20 +5640,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-11T14:46:58+00:00"
},
{
"name": "symfony/doctrine-messenger",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/doctrine-messenger.git",
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7"
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/a34e55dce43e1229bb47555b57ba3c95910988c7",
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7",
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/edee21e6307105f0aca6b82a1dc201e6789c5ed1",
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1",
"shasum": ""
},
"require": {
@@ -5666,7 +5666,7 @@
"doctrine/persistence": "<1.3"
},
"require-dev": {
"doctrine/persistence": "^1.3|^2|^3|^4",
"doctrine/persistence": "^1.3|^2|^3",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4|^7.0|^8.0"
},
@@ -5696,7 +5696,7 @@
"description": "Symfony Doctrine Messenger Bridge",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.20"
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.19"
},
"funding": [
{
@@ -5716,7 +5716,7 @@
"type": "tidelift"
}
],
"time": "2026-09-24T08:15:08+00:00"
"time": "2026-09-13T08:27:19+00:00"
},
{
"name": "symfony/dom-crawler",
@@ -5870,16 +5870,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0"
"reference": "8373921e231e190a88e2ad526951bbaa791576fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa",
"reference": "8373921e231e190a88e2ad526951bbaa791576fa",
"shasum": ""
},
"require": {
@@ -5928,7 +5928,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.4.20"
"source": "https://github.com/symfony/error-handler/tree/v7.4.17"
},
"funding": [
{
@@ -5948,7 +5948,7 @@
"type": "tidelift"
}
],
"time": "2026-09-22T18:56:40+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/event-dispatcher",
@@ -6255,16 +6255,16 @@
},
{
"name": "symfony/finder",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e"
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
"url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"shasum": ""
},
"require": {
@@ -6299,7 +6299,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.20"
"source": "https://github.com/symfony/finder/tree/v7.4.19"
},
"funding": [
{
@@ -6319,7 +6319,7 @@
"type": "tidelift"
}
],
"time": "2026-09-26T06:31:10+00:00"
"time": "2026-09-10T19:14:10+00:00"
},
{
"name": "symfony/flex",
@@ -6396,16 +6396,16 @@
},
{
"name": "symfony/form",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/form.git",
"reference": "a36c175f54bbe0320e096a857d67780053a28f44"
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/form/zipball/a36c175f54bbe0320e096a857d67780053a28f44",
"reference": "a36c175f54bbe0320e096a857d67780053a28f44",
"url": "https://api.github.com/repos/symfony/form/zipball/ededc326d2e5f271019b37d54c978e67cbc53ae3",
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3",
"shasum": ""
},
"require": {
@@ -6475,7 +6475,7 @@
"description": "Allows to easily create, process and reuse HTML forms",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/form/tree/v7.4.20"
"source": "https://github.com/symfony/form/tree/v7.4.19"
},
"funding": [
{
@@ -6495,20 +6495,20 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-15T06:01:15+00:00"
},
{
"name": "symfony/framework-bundle",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/framework-bundle.git",
"reference": "99786f9338c24770ce4de6a95d049675aa75b129"
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/99786f9338c24770ce4de6a95d049675aa75b129",
"reference": "99786f9338c24770ce4de6a95d049675aa75b129",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/666aece282b9c6f4664c5d81149b17ac5a0f4732",
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732",
"shasum": ""
},
"require": {
@@ -6562,6 +6562,7 @@
"symfony/workflow": "<7.4"
},
"require-dev": {
"doctrine/persistence": "^1.3|^2|^3",
"dragonmantank/cron-expression": "^3.1",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"seld/jsonlint": "^1.10",
@@ -6632,7 +6633,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.19"
},
"funding": [
{
@@ -6652,20 +6653,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-14T09:18:42+00:00"
},
{
"name": "symfony/html-sanitizer",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc"
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/d49c351e24d38604466e5a51ab20cbc99f9493c7",
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7",
"shasum": ""
},
"require": {
@@ -6706,7 +6707,7 @@
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.20"
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.19"
},
"funding": [
{
@@ -6726,20 +6727,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T05:21:23+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "symfony/http-client",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c"
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
"url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
"shasum": ""
},
"require": {
@@ -6807,7 +6808,7 @@
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.20"
"source": "https://github.com/symfony/http-client/tree/v7.4.19"
},
"funding": [
{
@@ -6827,7 +6828,7 @@
"type": "tidelift"
}
],
"time": "2026-09-24T11:02:13+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/http-client-contracts",
@@ -6913,16 +6914,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33"
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9d0761c0da6bd5b801b401666834901a9a1ffc33",
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936",
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936",
"shasum": ""
},
"require": {
@@ -6971,7 +6972,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.20"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.19"
},
"funding": [
{
@@ -6991,20 +6992,20 @@
"type": "tidelift"
}
],
"time": "2026-09-24T07:12:44+00:00"
"time": "2026-09-14T17:41:17+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "392784b5d7b959b8d872542a2530c50c540a788f"
"reference": "683507424cc84ca85ac1551adb609b6b98216667"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/392784b5d7b959b8d872542a2530c50c540a788f",
"reference": "392784b5d7b959b8d872542a2530c50c540a788f",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667",
"reference": "683507424cc84ca85ac1551adb609b6b98216667",
"shasum": ""
},
"require": {
@@ -7090,7 +7091,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.20"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.19"
},
"funding": [
{
@@ -7110,7 +7111,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T14:05:16+00:00"
"time": "2026-09-15T07:09:35+00:00"
},
{
"name": "symfony/intl",
@@ -7204,16 +7205,16 @@
},
{
"name": "symfony/lock",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/lock.git",
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0"
"reference": "4073e583b1cb048799706d7ba3245328858cfea6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/lock/zipball/80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
"url": "https://api.github.com/repos/symfony/lock/zipball/4073e583b1cb048799706d7ba3245328858cfea6",
"reference": "4073e583b1cb048799706d7ba3245328858cfea6",
"shasum": ""
},
"require": {
@@ -7263,7 +7264,7 @@
"semaphore"
],
"support": {
"source": "https://github.com/symfony/lock/tree/v7.4.20"
"source": "https://github.com/symfony/lock/tree/v7.4.18"
},
"funding": [
{
@@ -7283,7 +7284,7 @@
"type": "tidelift"
}
],
"time": "2026-09-18T15:55:00+00:00"
"time": "2026-08-30T20:10:52+00:00"
},
{
"name": "symfony/mailer",
@@ -7371,16 +7372,16 @@
},
{
"name": "symfony/messenger",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/messenger.git",
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410"
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/messenger/zipball/4cf94f12c9e899a87e6b6bf800dffcb709585410",
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410",
"url": "https://api.github.com/repos/symfony/messenger/zipball/4070504211a16c4237247f7b2a91143c3a1f4e51",
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51",
"shasum": ""
},
"require": {
@@ -7441,7 +7442,7 @@
"description": "Helps applications send and receive messages to/from other applications or via message queues",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/messenger/tree/v7.4.20"
"source": "https://github.com/symfony/messenger/tree/v7.4.19"
},
"funding": [
{
@@ -7461,7 +7462,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T06:38:53+00:00"
"time": "2026-09-14T15:23:30+00:00"
},
{
"name": "symfony/mime",
@@ -8925,16 +8926,16 @@
},
{
"name": "symfony/property-info",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-info.git",
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48"
"reference": "547d2acbe865d511809b96ab653304fd632abbaa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/property-info/zipball/d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
"url": "https://api.github.com/repos/symfony/property-info/zipball/547d2acbe865d511809b96ab653304fd632abbaa",
"reference": "547d2acbe865d511809b96ab653304fd632abbaa",
"shasum": ""
},
"require": {
@@ -8991,7 +8992,7 @@
"validator"
],
"support": {
"source": "https://github.com/symfony/property-info/tree/v7.4.20"
"source": "https://github.com/symfony/property-info/tree/v7.4.19"
},
"funding": [
{
@@ -9011,7 +9012,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T09:47:41+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
@@ -9103,16 +9104,16 @@
},
{
"name": "symfony/routing",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b"
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
"url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
"shasum": ""
},
"require": {
@@ -9164,7 +9165,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.20"
"source": "https://github.com/symfony/routing/tree/v7.4.18"
},
"funding": [
{
@@ -9184,7 +9185,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-08-17T13:12:36+00:00"
},
{
"name": "symfony/runtime",
@@ -9273,16 +9274,16 @@
},
{
"name": "symfony/security-bundle",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-bundle.git",
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c"
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/caaa8b92f2657a6b99057f4da36ee89f3baab506",
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506",
"shasum": ""
},
"require": {
@@ -9323,7 +9324,6 @@
"symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/ldap": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
"symfony/runtime": "^6.4.13|^7.1.6|^8.0",
@@ -9362,7 +9362,7 @@
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/security-bundle/tree/v7.4.18"
},
"funding": [
{
@@ -9382,20 +9382,20 @@
"type": "tidelift"
}
],
"time": "2026-09-24T11:02:13+00:00"
"time": "2026-08-30T17:23:54+00:00"
},
{
"name": "symfony/security-core",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-core.git",
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829"
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4e5c01656aa79e6d463fde45117041ebccbaf829",
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4fac226354298b8d66f680275b0daef1d3f580d2",
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2",
"shasum": ""
},
"require": {
@@ -9454,7 +9454,7 @@
"description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-core/tree/v7.4.20"
"source": "https://github.com/symfony/security-core/tree/v7.4.18"
},
"funding": [
{
@@ -9474,7 +9474,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T16:00:27+00:00"
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "symfony/security-csrf",
@@ -9552,16 +9552,16 @@
},
{
"name": "symfony/security-http",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-http.git",
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692"
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-http/zipball/5fa566b8bfbb398cded6988f059c3d97506ed692",
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692",
"url": "https://api.github.com/repos/symfony/security-http/zipball/c13182ec07b8a0c5316505a4c9c51948f87d87bb",
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb",
"shasum": ""
},
"require": {
@@ -9620,7 +9620,7 @@
"description": "Symfony Security Component - HTTP Integration",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-http/tree/v7.4.20"
"source": "https://github.com/symfony/security-http/tree/v7.4.19"
},
"funding": [
{
@@ -9640,20 +9640,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T18:33:58+00:00"
"time": "2026-09-15T06:01:15+00:00"
},
{
"name": "symfony/serializer",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/serializer.git",
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf"
"reference": "d631b82e0db971444651c4e6a076040d20878caa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/serializer/zipball/67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
"url": "https://api.github.com/repos/symfony/serializer/zipball/d631b82e0db971444651c4e6a076040d20878caa",
"reference": "d631b82e0db971444651c4e6a076040d20878caa",
"shasum": ""
},
"require": {
@@ -9724,7 +9724,7 @@
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/serializer/tree/v7.4.20"
"source": "https://github.com/symfony/serializer/tree/v7.4.19"
},
"funding": [
{
@@ -9744,7 +9744,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T09:39:38+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/service-contracts",
@@ -10452,16 +10452,16 @@
},
{
"name": "symfony/type-info",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/type-info.git",
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2"
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/type-info/zipball/bbaaf3366400f5c85f66800b955c77b46e8abad2",
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2",
"url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
"shasum": ""
},
"require": {
@@ -10511,7 +10511,7 @@
"type"
],
"support": {
"source": "https://github.com/symfony/type-info/tree/v7.4.20"
"source": "https://github.com/symfony/type-info/tree/v7.4.17"
},
"funding": [
{
@@ -10531,20 +10531,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T12:19:37+00:00"
"time": "2026-08-21T15:52:48+00:00"
},
{
"name": "symfony/uid",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa"
"reference": "69d732355a139c6f8881337d28515aa01f12b8be"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/uid/zipball/a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
"url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be",
"reference": "69d732355a139c6f8881337d28515aa01f12b8be",
"shasum": ""
},
"require": {
@@ -10589,7 +10589,7 @@
"uuid"
],
"support": {
"source": "https://github.com/symfony/uid/tree/v7.4.20"
"source": "https://github.com/symfony/uid/tree/v7.4.17"
},
"funding": [
{
@@ -10609,7 +10609,7 @@
"type": "tidelift"
}
],
"time": "2026-09-23T16:18:28+00:00"
"time": "2026-08-11T07:38:58+00:00"
},
{
"name": "symfony/ux-twig-component",
@@ -10702,16 +10702,16 @@
},
{
"name": "symfony/validator",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "75a30e116016554477f662e106fd8e337f0c4dac"
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/75a30e116016554477f662e106fd8e337f0c4dac",
"reference": "75a30e116016554477f662e106fd8e337f0c4dac",
"url": "https://api.github.com/repos/symfony/validator/zipball/a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
"shasum": ""
},
"require": {
@@ -10782,7 +10782,7 @@
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v7.4.20"
"source": "https://github.com/symfony/validator/tree/v7.4.19"
},
"funding": [
{
@@ -10802,7 +10802,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-09T19:50:33+00:00"
},
{
"name": "symfony/var-dumper",
@@ -11137,16 +11137,16 @@
},
{
"name": "symfony/yaml",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7"
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
"url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1",
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1",
"shasum": ""
},
"require": {
@@ -11189,7 +11189,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.20"
"source": "https://github.com/symfony/yaml/tree/v7.4.18"
},
"funding": [
{
@@ -11209,7 +11209,7 @@
"type": "tidelift"
}
],
"time": "2026-09-26T06:31:10+00:00"
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "twig/extra-bundle",
@@ -13055,16 +13055,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.37",
"version": "12.5.36",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba"
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/684340c1590e5f3f3b859f5e80619725d88b63ba",
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"shasum": ""
},
"require": {
@@ -13133,7 +13133,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.37"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36"
},
"funding": [
{
@@ -13141,7 +13141,7 @@
"type": "other"
}
],
"time": "2026-09-29T12:08:54+00:00"
"time": "2026-09-25T08:47:40+00:00"
},
{
"name": "react/cache",
@@ -15103,16 +15103,16 @@
},
{
"name": "symfony/web-profiler-bundle",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1"
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/916c2a4edf51270a5173a57d8d3936c02a4543a1",
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f9c5fed39f307b23c47e8a11c431d848390f8a67",
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67",
"shasum": ""
},
"require": {
@@ -15169,7 +15169,7 @@
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.19"
},
"funding": [
{
@@ -15189,7 +15189,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "theseer/tokenizer",
-5
View File
@@ -228,11 +228,6 @@ services:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
+7 -158
View File
@@ -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/*`; 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.
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.
- 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/*` 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/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.
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.
@@ -92,9 +92,7 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
status 200 — treat null as "unknown", not as "changed".
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
---
@@ -148,7 +146,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.
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
This is the only paginated endpoint and the only one 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.
@@ -202,7 +200,6 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
@@ -212,8 +209,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -241,154 +238,6 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
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
-23
View File
@@ -4,9 +4,6 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo
{
private const INFO_FILENAME = 'uebertragung.info';
@@ -23,26 +20,6 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME;
}
/**
* Reads the marker from a storage, or null when it is missing or unreadable.
*
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
* callers only ever want to know which export they are looking at, and "unknown" is
* an answer they can act on.
*/
public static function fromStorage(FilesystemOperator $storage): ?self
{
try {
if (false === $storage->fileExists(self::INFO_FILENAME)) {
return null;
}
return self::fromString($storage->read(self::INFO_FILENAME));
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
public static function fromString(string $content): self
{
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
@@ -1,161 +0,0 @@
<?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;
}
}
-199
View File
@@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Removes local accounts that carry nothing worth keeping.
*
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
*
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
*
* An account is removed when all of the following hold:
*
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
* at all is the same state, spelled the way it was before the fallback landed;
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
* anybody had ruled on it;
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
*
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
*
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
* say out loud that the task destroys data.
*/
#[AsCommand(
name: 'app:cleanup:users',
description: 'Removes accounts that hold no role, no draft and no group booking reference',
)]
class CleanupUsersCommand extends Command
{
/**
* How many accounts are removed between two flushes. The unit of work holds every scheduled
* removal until then, and the first run has thousands of rows to get through, so it is a
* memory bound rather than a policy one.
*/
private const BATCH_SIZE = 500;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'inactive-for',
null,
InputOption::VALUE_REQUIRED,
'Only consider accounts without a login for this many months (default: no such limit)',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Maximum number of accounts to delete in one run (default: no limit)',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$dryRun = (bool) $input->getOption('dry-run');
$force = (bool) $input->getOption('force');
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
$io->error('--inactive-for and --limit have to be at least 1 when given.');
return Command::INVALID;
}
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
$candidates = array_values(array_filter(
$this->userRepository->findCleanupCandidates($inactiveSince),
static fn (User $user): bool => self::isDisposable($user),
));
// After the role filter, never before it: --limit caps the accounts actually deleted, and
// the candidate query cannot tell which ones those are.
if (null !== $limit) {
$candidates = \array_slice($candidates, 0, $limit);
}
if ([] === $candidates) {
$io->success('No accounts to remove.');
return Command::SUCCESS;
}
if (true === $dryRun || false === $force) {
$io->table(
['E-Mail', 'Letzter Login', 'Rollen'],
array_map(static fn (User $user): array => [
$user->getEmail(),
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
], $candidates),
);
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
if (true === $dryRun) {
return Command::SUCCESS;
}
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
// schedule entry missing --force turns into a mail rather than a task that quietly
// does nothing every week.
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
return Command::INVALID;
}
foreach ($candidates as $index => $user) {
// One line per account, before the removal rather than after: once the row is gone
// this is the only place left that records which account the uuid belonged to. Worth
// a log_entry row per deletion, including the several thousand the first run writes —
// they age out with the six-month log retention.
$this->logger->info('Deleting account without roles', [
'uuid' => $user->getUuid(),
'email' => $user->getEmail(),
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
'roles' => Role::assignedOnly($user->getRoles()),
]);
$this->entityManager->remove($user);
if (0 === ($index + 1) % self::BATCH_SIZE) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$count = \count($candidates);
$this->logger->info('Deleted accounts without roles', [
'count' => $count,
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
]);
$io->success(sprintf('Deleted %d account(s).', $count));
return Command::SUCCESS;
}
/**
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
* spelling accounts kept that have not signed in since the fallback landed.
*
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
* account carrying one stays until somebody has ruled on it.
*/
private static function isDisposable(User $user): bool
{
$roles = $user->getRoles();
return [] === Role::pendingOnly($roles)
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
}
}
+3 -25
View File
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -31,7 +29,6 @@ class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
private readonly ProductExportBuilder $productExportBuilder,
) {
}
@@ -44,31 +41,13 @@ class ExportController extends AbstractController
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ExportQuery $query = new ExportQuery(),
TravelExportQuery $query = new TravelExportQuery(),
): 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.
return $this->paginate($this->travelExportBuilder->build(), $query);
}
$export = $this->travelExportBuilder->build();
/**
* 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([
@@ -80,7 +59,6 @@ class ExportController extends AbstractController
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
'generatedAt' => $export->generatedAt->format('c'),
],
]);
+14 -7
View File
@@ -2,7 +2,6 @@
namespace App\Controller\Api;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -17,15 +16,23 @@ class LastUpdateController extends AbstractController
{
}
/**
* A missing or unreadable marker answers `{"timestamp": null}`, never a 500: consumers
* poll this for change detection and treat null as "unknown".
*/
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
public function index(): JsonResponse
{
$info = XmlExportInfo::fromStorage($this->xmlExport);
if (false === $this->xmlExport->has('uebertragung.info')) {
$lastUploadAt = null;
} else {
$file = $this->xmlExport->read('uebertragung.info');
$lines = explode("\n", $file);
$line = trim($lines[0]);
return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
if (true === empty($line)) {
$lastUploadAt = null;
} else {
$lastUploadAt = \DateTime::createFromFormat('d.m.Y H:i:s', $line);
}
}
return $this->json(['timestamp' => $lastUploadAt->format('Y-m-d H:i:s')]);
}
}
@@ -5,29 +5,26 @@ declare(strict_types=1);
namespace App\Model;
/**
* A whole travel-date x hotel export plus the counters that describe how it was built.
* The whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
* @see \App\Service\ProductExportBuilder
*/
final readonly class Export
final readonly class TravelExport
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
* @param \DateTimeImmutable|null $sourceGeneratedAt the uebertragung.info timestamp of the export read, null if unknown
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
public ?\DateTimeImmutable $sourceGeneratedAt = null,
) {
}
}
@@ -6,7 +6,7 @@ namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ExportQuery
final readonly class TravelExportQuery
{
public function __construct(
#[Assert\Positive]
-88
View File
@@ -4,12 +4,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEditDraft;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Entity\User;
use App\Form\Model\Filter\UserFilterDto;
use App\Repository\Filter\AppliesListFiltersTrait;
@@ -127,87 +122,4 @@ class UserRepository extends ServiceEntityRepository
return $users;
}
/**
* The entities that point at a User and would silently lose that pointer if the account were
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
* stand in the way — it just erases who created, last touched or looks after a group booking.
* The cleanup therefore has to ask, rather than let the constraint decide.
*
* @var array<class-string, string[]>
*/
private const USER_REFERENCES = [
Accommodation::class => ['createdBy', 'updatedBy'],
AccommodationPrice::class => ['createdBy', 'updatedBy'],
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
AdditionalService::class => ['createdBy', 'updatedBy'],
BoardService::class => ['createdBy', 'updatedBy'],
];
/**
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
*
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
* asking for ten deletions would get however many of the first ten candidates survived the
* role filter. Ordered oldest login first so a caller that does cap the result takes the
* least recently seen accounts.
*
* The roles are deliberately not part of this query. They live in a JSON array column with no
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
* a role set actually means.
*
* @return User[]
*/
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
{
$qb = $this->createQueryBuilder('u');
if (null !== $inactiveSince) {
$qb
->andWhere('u.lastLoginAt IS NOT NULL')
->andWhere('u.lastLoginAt < :inactiveSince')
->setParameter('inactiveSince', $inactiveSince)
;
}
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select('draft.id')
->from(BookingEditDraft::class, 'draft')
->andWhere('draft.user = u')
->getDQL()
)));
$index = 0;
foreach (self::USER_REFERENCES as $class => $fields) {
foreach ($fields as $field) {
$alias = sprintf('ref%d%s', $index++, $field);
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select($alias.'.id')
->from($class, $alias)
->andWhere(sprintf('%s.%s = u', $alias, $field))
->getDQL()
)));
}
}
/** @var User[] $users */
$users = $qb
->orderBy('u.lastLoginAt', 'ASC')
->getQuery()
->getResult()
;
return $users;
}
}
-225
View File
@@ -1,225 +0,0 @@
<?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;
}
}
+4 -11
View File
@@ -6,13 +6,12 @@ namespace App\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\Export;
use App\Model\TravelExport;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
@@ -52,7 +51,7 @@ class TravelExportBuilder
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): Export
public function build(): TravelExport
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
@@ -66,13 +65,8 @@ class TravelExportBuilder
}
}
private function assemble(): Export
private function assemble(): TravelExport
{
// Read before the walk and cached with the rows, so every page served from this
// copy names the same source export. A client that sees it change between pages
// knows xml-sync swapped the export underneath its walk.
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
$hotels = $this->hotelLoader->loadAll();
$pickupMasterData = $this->pickupLoader->loadAll();
@@ -97,12 +91,11 @@ class TravelExportBuilder
);
}
return new Export(
return new TravelExport(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
$sourceGeneratedAt,
);
}
-201
View File
@@ -1,201 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\CleanupUsersCommand;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class CleanupUsersCommandTest extends TestCase
{
public function testDeletesAccountsWithoutAnEffectiveRole(): void
{
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
$roleless = $this->user('[email protected]', []);
$entityManager = $this->createMock(EntityManagerInterface::class);
$removed = [];
$entityManager->expects(self::exactly(2))
->method('remove')
->willReturnCallback(static function (User $user) use (&$removed): void {
$removed[] = $user->getEmail();
});
$entityManager->expects(self::once())->method('flush');
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertSame(['[email protected]', '[email protected]'], $removed);
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
}
/**
* The candidate query cannot read the roles, so everything the role policy protects has to
* survive this filter — including an account whose only effective role sits beside the
* ROLE_CUSTOMER fallback that older rows still carry.
*
* @param string[] $roles
*/
#[DataProvider('protectedRoleSets')]
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
}
/**
* @return iterable<string, array{string[]}>
*/
public static function protectedRoleSets(): iterable
{
yield 'teamer' => [[Role::TEAMER]];
yield 'employee' => [[Role::EMPLOYEE]];
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
yield 'house manager' => [[Role::HOUSE_MANAGER]];
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
}
/**
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
* definition ever forces the caller to state an intent — this does.
*/
public function testRefusesToDeleteWithoutForce(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute([]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
}
public function testDryRunDeletesNothing(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute(['--dry-run' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
self::assertStringContainsString('[email protected]', $tester->getDisplay());
}
/**
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
* account among protected ones still deletes it rather than spending the budget on rows it
* then discards.
*/
public function testLimitCountsDeletionsRatherThanCandidates(): void
{
$users = [
$this->user('[email protected]', [Role::TEAMER]),
$this->user('[email protected]', [Role::CUSTOMER]),
];
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())
->method('remove')
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
$tester = new CommandTester($this->command($users, $entityManager));
$tester->execute(['--limit' => 1, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
}
public function testInactivityWindowIsOffUnlessAskedFor(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(null)
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testInactivityWindowIsPassedOnAsADate(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
$expected = new \DateTimeImmutable('-6 months');
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
}))
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 6, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testRejectsANonPositiveWindow(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::never())->method('findCleanupCandidates');
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 0]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
}
/**
* @param string[] $roles
*/
private function user(string $email, array $roles): User
{
return (new User($email))
->setRoles($roles)
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
}
/**
* @param User[] $candidates
*/
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
{
$repository = $this->createStub(UserRepository::class);
$repository->method('findCleanupCandidates')->willReturn($candidates);
return $this->commandWith($repository, $entityManager);
}
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
{
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
}
}
+16 -74
View File
@@ -5,9 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
@@ -17,7 +16,7 @@ class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->call(new ExportQuery(1, 5))['meta'];
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
self::assertSame(1, $meta['page']);
self::assertSame(5, $meta['perPage']);
@@ -28,18 +27,18 @@ 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 ExportQuery(1, 5))['meta'];
$last = $this->call(new ExportQuery(3, 5))['meta'];
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) {
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
}
}
public function testConsecutivePagesDoNotOverlap(): void
{
$first = array_column($this->call(new ExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new ExportQuery(2, 5))['data'], 'busProId');
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
self::assertSame([1, 2, 3, 4, 5], $first);
self::assertSame([6, 7, 8, 9, 10], $second);
@@ -48,7 +47,7 @@ class ExportControllerTest extends TestCase
public function testTheLastPageIsShort(): void
{
$data = $this->call(new ExportQuery(3, 5))['data'];
$data = $this->call(new TravelExportQuery(3, 5))['data'];
self::assertCount(2, $data);
self::assertSame([11, 12], array_column($data, 'busProId'));
@@ -56,7 +55,7 @@ class ExportControllerTest extends TestCase
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
{
$body = $this->call(new ExportQuery(99, 5));
$body = $this->call(new TravelExportQuery(99, 5));
self::assertSame([], $body['data']);
self::assertSame(12, $body['meta']['total']);
@@ -79,18 +78,6 @@ class ExportControllerTest extends TestCase
self::assertSame(7, $meta['skipped']);
self::assertSame(21, $meta['pickupsWithoutMasterData']);
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
self::assertSame('2026-09-28T23:10:15+02:00', $meta['sourceGeneratedAt']);
}
public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void
{
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new Export([], 0, 0, new \DateTimeImmutable()));
$meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->getContent(), true)['meta'];
self::assertArrayHasKey('sourceGeneratedAt', $meta);
self::assertNull($meta['sourceGeneratedAt']);
}
/**
@@ -104,57 +91,13 @@ class ExportControllerTest extends TestCase
$this->expectException(\RuntimeException::class);
$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());
$this->controller($builder)->travels(new TravelExportQuery());
}
/**
* @return array<string, mixed>
*/
private function call(?ExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
{
$rows = [];
for ($i = 1; $i <= 12; ++$i) {
@@ -162,24 +105,23 @@ class ExportControllerTest extends TestCase
}
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new Export(
$builder->method('build')->willReturn(new TravelExport(
$rows,
$skipped,
$withoutMasterData,
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($builder)->travels($query ?? new ExportQuery());
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController
private function controller(TravelExportBuilder $builder): ExportController
{
$controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class));
$controller = new ExportController($builder);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse -- which is what this endpoint produces in production anyway.
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\LastUpdateController;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
class LastUpdateControllerTest extends TestCase
{
public function testReturnsTheMarkerTimestamp(): void
{
self::assertSame('2026-09-22 11:10:15', $this->timestampFor(
"22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"
));
}
public function testAMissingMarkerIsNullRatherThanAServerError(): void
{
self::assertNull($this->timestampFor(null));
}
public function testAnEmptyMarkerIsNull(): void
{
self::assertNull($this->timestampFor(''));
}
public function testAnUnparseableMarkerIsNull(): void
{
self::assertNull($this->timestampFor("gestern\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\n"));
}
private function timestampFor(?string $marker): ?string
{
$storage = $this->createStub(FilesystemOperator::class);
$storage->method('fileExists')->willReturn(null !== $marker);
$storage->method('read')->willReturn((string) $marker);
$controller = new LastUpdateController($storage);
$controller->setContainer(new Container());
$body = json_decode((string) $controller->index()->getContent(), true);
self::assertArrayHasKey('timestamp', $body);
return $body['timestamp'];
}
}
-98
View File
@@ -1,98 +0,0 @@
<?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>
-243
View File
@@ -1,243 +0,0 @@
<?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;
}
}
+3 -18
View File
@@ -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\Export;
use App\Model\TravelExport;
use App\Service\TravelExportBuilder;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
@@ -156,18 +156,6 @@ class TravelExportBuilderTest extends TestCase
/**
* @return array<string, mixed>
*/
public function testCarriesTheSourceExportTimestamp(): void
{
$export = $this->build(null, ['Ziel_1.xml'], "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'));
}
public function testTheSourceExportTimestampIsNullWithoutAMarker(): void
{
self::assertNull($this->build()->sourceGeneratedAt);
}
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
@@ -182,7 +170,7 @@ class TravelExportBuilderTest extends TestCase
/**
* @param list<string> $files
*/
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export
private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport
{
$contents = [
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
@@ -195,10 +183,7 @@ class TravelExportBuilderTest extends TestCase
$files,
)));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? ''
);
$filesystem->method('fileExists')->willReturnCallback(
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
static fn (string $path): string => $contents[$path] ?? ''
);
$hotelLoader = $this->createStub(HotelLoader::class);