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
fromme d0e7ba017c fix: resync room selections with travel data before step 2 2026-09-29 10:59:13 +02:00
fromme be0e15b3d4 chore: update project dependencies 2026-09-29 10:12:41 +02:00
fromme f5e6d2105d feat: make boarding point price levelling switchable 2026-09-29 10:11:03 +02:00
fromme 0c6126010f fix: always adopt outbound pickup price for inbound when set 2026-09-28 16:33:36 +02:00
fromme 29f295abf6 feat: duplicate an accommodation booking into a new draft
addresses #869f6z44f
2026-09-28 08:55:01 +02:00
fromme 2aa98b81fe feat: dedicated login page for oauth2 logins 2026-09-27 13:43:17 +02:00
fromme 56adf72d12 fix: refuse implausible accommodation booking prices 2026-09-26 15:39:04 +02:00
65 changed files with 3966 additions and 182 deletions

No files matched your search

+9
View File
@@ -92,6 +92,15 @@ APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Compensates a BusPro export defect: a <zustieg_rueck> can omit its preis although the booking
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg.
# A boarding point costs the same in both directions, so the stated price is applied to both.
# Off = report the export verbatim. Provisional until the vendor fixes the export; see
# docs/buspro-database-reference.md section A7.
# Flipping this needs `app:bpn:xml-cache-invalidate` and `app:bpn:refresh-travel-snapshot`,
# because the cached travels and TravelSnapshot rows carry the parsed price.
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
# Emails
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
Generated
+61 -55
View File
@@ -3372,16 +3372,16 @@
},
{
"name": "nesbot/carbon",
"version": "3.14.0",
"version": "3.14.1",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2"
"reference": "34e9109535165bf1d7ff17a610492372ae73a896"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896",
"reference": "34e9109535165bf1d7ff17a610492372ae73a896",
"shasum": ""
},
"require": {
@@ -3473,7 +3473,7 @@
"type": "tidelift"
}
],
"time": "2026-09-12T20:45:19+00:00"
"time": "2026-09-27T12:07:26+00:00"
},
{
"name": "nyholm/psr7",
@@ -3674,20 +3674,25 @@
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
"version": "2.2.1",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
"reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8",
"phpunit/phpunit": "^9.5",
"vimeo/psalm": "^4.25"
},
"type": "library",
"extra": {
@@ -3711,7 +3716,7 @@
}
],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org",
"homepage": "https://www.phpdoc.org",
"keywords": [
"FQSEN",
"phpDocumentor",
@@ -3721,9 +3726,9 @@
],
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1"
},
"time": "2020-06-27T09:03:43+00:00"
"time": "2025-11-10T21:21:55+00:00"
},
{
"name": "phpdocumentor/reflection-docblock",
@@ -4070,16 +4075,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.5",
"version": "2.3.6",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
"reference": "1427af4647235b4a73b13940b1f1258d3584f36e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e",
"reference": "1427af4647235b4a73b13940b1f1258d3584f36e",
"shasum": ""
},
"require": {
@@ -4111,9 +4116,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6"
},
"time": "2026-08-31T16:05:28+00:00"
"time": "2026-09-27T20:07:45+00:00"
},
{
"name": "psr/cache",
@@ -11350,16 +11355,16 @@
},
{
"name": "twig/intl-extra",
"version": "v3.29.0",
"version": "v3.30.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/intl-extra.git",
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752"
"reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/51c52470aca59f3a9715c88f6fe505133b88c752",
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"shasum": ""
},
"require": {
@@ -11398,7 +11403,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.29.0"
"source": "https://github.com/twigphp/intl-extra/tree/v3.30.0"
},
"funding": [
{
@@ -11410,7 +11415,7 @@
"type": "tidelift"
}
],
"time": "2026-09-06T20:35:36+00:00"
"time": "2026-09-25T05:50:11+00:00"
},
{
"name": "twig/string-extra",
@@ -11481,16 +11486,16 @@
},
{
"name": "twig/twig",
"version": "v3.29.0",
"version": "v3.30.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d"
"reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"shasum": ""
},
"require": {
@@ -11545,7 +11550,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.29.0"
"source": "https://github.com/twigphp/Twig/tree/v3.30.0"
},
"funding": [
{
@@ -11557,7 +11562,7 @@
"type": "tidelift"
}
],
"time": "2026-09-18T09:10:14+00:00"
"time": "2026-09-25T13:20:01+00:00"
},
{
"name": "webmozart/assert",
@@ -11776,16 +11781,16 @@
},
{
"name": "composer/semver",
"version": "3.4.4",
"version": "3.5.0",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
"reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"shasum": ""
},
"require": {
@@ -11827,7 +11832,7 @@
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"description": "Version comparison library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
@@ -11837,7 +11842,7 @@
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
"source": "https://github.com/composer/semver/tree/3.5.0"
},
"funding": [
{
@@ -11849,7 +11854,7 @@
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
"time": "2026-09-24T14:38:51+00:00"
},
{
"name": "composer/xdebug-handler",
@@ -12089,23 +12094,23 @@
},
{
"name": "fidry/cpu-core-counter",
"version": "1.3.0",
"version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678"
"reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"fidry/makefile": "^0.2.0",
"fidry/makefile": "^0.2.0 || ^1.1.2",
"fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0",
@@ -12113,6 +12118,7 @@
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26",
"symfony/polyfill-mbstring": "^1.33",
"webmozarts/strict-phpunit": "^7.5"
},
"type": "library",
@@ -12138,7 +12144,7 @@
],
"support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues",
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.0"
},
"funding": [
{
@@ -12146,7 +12152,7 @@
"type": "github"
}
],
"time": "2025-08-14T07:29:31+00:00"
"time": "2026-09-27T17:40:20+00:00"
},
{
"name": "friendsofphp/php-cs-fixer",
@@ -12489,11 +12495,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.15",
"version": "2.2.16",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7",
"reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"shasum": ""
},
"require": {
@@ -12549,7 +12555,7 @@
"type": "github"
}
],
"time": "2026-09-23T12:23:07+00:00"
"time": "2026-09-25T09:31:51+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -13049,16 +13055,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.35",
"version": "12.5.36",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"shasum": ""
},
"require": {
@@ -13127,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.35"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36"
},
"funding": [
{
@@ -13135,7 +13141,7 @@
"type": "other"
}
],
"time": "2026-09-09T04:48:50+00:00"
"time": "2026-09-25T08:47:40+00:00"
},
{
"name": "react/cache",
+1 -1
View File
@@ -4,7 +4,7 @@ league_oauth2_server:
private_key_passphrase: null
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
require_code_challenge_for_public_clients: true
enable_client_credentials_grant: false
enable_client_credentials_grant: true
enable_auth_code_grant: true
enable_refresh_token_grant: true
enable_password_grant: false
+13 -5
View File
@@ -185,6 +185,10 @@ services:
arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\BusProNet\XmlParser\TravelParser:
arguments:
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
App\Service\TravelDataProvider:
arguments:
$logger: '@monolog.logger.core'
@@ -203,6 +207,10 @@ services:
arguments:
$cache: '@bpn.cache'
App\BusProNet\Utility\CountryCodeMapper:
arguments:
$logger: '@monolog.logger.bpn'
App\BusProNet\XmlLoader\HotelLoader:
arguments:
$cache: '@bpn.cache'
@@ -215,6 +223,11 @@ services:
arguments:
$cache: '@bpn.cache'
App\Service\TravelExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
@@ -382,8 +395,3 @@ services:
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
league.oauth2_server.grant.client_credentials:
class: League\OAuth2\Server\Grant\ClientCredentialsGrant
tags:
- { name: league.oauth2_server.authorization_server.grant, accessTokenTTL: 'PT1H' }
+122 -5
View File
@@ -10,8 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Token endpoint: `POST /token` (unauthenticated).
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL depends on the grant: **1 hour** for client_credentials, **10 minutes** for authorization_code.
Do not hardcode either value — read `expires_in` from the token response, it is authoritative and may be tuned.
- Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
- 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>`.
@@ -48,7 +50,9 @@ There is no single error envelope; three shapes occur:
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
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.
@@ -60,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
#### `GET /api/hotels` — scope `api`
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
`costUnit`, `countryCode`.
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
the mapping table is extended.
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
#### `GET /api/hotels/{id}` — scope `api`
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
`404 {"message":"Not found"}` if unknown.
#### `GET /api/countries` — scope `api`
@@ -123,6 +139,107 @@ This is a full overwrite, not a merge.
---
### Bulk exports
#### `GET /api/exports/travels` — scope `api`
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
This is the only paginated endpoint and the only one with a `meta` envelope.
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.
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
| `perPage` | int 1–1000 | `500` | |
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
an error: it returns `200` with an empty `data` array and the usual `meta`.
```json
{
"data": [
{
"busProId": 12794,
"code": "PCJSER261226",
"product": "Semesterende - Portes du Soleil - Joliment",
"dateFrom": "2026-12-26",
"dateTo": "2026-12-30",
"hotel": {
"busProId": 225444,
"code": "PCJSER1",
"name": "NEU SER Jolimont 1",
"costUnit": "863",
"country": "CH",
"countryCode": "ch"
},
"pickups": [
{
"busProId": 18,
"code": "ESS-Route",
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00"
}
]
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 415,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
```
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
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`
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
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
lost rows you previously had.
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
with no name is more useful to a disposition view than one that silently vanished.
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
day earlier than the running order implies. This is upstream data, not a transformation applied here.
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
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.
---
### Travels
#### `GET /api/travels` — scope `api`
+11
View File
@@ -225,6 +225,17 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
```
**V 2026-09-28 — the XML export can omit the return-leg price.** Since one price row is
surfaced twice, an asymmetric pair is an export defect: on products 701 and 2435 a
`<zustieg_rueck>` carries no `preis` although the engine still bills the return
`Abfahrtsortzuschlag`, so reading the export verbatim undercharges by one leg. Measured over
all exports, a stated return price never differs from its outbound counterpart (510 pairs,
zero counter-examples). `TravelParser` therefore levels a boarding point's price across both
directions, switchable through `APP_BPN_LEVEL_BOARDING_POINT_PRICES` while the defect is open
with the vendor. Flipping the flag needs `app:bpn:xml-cache-invalidate` and
`app:bpn:refresh-travel-snapshot`. A point appearing **only** in `zustiege_rueck` without a
price cannot be priced from our side — that is a catalog job.
**N — the departure surcharge is computed.** Booking responses carry a position
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
+11
View File
@@ -35,4 +35,15 @@ class Hotel
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $country = null;
/**
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
* space-separated list such as "84 85", so it is an opaque label, not a number.
*/
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $costUnit = null;
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $countryCode = null;
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
use Psr\Log\LoggerInterface;
/**
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
*
* BusPro has no country vocabulary: the field is free text that has accumulated three
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
* under several tokens. The table below is therefore part of the API contract rather than
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
* guess, never to an empty string.
*/
class CountryCodeMapper
{
/**
* Keys are upper-cased tokens, values lowercase ISO-2.
*
* Entries worth their explanation:
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
* Slovenia already arrives under its own `SI`.
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
* - `UK` is the colloquial form of the ISO `GB`.
*
* @var array<string, string>
*/
private const MAP = [
'A' => 'at',
'AT' => 'at',
'AUT' => 'at',
'B' => 'be',
'CA' => 'ca',
'CH' => 'ch',
'CZ' => 'cz',
'D' => 'de',
'DEU' => 'de',
'DK' => 'dk',
'E' => 'es',
'ES' => 'es',
'F' => 'fr',
'FI' => 'fi',
'FL' => 'li',
'FR' => 'fr',
'GR' => 'gr',
'GRE' => 'gr',
'HR' => 'hr',
'HRV' => 'hr',
'HU' => 'hu',
'I' => 'it',
'IL' => 'il',
'IR' => 'ie',
'IS' => 'is',
'IT' => 'it',
'LT' => 'lt',
'LV' => 'lv',
'MT' => 'mt',
'N' => 'no',
'NL' => 'nl',
'PL' => 'pl',
'PRT' => 'pt',
'PT' => 'pt',
'RUS' => 'ru',
'S' => 'se',
'SI' => 'si',
'SLO' => 'sk',
'UK' => 'gb',
'USA' => 'us',
'ZA' => 'za',
];
/**
* Tokens already reported in this process, so a master data file full of the same
* unknown token produces one log line rather than one per hotel.
*
* @var array<string, true>
*/
private array $reported = [];
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/**
* @param string|null $token the raw `hotel/land` value
*
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
*/
public function map(?string $token): ?string
{
if (null === $token) {
return null;
}
$normalized = strtoupper(trim($token));
if ('' === $normalized) {
return null;
}
if (isset(self::MAP[$normalized])) {
return self::MAP[$normalized];
}
if (false === isset($this->reported[$normalized])) {
$this->reported[$normalized] = true;
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
}
return null;
}
}
+15
View File
@@ -4,14 +4,25 @@ namespace App\BusProNet\XmlLoader;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory;
use App\Exception\HotelNotFoundException;
use League\Flysystem\FilesystemOperator;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class HotelLoader extends AbstractLoader
{
public function __construct(
private readonly CountryCodeMapper $countryCodeMapper,
CacheInterface $cache,
FilesystemOperator $xmlExport,
) {
parent::__construct($cache, $xmlExport);
}
/** @return array<int, Hotel> */
public function loadAll(?string $filename = 'hotel.xml'): array
{
@@ -87,6 +98,10 @@ class HotelLoader extends AbstractLoader
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
// String, never int: see Hotel::$costUnit.
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
return $hotel;
}
+7
View File
@@ -92,6 +92,13 @@ class TravelLoader extends AbstractLoader
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// A travel may reference a hotel that hotel.xml does not carry.
// Reading ->name off the miss would fatal the whole mapping, not
// just this entry, and take /api/travels down with it.
if (false === isset($hotels[$hotelId])) {
return;
}
$mapping[$travelId]['hotels'][$hotelId] = [
'id' => $hotelId,
'code' => $hotelNode->attr('code'),
@@ -8,6 +8,11 @@ use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser
{
public function __construct(
private readonly TravelParser $travelParser,
) {
}
/**
* @throws ResponseParserException
*/
@@ -81,7 +86,7 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('No travel data node found in product data response');
}
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER:
+48 -13
View File
@@ -29,8 +29,20 @@ class TravelParser extends AbstractParser
Constants::PSEUDO_PRICE_ROOM_CODE,
];
/**
* `$levelBoardingPointPrices` carries no default on purpose: a hand-built parser would
* otherwise silently ignore APP_BPN_LEVEL_BOARDING_POINT_PRICES and price one travel tier
* differently from the others. The registry gave up its own default along with it, so that
* there is one way to build this class — through the container, or explicitly in a test.
*
* @param bool $levelBoardingPointPrices Whether to compensate the BusPro export defect that
* omits a boarding point's return-leg price. The
* container wires it from
* APP_BPN_LEVEL_BOARDING_POINT_PRICES.
*/
public function __construct(
private readonly AgeConstraintParserRegistry $ageConstraintRegistry = new AgeConstraintParserRegistry(),
private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
private readonly bool $levelBoardingPointPrices,
) {
}
@@ -87,8 +99,10 @@ class TravelParser extends AbstractParser
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
// Merge drop-off prices into pickups for split pricing support
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
// A boarding point costs the same in both directions
if ($this->levelBoardingPointPrices) {
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
}
$travel->guide = $this->getGuide($node);
@@ -453,22 +467,43 @@ class TravelParser extends AbstractParser
}
/**
* Merges drop-off prices into pickups for split pricing support.
* Levels the price of a boarding point across both travel directions.
*
* For each pickup, finds the matching drop-off by ID and copies
* the inbound price to the pickup's priceInbound property. This enables
* split pricing calculations where costs are distributed between outbound
* and inbound transportation.
* A boarding point is either free or costs the same in both directions:
* `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
* BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
* asymmetric pair is therefore always an export defect — on some products a
* `<zustieg_rueck>` carries no `preis` although the engine still charges the return
* leg — and the stated price is the one that applies to both legs.
*
* Where both directions state a price they agree in practice; the outbound one wins
* to keep the outcome deterministic. Points without a counterpart are left as they are.
*
* Compensating the defect locally is provisional, so it is switchable: with
* `$levelBoardingPointPrices` off the export is reported verbatim and the affected
* travels undercharge the return leg again. See
* `docs/buspro-database-reference.md` section A7.
*
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID (modified in place)
*/
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
private function applyBoardingPointPriceLevelling(array $pickups, array $dropOffs): void
{
foreach ($pickups as $pickupId => $pickup) {
if (isset($dropOffs[$pickupId])) {
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
foreach ($dropOffs as $pickupId => $dropOff) {
if (false === isset($pickups[$pickupId])) {
continue;
}
$pickup = $pickups[$pickupId];
$price = $pickup->price ?? $dropOff->price;
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
$dropOff->price = $price;
$dropOff->priceOutbound = $price;
$dropOff->priceInbound = $price;
}
}
@@ -6,11 +6,13 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -49,7 +51,24 @@ class ChangeAccommodationController extends AbstractController
$selected = $form->get('accommodation')->getData();
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) {
$this->bookingService->changeAccommodation($booking, $selected);
try {
$this->bookingService->changeAccommodation($booking, $selected);
} catch (ImplausibleBookingPriceException $e) {
// The house that was picked prices this stay implausibly high, so the change
// did not happen. Reported in the modal the office is standing in front of —
// the same treatment the create and edit forms give it.
$this->logger->error('Refused to change accommodation over an implausible booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $selected->getCalendarCode(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
$this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.');
@@ -6,12 +6,15 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingCreateType;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +48,7 @@ class CreateController extends AbstractController
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->refreshPriceSnapshot($booking);
if ($form->isSubmitted() && $form->isValid() && $this->refreshPriceSnapshot($form, $booking)) {
$this->entityManager->persist($booking);
$this->entityManager->flush();
@@ -65,4 +67,31 @@ class CreateController extends AbstractController
'form' => $form,
]);
}
/**
* The price is computed after validation, so an implausible total cannot be a constraint on
* the form — it is reported as one anyway, because the office's next move is to go and fix
* the house's prices, not to read an error page.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
{
try {
$this->bookingService->refreshPriceSnapshot($booking);
return true;
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
'paxCount' => $booking->getPaxCount(),
'nights' => $booking->getNights(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return false;
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Copying is its own named action rather than a flag on the edit route the way it is for the
* catalog entities (see Admin\AdditionalService\EditController): a booking carries a lifecycle,
* so the copy has to be reset and written before anybody edits it, and the edit form is built
* from a persisted record's house and service catalog.
*
* Unlike absagen and löschen this has no status guard — the original is not touched, so a
* confirmed or discarded booking may be copied just as well as a draft.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class DuplicateController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/duplicate', name: 'app_admin_accommodationbooking_duplicate')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('duplicate_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$copy = $this->bookingService->duplicate($booking);
$this->addFlash('success', 'Die Kopie wurde als neuer Entwurf angelegt.');
$this->logger->info('Duplicated accommodation booking', [
'id' => $booking->getId(),
'copyId' => $copy->getId(),
'groupName' => $copy->getGroupName(),
]);
// Straight into the copy's edit form — the copy exists to be changed, and that is
// the only reason it was made. The return url is forwarded still encoded, the way
// return_url() handed it over, so "zurück" still leads to the list this started from.
$parameters = ['id' => $copy->getId()];
$returnUrl = $request->query->getString('r');
if ('' !== $returnUrl) {
$parameters['r'] = $returnUrl;
}
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', $parameters));
}
return $this->render('admin/accommodation_booking/modal_duplicate.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'duplicate_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -7,6 +7,7 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
@@ -15,6 +16,8 @@ use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -121,7 +124,15 @@ class EditController extends AbstractController
}
}
$this->bookingService->refreshPriceSnapshot($booking);
if (!$this->refreshPriceSnapshot($form, $booking)) {
// The submitted changes stay on the entity but are never flushed, so the form
// comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
// Editing never moves the booking on and never mails the customer: every status
// change is its own named action (Angebot senden, bestätigen, absagen), so the
@@ -154,4 +165,31 @@ class EditController extends AbstractController
'form' => $form,
]);
}
/**
* The price is computed after validation, so an implausible total cannot be a constraint on
* the form — it is reported as one anyway, because the office's next move is to go and fix
* the house's prices, not to read an error page.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
{
try {
$this->bookingService->refreshPriceSnapshot($booking);
return true;
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
'paxCount' => $booking->getPaxCount(),
'nights' => $booking->getNights(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return false;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Bulk exports, for consumers that mirror our data rather than query it per row.
*
* Deliberately its own resource rather than a verb hung off the collection it exports.
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
* share a namespace with every date code and could only be kept apart from one by a
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
*/
#[Route('/api/exports')]
#[IsGranted('ROLE_OAUTH2_API')]
class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
) {
}
/**
* Every travel date x hotel pair with its pickups resolved, paginated.
*
* The counters in meta describe the whole export, not the page, so they repeat
* unchanged on every page and a consumer can abort after the first one.
*/
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
$total = count($export->rows);
return $this->json([
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
'meta' => [
'page' => $query->page,
'perPage' => $query->perPage,
'total' => $total,
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'generatedAt' => $export->generatedAt->format('c'),
],
]);
}
}
@@ -13,6 +13,7 @@ use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
@@ -66,6 +67,12 @@ class Step2Controller extends AbstractBookingCreateController
// Enrich with fresh availability data
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
// Resync the room selections with the refreshed travel before any participant count is derived
// from them, and send the customer back to step 1 when a room they picked is gone.
if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) {
return $redirect;
}
// Ensure correct number of participants first, then prepopulate the applicant if needed.
if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) {
return $redirect;
@@ -120,6 +127,79 @@ class Step2Controller extends AbstractBookingCreateController
return $this->render('booking/create/step_2.html.twig', $templateData);
}
/**
* Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms.
*
* Deriving the participant count from a room that has disappeared would silently fall back to a
* capacity of one and render too few participants, and a quantity the contingent no longer covers
* reaches the BusPro submit unchecked, because no later step re-reads room availability. The
* selections are resynced and saved before every exit, so step 1 and the neighbouring guard both
* see a collection that matches the rooms the template renders: leaving a stale entry behind would
* fail step 1's own validation on a field it never shows.
*/
private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse
{
$adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto);
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
if ([] === $bookingCreateDto->travel->getAvailableRooms()) {
$this->handleApiError(
$this->logger,
'Travel has no bookable rooms left while a booking is in progress.',
$this->createRoomContext($bookingCreateDto, $adjustments),
'Für diese Reise sind leider keine Zimmer mehr verfügbar.'
);
return $this->redirectToRoute('app_booking_create_error');
}
if ([] === $adjustments) {
return null;
}
$hasDroppedRoom = false;
foreach ($adjustments as $adjustment) {
$hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped();
}
$this->handleApiError(
$this->logger,
'Room selection exceeds what the current travel data still offers.',
$this->createRoomContext($bookingCreateDto, $adjustments),
true === $hasDroppedRoom
? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.'
: 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.'
);
return $this->redirectToRoute('app_booking_create_step_1');
}
/**
* @param list<RoomSelectionAdjustment> $adjustments
*
* @return array<string, mixed>
*/
private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array
{
$rooms = [];
foreach ($adjustments as $adjustment) {
$rooms[] = [
'roomId' => $adjustment->roomId,
'label' => $adjustment->label,
'requestedQuantity' => $adjustment->requestedQuantity,
'bookableQuantity' => $adjustment->bookableQuantity,
];
}
return [
'travelId' => $bookingCreateDto->travel->id,
'travelCode' => $bookingCreateDto->travel->code,
'hotelId' => $bookingCreateDto->hotelId,
'rooms' => $rooms,
];
}
/**
* Rejects a room selection that resolves to no participants at all.
*
@@ -190,7 +270,14 @@ class Step2Controller extends AbstractBookingCreateController
$rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->id];
$room = $rooms[$roomSelection->id] ?? null;
if (null === $room) {
// Unreachable from index(), which reconciles the selections first. Kept so a future
// caller cannot silently fall back to a capacity of one and book too few people.
continue;
}
$participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity;
}
@@ -8,6 +8,7 @@ use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto;
@@ -16,6 +17,7 @@ use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\AccommodationTermsUrlProvider;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -28,6 +30,7 @@ class Step4Controller extends AbstractController
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
private readonly LoggerInterface $logger,
) {
}
@@ -51,7 +54,9 @@ class Step4Controller extends AbstractController
}
$dto->forceInquiry = true;
$this->persistBooking($request, $dto, $accommodation, $services);
if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
return $failure;
}
return $this->redirectToRoute('app_groups_booking_success');
}
@@ -102,7 +107,9 @@ class Step4Controller extends AbstractController
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$this->persistBooking($request, $dto, $accommodation, $services);
if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
return $failure;
}
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
}
@@ -148,12 +155,35 @@ class Step4Controller extends AbstractController
* groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[]
* } $services
*
* @return Response|null a response to return instead of the success redirect, or null when
* the booking was stored
*/
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): ?Response
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
try {
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
} catch (ImplausibleBookingPriceException $e) {
// The bounded inputs are already rejected in step 2, so what is left is a mistyped
// catalog price for this house — nothing the customer can act on and nothing to show
// them an error page over. The session survives so the desk can pick the inquiry up
// by phone with the data still in hand.
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'accommodation' => $accommodation->getCalendarCode(),
'paxCount' => $dto->paxCount,
'nights' => $dto->getNights(),
]);
$this->addFlash('error', 'Die Anfrage konnte nicht abgeschlossen werden. Bitte wende dich direkt an uns — wir kümmern uns darum.');
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_error'));
}
$this->addFlash('groups_booking_result', $booking->getOrigin()->value);
$this->sessionManager->clear($request);
return null;
}
}
+53 -12
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -21,13 +22,28 @@ class SecurityController extends AbstractController
Request $request,
BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager,
): Response {
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
// Read before the booking flow is decided, because it overrules it.
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// Check if this is a booking flow (BookingDto exists in session)
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
$isBookingFlow = null !== $bookingDto;
$hasPendingBooking = null !== $bookingDto;
// A pending authorization wins over a half-finished booking. Otherwise the target path
// below is overwritten with booking step 1, the user is sent there after logging in, and
// the application that asked for the authorization never receives its code.
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
// If authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $isBookingFlow) {
if (null !== $this->getUser() && true === $hasPendingBooking) {
return $this->redirectToRoute('app_booking_create_step_1');
}
@@ -39,21 +55,18 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = null !== $targetPath && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
if (true === $isBookingFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
}
// Render booking login template if in booking flow, otherwise standard login
$template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig';
// Render booking login template if in booking flow, the self-explaining one when another
// application sent them here to authorize it, otherwise standard login
$template = match (true) {
$isBookingFlow => 'booking/create/authenticate.html.twig',
$isOauth2 => 'security/login_oauth2.html.twig',
default => 'security/login.html.twig',
};
// Fetch CMS data for booking flow (uses cache warmed in IndexController)
$cmsData = null;
@@ -72,9 +85,37 @@ class SecurityController extends AbstractController
'travel_date_to' => $bookingDto?->travel->dateTo,
'cmsData' => $cmsData,
'oauth2' => $isOauth2,
'client_name' => $isOauth2 ? $this->authorizingClientName($clientManager, $targetPath) : null,
]);
}
/**
* The display name of the application that sent the user here to authorize it, so the login page
* can name it instead of leaving the user wondering why the site changed.
*
* Derived from the pending target path on every render rather than stashed on the session: a
* failed login redirects back to this route and the target path survives, so the name comes back
* with it. Returns null when the client cannot be resolved, and the page falls back to neutral
* wording rather than failing -- this runs before authentication.
*/
private function authorizingClientName(ClientManagerInterface $clientManager, ?string $targetPath): ?string
{
$query = null !== $targetPath ? parse_url($targetPath, \PHP_URL_QUERY) : null;
if (!\is_string($query) || '' === $query) {
return null;
}
parse_str($query, $params);
$clientId = $params['client_id'] ?? null;
if (!\is_string($clientId) || '' === $clientId) {
return null;
}
$name = $clientManager->find($clientId)?->getName() ?? '';
return '' !== $name ? $name : null;
}
#[Route('/logout', name: 'app_logout')]
#[IsGranted('ROLE_USER')]
public function logout(): void
+79 -1
View File
@@ -12,11 +12,13 @@ use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
@@ -24,6 +26,24 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
use BlameableEntity;
use TimestampableEntity;
/**
* Plausibility ceilings for the two figures every price in the breakdown is multiplied by.
* Both sit above anything the group houses actually see — the largest booking on record is
* 150 people for 18 nights — and exist so that a mistyped year or pax count is answered with
* a form error rather than a price nobody meant.
*/
public const int MAX_PAX = 250;
public const int MAX_NIGHTS = 30;
/**
* And the ceiling on the product of the two, in minor units: a million euros against a
* largest-ever booking of some 42,000. The inputs above cannot reach it, so what it catches
* is a mistyped catalog price, the one factor of the total that is not bounded per booking.
* Deliberately well inside what the INT column holds — the column's range is not a price
* limit, and a total that needs more room is a mistake rather than a wider column.
*/
public const int MAX_TOTAL_PRICE = 100_000_000;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -40,9 +60,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Abreisedatum muss nach dem Anreisedatum liegen.')]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column]
#[Assert\Range(min: 0, max: self::MAX_PAX)]
private int $paxCount = 0;
#[ORM\Column]
@@ -68,6 +90,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $priceBreakdown = null;
/** In minor units, like every other price here, and never above self::MAX_TOTAL_PRICE. */
#[ORM\Column(nullable: true)]
private ?int $totalPrice = null;
@@ -177,6 +200,34 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
$this->uuid = Uuid::v4()->toRfc4122();
}
/**
* A copy is a new record, not a second view of the original: it gets its own identity and
* its own access link token — sharing that token would put two records behind one customer
* link — and every mark the lifecycle left is wiped, so the copy starts as an Entwurf that
* has never been in front of a customer. The origin is rewritten too: however the original
* came about, the copy comes about as an offer the office prepared, which is what keeps the
* field truthful.
*
* The price snapshot, the service snapshots and all four discounts are deliberately carried
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
* the same numbers as the record it came from rather than a re-quote at today's catalog
* prices; saving it through the edit form recomputes it.
*/
public function __clone()
{
$this->id = null;
$this->uuid = Uuid::v4()->toRfc4122();
$this->status = AccommodationBookingStatus::Draft;
$this->origin = AccommodationBookingOrigin::Offer;
$this->accessLinkIssuedAt = null;
$this->acceptedAt = null;
$this->confirmedAt = null;
$this->createdAt = null;
$this->updatedAt = null;
$this->createdBy = null;
$this->updatedBy = null;
}
public function getId(): ?int
{
return $this->id;
@@ -232,6 +283,25 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->dateFrom->diff($this->dateTo)->days;
}
/**
* Reported on dateTo, the field a mistyped year is corrected in — a stay of 200 years is
* always the departure date being wrong, never the arrival.
*/
#[Assert\Callback]
public function validateStayLength(ExecutionContextInterface $context): void
{
if (null === $this->dateFrom || null === $this->dateTo) {
return;
}
if ($this->getNights() > self::MAX_NIGHTS) {
$context->buildViolation('Der Aufenthalt darf höchstens {{ max }} Nächte umfassen — bitte Anreise und Abreise prüfen.')
->setParameter('{{ max }}', (string) self::MAX_NIGHTS)
->atPath('dateTo')
->addViolation();
}
}
public function getPaxCount(): int
{
return $this->paxCount;
@@ -336,9 +406,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->priceBreakdown;
}
/** @param array<string, mixed> $priceBreakdown */
/**
* @param array<string, mixed> $priceBreakdown
*
* @throws ImplausibleBookingPriceException if the total is too large to be a real price
*/
public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self
{
if ($totalPrice > self::MAX_TOTAL_PRICE) {
throw new ImplausibleBookingPriceException($totalPrice, $this->paxCount, $this->getNights());
}
$this->priceBreakdown = $priceBreakdown;
$this->totalPrice = $totalPrice;
$this->pricingCurrency = $currency;
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Entity\Groups\AccommodationBooking;
/**
* Thrown when a computed accommodation booking total is too large to be a real price.
*
* The total is a product of pax count, nights and catalog prices, so a single mistyped figure
* anywhere in that chain can produce a number nobody could owe. Storing it is pointless — it
* would go out on an offer — and a signed INT column answers it with SQLSTATE 22003, which says
* nothing about where the mistake is. Failing here instead names the price and the inputs it
* came from.
*/
class ImplausibleBookingPriceException extends \RuntimeException
{
/**
* What the backoffice is told. The total itself is in the log; the actionable part is the
* house's price rows, which is where the stray digit is.
*/
public const string FORM_ERROR_MESSAGE = 'Der berechnete Gesamtpreis ist unplausibel hoch. Bitte die Preise und Leistungen des Gruppenhauses prüfen.';
public function __construct(int $totalPrice, int $paxCount, int $nights, ?\Throwable $previous = null)
{
parent::__construct(
sprintf(
'Refusing to store an implausible booking total of %d (ceiling %d, in minor units) for %d people over %d nights — check the catalog prices for this house.',
$totalPrice,
AccommodationBooking::MAX_TOTAL_PRICE,
$paxCount,
$nights,
),
0,
$previous,
);
}
}
+7 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Validator\Constraints as Assert;
class AccommodationBookingDto
@@ -16,13 +17,16 @@ class AccommodationBookingDto
public ?\DateTimeImmutable $dateTo = null;
#[Assert\Positive(message: 'invalid', groups: ['step_3'])]
// step_2 is where the customer types these, so that is the group that has to carry the
// bounds: the price is computed from them on the same screen, and an implausible figure
// reaching step 4 only surfaces as a failure to store the booking.
#[Assert\Range(min: 1, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $paxCount = 1;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
#[Assert\Range(min: 0, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $minorsCount = 0;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
#[Assert\Range(min: 0, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $childrenCount = 0;
#[Assert\NotNull(message: 'required', groups: ['step_2'])]
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* A room selection the current travel data no longer supports in full.
*
* A room that disappeared altogether is the same case with nothing left to book, so it is reported
* as an adjustment down to zero rather than as a second kind of result.
*/
final readonly class RoomSelectionAdjustment
{
public function __construct(
public int $roomId,
public ?string $label,
public int $requestedQuantity,
public int $bookableQuantity,
) {
}
/**
* Whether the room is gone entirely rather than merely reduced.
*/
public function isDropped(): bool
{
return 0 === $this->bookableQuantity;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
*/
final readonly class TravelExport
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
) {
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
{
public function __construct(
#[Assert\Positive]
public int $page = 1,
#[Assert\Range(min: 1, max: 1000)]
public int $perPage = 500,
) {
}
}
+49 -3
View File
@@ -13,6 +13,7 @@ use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams;
@@ -79,7 +80,8 @@ class AccommodationBookingService
/**
* Parses and validates raw date strings, then sets them on the DTO.
*
* @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom
* @throws \InvalidArgumentException if dates are invalid, dateTo ≤ dateFrom, or the stay
* exceeds AccommodationBooking::MAX_NIGHTS
*/
public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
{
@@ -94,6 +96,10 @@ class AccommodationBookingService
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.');
}
if ($dateFrom->diff($dateTo)->days > AccommodationBooking::MAX_NIGHTS) {
throw new \InvalidArgumentException(sprintf('Der Aufenthalt darf höchstens %d Nächte umfassen.', AccommodationBooking::MAX_NIGHTS));
}
$dto->dateFrom = $dateFrom->setTime(0, 0, 0);
$dto->dateTo = $dateTo->setTime(0, 0, 0);
}
@@ -566,7 +572,37 @@ class AccommodationBookingService
* their access link, and swapping the house underneath them would rewrite what they were
* offered.
* Idempotent — a no-op for a booking that is not a draft or already sits at $accommodation.
*
* @throws ImplausibleBookingPriceException if the new house prices the stay implausibly high;
* the booking is left at its old house
*/
/**
* Copies a booking into a fresh Entwurf, so the office can build a variation of an offer
* instead of keying the whole thing in again. Everything the customer was quoted comes
* along verbatim — house, dates, pax, the board and additional service snapshots, the
* discounts and the stored price breakdown — while AccommodationBooking::__clone() resets
* the identity and the lifecycle.
*
* Allowed from any status, including Bestätigt and Absage: nothing on the original is
* touched, and "same group, next season" is exactly the case this exists for. The price is
* deliberately not recomputed, see __clone().
*/
public function duplicate(AccommodationBooking $booking): AccommodationBooking
{
$copy = clone $booking;
// Marks the copy where the office looks for it — the list is sorted and scanned by
// group name, and two identical rows are otherwise impossible to tell apart.
if (null !== $copy->getGroupName()) {
$copy->setGroupName($copy->getGroupName().' (Kopie)');
}
$this->entityManager->persist($copy);
$this->entityManager->flush();
return $copy;
}
public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void
{
if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) {
@@ -580,8 +616,18 @@ class AccommodationBookingService
$booking->setBoardServiceOriginalId(null);
$booking->setAdditionalServices([]);
// Clears the snapshot by itself when the new house has no price for the booked range.
$this->refreshPriceSnapshot($booking);
try {
// Clears the snapshot by itself when the new house has no price for the booked range.
$this->refreshPriceSnapshot($booking);
} catch (ImplausibleBookingPriceException $e) {
// The swap and the dropped services are already on the managed entity, and the next
// flush in this request would write them without a price. Reloading the row puts the
// booking back at its old house so the caller can report the refusal on a record that
// still matches the database.
$this->entityManager->refresh($booking);
throw $e;
}
$this->entityManager->flush();
}
+49
View File
@@ -15,6 +15,7 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Model\RoomSelectionAdjustment;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -91,6 +92,54 @@ class BookingConfigurator
return $bookingCreateDto;
}
/**
* Brings the room selections back in line with the rooms the travel currently offers.
*
* Room selections are built once when the booking session starts, while the Travel is re-fetched
* from BusPro on every request. A room that sells out or leaves a bookable status in between drops
* out of getAvailableRooms() and leaves a selection behind that points at a room that no longer
* exists; one that only loses part of its contingent leaves a quantity nothing else re-checks
* before the booking is submitted; and one that becomes bookable again has no selection at all.
* Rebuilding the selections from the current rooms, with the chosen quantities carried over and
* capped at what is left, keeps both sides in step.
*
* @return list<RoomSelectionAdjustment> The selections that had to give up quantity, in selection order
*/
public function reconcileRoomSelections(BookingDto $bookingDto): array
{
$availableRooms = $bookingDto->travel->getAvailableRooms();
$adjustments = [];
$quantitiesByRoomId = [];
foreach ($bookingDto->roomSelections as $roomSelection) {
if (null === $roomSelection->id) {
continue;
}
$requestedQuantity = $roomSelection->quantity ?? 0;
$bookableQuantity = min($requestedQuantity, $availableRooms[$roomSelection->id]->available ?? 0);
$quantitiesByRoomId[$roomSelection->id] = $bookableQuantity;
if (0 < $requestedQuantity && $bookableQuantity < $requestedQuantity) {
$adjustments[] = new RoomSelectionAdjustment(
$roomSelection->id,
$roomSelection->label,
$requestedQuantity,
$bookableQuantity
);
}
}
$bookingDto->roomSelections = array_map(
fn (Room $room) => $this->createRoomSelection($room, $quantitiesByRoomId),
$availableRooms
);
return $adjustments;
}
/**
* Creates a room selection DTO from room data and quantities.
*
+5 -2
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -161,7 +162,9 @@ class BookingPricingAssembler
}
$hasOutboundBus = null !== $participant->transportationOutbound
&& 'BUS' === $participant->transportationOutbound->subType;
&& DirectionMapper::isBus($participant->transportationOutbound->subType);
$hasInboundBus = null !== $participant->transportationInbound
&& DirectionMapper::isBus($participant->transportationInbound->subType);
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
if ($participant->pickup->price < 0) {
@@ -171,7 +174,7 @@ class BookingPricingAssembler
}
}
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
if ($participant->dropOff->price < 0) {
$participantTransportationDiscountCost += $participant->dropOff->price;
} else {
+6 -6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -193,18 +194,17 @@ class ParticipantPricingCalculator
}
}
// Pickup pricing - only charged when outbound transportation is BUS
// This matches current BusPro behavior where pickup price is only applied for outbound bus
// Note: priceOutbound/priceInbound are populated for future split pricing support when BusPro is updated
// Boarding surcharges are charged per leg, at the same rate in both directions
$hasOutboundBus = null !== $participant->transportationOutbound
&& 'BUS' === $participant->transportationOutbound->subType;
&& DirectionMapper::isBus($participant->transportationOutbound->subType);
$hasInboundBus = null !== $participant->transportationInbound
&& DirectionMapper::isBus($participant->transportationInbound->subType);
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
$serviceTotal += $participant->pickup->price;
}
// Drop-off pricing (charged when inbound transportation is BUS and a drop-off is selected)
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
$serviceTotal += $participant->dropOff->price;
}
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
*
* Built from the XML file map rather than the travel_snapshot table. The table sits at
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
*
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
*/
class TravelExportBuilder
{
use TypeConversionTrait;
private const CACHE_KEY = 'bpn_travels_export';
public function __construct(
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly TravelParser $travelParser,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): TravelExport
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
return $this->assemble();
});
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
}
}
private function assemble(): TravelExport
{
$hotels = $this->hotelLoader->loadAll();
$pickupMasterData = $this->pickupLoader->loadAll();
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
$rows = [];
$skipped = 0;
/** @var array<int, true> $pickupsWithoutMasterData */
$pickupsWithoutMasterData = [];
$files = $this
->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
foreach ($files as $file) {
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
$crawler->filterXPath('//reisen/reise/termin')->each(
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
}
);
}
return new TravelExport(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
);
}
/**
* @param array<string, array<string, mixed>> $rows
* @param array<int, true> $pickupsWithoutMasterData
* @param array<int, Hotel> $hotels
* @param array<int|string, Pickup> $pickupMasterData
*/
private function collectTravel(
Crawler $node,
array &$rows,
int &$skipped,
array &$pickupsWithoutMasterData,
array $hotels,
array $pickupMasterData,
): void {
$hotelNodes = $node->filterXPath('.//hotel');
// Dates first: getPickups() needs a non-null default date for the time-only zeit
// shape, and parsing them up front is also what turns an unusable date into a
// dropped row rather than a row with a null date the consumer cannot key on.
$dateFrom = $this->stringToDate($node->attr('termin'));
$dateTo = $this->stringToDate($node->attr('bis'));
if (null === $dateFrom || null === $dateTo) {
$this->logger->warning('Travel export: unparseable travel date', [
'travel_id' => $node->attr('idbuspro'),
'termin' => $node->attr('termin'),
'bis' => $node->attr('bis'),
]);
$skipped += $hotelNodes->count();
return;
}
$travelId = (int) $node->attr('idbuspro');
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
// calendar day. Resolved once per travel date, then shared by its hotel rows.
$pickups = $this->buildPickups(
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
$pickupMasterData,
$pickupsWithoutMasterData,
);
$travel = [
'busProId' => $travelId,
'code' => $node->attr('code'),
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
];
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// Defensive: no travel in the current snapshot references a hotel that
// hotel.xml does not carry. A row without master data would reach the consumer
// with no name and no country, so it is dropped and counted instead.
if (false === isset($hotels[$hotelId])) {
$this->logger->warning('Travel export: hotel missing from master data', [
'travel_id' => $travelId,
'hotel_id' => $hotelId,
]);
++$skipped;
return;
}
$hotel = $hotels[$hotelId];
// Keyed by the pair the consumer upserts on, so a date repeated across files
// collapses last-wins instead of reaching it as a duplicate.
$rows[$travelId.'-'.$hotelId] = $travel + [
'hotel' => [
'busProId' => $hotelId,
'code' => $hotel->code,
'name' => $hotel->name,
'costUnit' => $hotel->costUnit,
'country' => $hotel->country,
'countryCode' => $hotel->countryCode,
],
'pickups' => $pickups,
];
});
}
/**
* Joins the travel XML's pickups against zustiege.xml.
*
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
* contract is source order, which is the order the disposition lists them in. Nor
* enrichPickupDetails(), which is private and passes over a miss silently.
*
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
* @return list<array<string, mixed>>
*/
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
foreach ($pickups as $pickup) {
$pickupId = $pickup->id;
// Defensive: none exist today. An entry with no id is not addressable by the
// consumer, so drop the entry -- never the row it belongs to.
if (null === $pickupId || 0 === $pickupId) {
continue;
}
$details = $masterData[$pickupId] ?? null;
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
// entry is emitted anyway: dropping it would hide a real departure point from
// the disposition UI, where a nameless stop is still better than a missing one.
if (null === $details) {
$pickupsWithoutMasterData[$pickupId] = true;
}
$resolved[] = [
'busProId' => $pickupId,
'code' => $details?->code,
'city' => $details?->city,
'street' => $details?->street,
'time' => $pickup->time?->format('H:i'),
// Emitted for time-only sources too: adjustOvernightTimes() has already
// worked out the real day, and a null here would throw that away.
'datetime' => $pickup->time?->format('c'),
];
}
return $resolved;
}
private function getStringOrNullValue(Crawler $node): ?string
{
return 0 < $node->count() ? $node->text() : null;
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
+1 -13
View File
@@ -1,18 +1,6 @@
<div class="flex items-center justify-between">
<a href="{{ path('app_account') }}" title="zu meinem Account">
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #}
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
{% if country_logo %}
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
{% elseif booking_theme() == 'sbw' %}
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'ser' %}
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'snz' %}
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
{% else %}
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
{% endif %}
{% include '_partials/_logo.html.twig' %}
</a>
<div class="flex items-center space-x-2">
{% if is_granted('ROLE_USER') %}
+47
View File
@@ -0,0 +1,47 @@
{# The login form itself, shared by security/login.html.twig and security/login_oauth2.html.twig so
the field names, the CSRF token id and the error rendering cannot drift between the two pages.
Expects `error` and `last_username` from the including template's context. #}
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post">
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<div class="relative">
<input type="password"
id="password"
name="_password"
class="form-field"
{{ stimulus_target('password-reveal', 'field') }}
required
autocomplete="current-password">
<button type="button"
{{ stimulus_action('password-reveal', 'toggle') }}
class="absolute top-0 right-0 mt-2 mr-2">
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
<button type="submit" class="button button--primary">
Login
</button>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
+15
View File
@@ -0,0 +1,15 @@
{# The brand logo, extracted from _header.html.twig so the OAuth2 login chrome can show the same
logo without inheriting the account links that go with it. Takes an optional `accommodation`. #}
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #}
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
{% if country_logo %}
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
{% elseif booking_theme() == 'sbw' %}
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'ser' %}
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'snz' %}
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
{% else %}
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
{% endif %}
@@ -97,6 +97,11 @@
<twig:dropdown:link url="{{ edit_url }}">
bearbeiten
</twig:dropdown:link>
{# Copying leaves the original alone, so it is offered in every
status — see DuplicateController. #}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_duplicate', { 'id': booking.id, 'r': return_url() }) }}">
duplizieren
</twig:dropdown:hxbutton>
{% if booking.confirmed %}
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
PDF herunterladen
@@ -0,0 +1,15 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}{{ booking.recordLabel }} kopieren{% endblock %}
{% block content %}
<div>
Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für
<em>{{ booking.groupName }}</em> kopieren?
Die Kopie übernimmt Gruppenhaus, Zeitraum, Personen, Leistungen, Rabatte und den
berechneten Preis unverändert und wird als neuer Entwurf angelegt. Das Original bleibt
unverändert, und die Kopie bekommt einen eigenen Zugangslink.
</div>
{% endblock %}
{% block button_confirm %}kopieren{% endblock %}
@@ -291,6 +291,13 @@
{% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
hx-target="body"
hx-swap="beforeend">
duplizieren
</button>
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
bearbeiten
</a>
+14
View File
@@ -0,0 +1,14 @@
{# Chrome for a login that is serving an external OAuth2 authorization.
Only the logo survives from the normal header: the account and logout links are dead ends in the
middle of an authorization, and the marketing column of the standard login page is noise when the
user did not come here to read about MyE&P. The footer's legal links stay -- this page collects
credentials, so dropping them would be the wrong direction.
The background is inherited unchanged, which is why the shared form partial's `text-white`
styling still works here. #}
{% extends 'layout.html.twig' %}
{% block header %}
{% include '_partials/_logo.html.twig' %}
{% endblock %}
+1 -44
View File
@@ -7,50 +7,7 @@
Login<br>MyE&amp;P
</h1>
{% include '_partials/_flashes.html.twig' %}
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post" hx-history="false">
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<div class="relative">
<input type="password"
id="password"
name="_password"
class="form-field"
{{ stimulus_target('password-reveal', 'field') }}
required
autocomplete="current-password">
<button type="button"
{{ stimulus_action('password-reveal', 'toggle') }}
class="absolute top-0 right-0 mt-2 mr-2">
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
<button type="submit" class="button button--primary">
Login
</button>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
{% include '_partials/_login_form.html.twig' %}
<div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}"
class="text-white underline">
+37
View File
@@ -0,0 +1,37 @@
{# The login page as seen by someone who was sent here by another E&P application (ephub, myep-team)
to authorize it. Its job is to explain why they are suddenly on a different website and that they
will be sent back, which the standard login page never said.
The grid is the one from security/login.html.twig, minus its marketing column: that keeps the
form column at exactly the width it has on the standard login page. #}
{% extends 'layout_oauth2.html.twig' %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
{% if client_name %}
Anmelden für<br>{{ client_name }}
{% else %}
Login<br>MyE&amp;P
{% endif %}
</h1>
<p class="mb-6 text-white">
Melde dich mit deinem MyE&amp;P-Konto an. Anschließend wirst du automatisch
{% if client_name %}zu {{ client_name }} {% endif %}zurückgeleitet.
</p>
{% include '_partials/_login_form.html.twig' %}
<div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}" class="text-white underline">
Passwort vergessen?
</a>
<a href="{{ path('app_registration') }}" class="text-white underline">
Jetzt registrieren
</a>
</div>
</div>
</div>
{% endblock %}
@@ -3,9 +3,11 @@
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
@@ -34,7 +36,7 @@ class HotelDataLoaderTest extends TestCase
->willReturn($xmlContent);
$cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new HotelLoader($cache, $filesystem);
$loader = new HotelLoader(new CountryCodeMapper(new NullLogger()), $cache, $filesystem);
$hotels = $loader->loadAll();
@@ -50,5 +52,7 @@ class HotelDataLoaderTest extends TestCase
$this->assertEquals('F', $hotel->country);
$this->assertEquals('Rue des Lacs', $hotel->street);
$this->assertEquals('', $hotel->phone);
$this->assertEquals('fr', $hotel->countryCode);
$this->assertNull($hotel->costUnit);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\Utility;
use App\BusProNet\Utility\CountryCodeMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
class CountryCodeMapperTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function tokenProvider(): array
{
return [
'iso-2 passes through' => ['CH', 'ch'],
'vehicle code D' => ['D', 'de'],
'iso-3 DEU' => ['DEU', 'de'],
'vehicle code A' => ['A', 'at'],
'iso-2 AT' => ['AT', 'at'],
'iso-3 AUT' => ['AUT', 'at'],
'vehicle code F' => ['F', 'fr'],
'vehicle code I' => ['I', 'it'],
'iso-2 IT' => ['IT', 'it'],
'vehicle code E' => ['E', 'es'],
'vehicle code B' => ['B', 'be'],
'vehicle code S is Sweden' => ['S', 'se'],
'vehicle code N is Norway' => ['N', 'no'],
'vehicle code FL is Liechtenstein' => ['FL', 'li'],
'IR is Ireland, not Iran' => ['IR', 'ie'],
'UK maps to the iso GB' => ['UK', 'gb'],
'iso-3 PRT' => ['PRT', 'pt'],
'iso-3 GRE' => ['GRE', 'gr'],
'iso-3 HRV' => ['HRV', 'hr'],
'iso-3 RUS' => ['RUS', 'ru'],
'iso-3 USA' => ['USA', 'us'],
'SI is Slovenia' => ['SI', 'si'],
'SLO is Slovakia' => ['SLO', 'sk'],
'ZA is South Africa' => ['ZA', 'za'],
];
}
#[DataProvider('tokenProvider')]
public function testMapsKnownToken(string $token, string $expected): void
{
$this->assertSame($expected, (new CountryCodeMapper(new NullLogger()))->map($token));
}
public function testEveryMappedCodeIsLowercaseIsoTwo(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
foreach (array_keys(self::tokenProvider()) as $name) {
[$token] = self::tokenProvider()[$name];
$code = $mapper->map($token);
$this->assertNotNull($code);
$this->assertMatchesRegularExpression('/^[a-z]{2}$/', $code, $token.' must map to lowercase ISO-2');
}
}
public function testIsCaseAndWhitespaceInsensitive(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
$this->assertSame('ch', $mapper->map('ch'));
$this->assertSame('at', $mapper->map(' aut '));
}
public function testUnknownTokenMapsToNullAndWarnsOnce(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->once())
->method('warning')
->with('Unmapped BusPro country token', ['token' => 'XX']);
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map('XX'));
$this->assertNull($mapper->map('XX'));
}
public function testAbsentTokenMapsToNullWithoutWarning(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->never())
->method('warning');
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map(null));
$this->assertNull($mapper->map(''));
$this->assertNull($mapper->map(' '));
}
}
+65 -1
View File
@@ -5,10 +5,14 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\DomCrawler\Crawler;
class HotelLoaderTest extends TestCase
{
@@ -19,7 +23,7 @@ class HotelLoaderTest extends TestCase
->expects($this->never())
->method('read');
$loader = new HotelLoader(new ArrayAdapter(), $filesystem);
$loader = $this->createLoader($filesystem);
$travel = new Travel();
$travel->hotelId = null;
$travel->hotel = null;
@@ -28,4 +32,64 @@ class HotelLoaderTest extends TestCase
$this->assertNull($travel->hotel);
}
public function testParseXmlMapsCostUnitAndCountryCode(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode(
'<kostenstelle>83</kostenstelle><land>CH</land>'
));
$this->assertSame('83', $hotel->costUnit);
$this->assertSame('CH', $hotel->country, 'the raw token is the join key and must not be rewritten');
$this->assertSame('ch', $hotel->countryCode);
}
public function testParseXmlKeepsAListOfCostUnitsAsAString(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode(
'<kostenstelle>84 85</kostenstelle><land>D</land>'
));
$this->assertSame('84 85', $hotel->costUnit);
}
public function testParseXmlLeavesCostUnitNullWhenAbsent(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>D</land>'));
$this->assertNull($hotel->costUnit);
$this->assertSame('de', $hotel->countryCode);
}
public function testParseXmlLeavesBothCountryFieldsNullWhenLandIsAbsent(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<name>Ohne Land</name>'));
$this->assertNull($hotel->country);
$this->assertNull($hotel->countryCode);
}
public function testParseXmlLeavesCountryCodeNullForAnUnmappableToken(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>XX</land>'));
$this->assertSame('XX', $hotel->country);
$this->assertNull($hotel->countryCode, 'an unknown token must never be guessed into a code');
}
private function hotelNode(string $children): Crawler
{
$xml = sprintf('<hotels><hotel idbuspro="1" code="TEST">%s</hotel></hotels>', $children);
return XmlCrawlerFactory::create($xml)->filterXPath('//hotels/hotel');
}
private function createLoader(?FilesystemOperator $filesystem = null): HotelLoader
{
return new HotelLoader(
new CountryCodeMapper(new NullLogger()),
new ArrayAdapter(),
$filesystem ?? $this->createStub(FilesystemOperator::class),
);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Model\Hotel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class TravelLoaderFilesMapTest extends TestCase
{
private const XML = '<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<termin idbuspro="900" code="SBW250101" termin="01.01.2025" bis="08.01.2025">
<text>Skiwoche</text>
<hotel idbuspro="1" code="KNOWN"/>
<hotel idbuspro="999" code="GHOST"/>
</termin>
</reise>
</reisen>';
/**
* A travel may reference a hotel id that hotel.xml does not carry. Before the guard the
* miss was dereferenced, which fataled the whole cached mapping rather than one entry --
* so /api/travels went down entirely, not just the affected travel.
*/
public function testGenerateFilesMapSkipsHotelsMissingFromMasterData(): void
{
$known = new Hotel();
$known->id = 1;
$known->name = 'Hotel Bekannt';
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn([1 => $known]);
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(
new DirectoryListing([new FileAttributes('Ziel_1.xml')])
);
$filesystem->method('read')->willReturn(self::XML);
$loader = new TravelLoader(
$hotelLoader,
$this->createStub(TravelParser::class),
'https://example.test',
new TagAwareAdapter(new ArrayAdapter()),
$filesystem,
);
$mapping = $loader->generateFilesMap();
$this->assertArrayHasKey(900, $mapping);
$this->assertSame([1], array_keys($mapping[900]['hotels']));
$this->assertSame('Hotel Bekannt', $mapping[900]['hotels'][1]['label']);
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\BusProNet\XmlParser\TravelParser;
use PHPUnit\Framework\TestCase;
/**
@@ -22,7 +23,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response (0 bytes)');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
}
public function testTruncatedResponseNamesTheLibxmlReason(): void
@@ -30,7 +31,7 @@ class ApiResponseParserFailureTest extends TestCase
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) {
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
@@ -45,7 +46,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
}
public function testUnknownCustomerDataSubtype(): void
@@ -55,7 +56,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
$this->createParser()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
}
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
@@ -64,7 +65,7 @@ class ApiResponseParserFailureTest extends TestCase
try {
try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
} catch (ResponseParserException) {
}
@@ -74,4 +75,12 @@ class ApiResponseParserFailureTest extends TestCase
libxml_use_internal_errors($previous);
}
}
/**
* None of these responses reach the travel parser; a stub keeps the dependency out of view.
*/
private function createParser(): ApiResponseParser
{
return new ApiResponseParser($this->createStub(TravelParser::class));
}
}
+96 -1
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -15,7 +17,7 @@ class TravelParserTest extends TestCase
protected function setUp(): void
{
$this->parser = new TravelParser();
$this->parser = $this->createParser();
}
public function testParseServiceDescriptions(): void
@@ -355,6 +357,99 @@ class TravelParserTest extends TestCase
$this->assertNull($travel->status); // No status_hin node should result in null
}
public function testDropOffsInheritThePickupPriceWhenTheReturnLegStatesNone(): void
{
// Shape of product 701: the return boarding points carry no preis although
// BusPro bills an Abfahrtsortzuschlag for the return leg as well.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />
<zustieg idbuspro="27" zeit="03:45" />',
'<zustieg_rueck idbuspro="18" />
<zustieg_rueck idbuspro="27" />'
);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
$this->assertSame(17.9, $travel->dropOffs[18]->priceInbound);
$this->assertSame(17.9, $travel->pickups[18]->priceInbound);
// A point that is free outbound stays free on the return leg
$this->assertNull($travel->dropOffs[27]->price);
$this->assertNull($travel->pickups[27]->priceInbound);
}
public function testPickupInheritsTheDropOffPriceWhenTheOutboundLegStatesNone(): void
{
// The mirror image of the product 701 defect: an asymmetric pair is a defect
// whichever leg is missing, so the stated price applies to both.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" />',
'<zustieg_rueck idbuspro="18" preis="17,90" />'
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertSame(17.9, $travel->pickups[18]->priceOutbound);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
}
public function testConflictingBoardingPricesResolveToTheOutboundOne(): void
{
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="18" preis="9,90" />'
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
}
public function testDropOffWithoutAPickupCounterpartKeepsItsOwnPrice(): void
{
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="99" preis="5,90" />'
);
$this->assertSame(5.9, $travel->dropOffs[99]->price);
}
public function testBoardingPricesAreReportedVerbatimWhenLevellingIsDisabled(): void
{
// The levelling compensates a vendor export defect and is therefore switchable.
// With it off the product 701 shape undercharges the return leg again.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="18" />',
$this->createParser(levelBoardingPointPrices: false)
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertNull($travel->pickups[18]->priceInbound);
$this->assertNull($travel->dropOffs[18]->price);
}
private function createParser(bool $levelBoardingPointPrices = true): TravelParser
{
return new TravelParser(new AgeConstraintParserRegistry(), $levelBoardingPointPrices);
}
private function parseTravelWithBoardingPoints(string $pickups, string $dropOffs, ?TravelParser $parser = null): Travel
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise id="1" idbuspro="701" code="MVKMP" erstellt_am="28.09.2026 12:29:00">
<termin id="1" idbuspro="12939" idprodukt="701" termin="13.12.2026" bis="18.12.2026" code="MVKMP131226" reiseart="F">
<text>Test Travel</text>
<zustiege>'.$pickups.'</zustiege>
<zustiege_rueck>'.$dropOffs.'</zustiege_rueck>
</termin>
</reise>
</reisen>';
$crawler = XmlCrawlerFactory::create($xmlContent);
return ($parser ?? $this->parser)->parse($crawler->filterXPath('//reise/termin')->first());
}
public function testGetPickupsAdjustsOvernightTimes(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
@@ -8,6 +8,7 @@ use App\Controller\Admin\AccommodationBooking\ChangeAccommodationController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -87,6 +88,36 @@ class ChangeAccommodationControllerTest extends TestCase
self::assertSame('success', $controller->flashes[0]['type']);
}
public function testAHouseThatPricesImplausiblyIsReportedInTheModal(): void
{
// The service leaves the booking at its old house and rethrows; the office has to see why
// the change did not happen, on the modal it just submitted, not as an error page.
$booking = $this->draft();
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('changeAccommodation')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$field = $this->createStub(FormInterface::class);
$field->method('getData')->willReturn(new Accommodation());
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('get')->with('accommodation')->willReturn($field);
$form->expects(self::once())->method('addError');
$controller = $this->buildController($bookingService, $form);
$response = $controller->index($booking, $this->request('POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'), 'the modal stays open on the error');
self::assertSame('admin/accommodation_booking/modal_change_accommodation.html.twig', $controller->renderedView);
self::assertSame([], $controller->flashes, 'nothing was changed, so nothing is reported as success');
}
public function testPickingTheSameHouseIsNotReportedAsAChange(): void
{
$booking = $this->draft();
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\CreateController;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
@@ -32,6 +33,37 @@ class CreateControllerTest extends TestCase
self::assertSame($user, $controller->capturedBooking?->getManagedBy());
}
public function testAnImplausiblePriceComesBackAsAFormErrorWithoutSaving(): void
{
// Same as on the edit form: the fix is in the house's price rows, so the office is told
// on the form it is standing in front of, and nothing is written.
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('refreshPriceSnapshot')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->expects(self::once())->method('addError');
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::never())->method('flush');
$controller = new TestableCreateController(
$entityManager,
$this->createStub(LoggerInterface::class),
$bookingService,
$form,
new User('[email protected]'),
);
$response = $controller->index(Request::create('/admin/accommodation-booking/create', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), 'the modal is re-rendered, not redirected');
}
private function buildController(?UserInterface $user): TestableCreateController
{
$form = $this->createStub(FormInterface::class);
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\DuplicateController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around copying — the copy itself is tested in AccommodationBookingTest
* and AccommodationBookingServiceTest.
*/
class DuplicateControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('duplicate');
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/accommodation_booking/modal_duplicate.html.twig', $controller->renderedView);
}
/**
* Copying does not touch the original, so unlike absagen and löschen it is offered in
* every status — "same group, next season" starts from a confirmed booking.
*/
#[DataProvider('everyStatus')]
public function testABookingMayBeCopiedInAnyStatus(AccommodationBookingStatus $status): void
{
$booking = $this->openBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())
->method('duplicate')
->with($booking)
->willReturn(new AccommodationBooking());
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
self::assertTrue($response->headers->has('HX-Redirect'));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function everyStatus(): iterable
{
foreach (AccommodationBookingStatus::cases() as $status) {
yield $status->value => [$status];
}
}
public function testTheBrowserIsSentToTheCopyAndKeepsTheReturnUrl(): void
{
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('duplicate')->willReturn(new AccommodationBooking());
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index(
$this->openBooking(),
Request::create('/admin/accommodation-booking/1/duplicate?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', 'POST'),
);
$location = (string) $response->headers->get('HX-Redirect');
self::assertStringContainsString('app_admin_accommodationbooking_edit', $location);
self::assertStringContainsString('r=', $location);
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('duplicate');
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
}
private function openBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setGroupName('Schulklasse 7b');
$booking->setEmail('[email protected]');
return $booking;
}
}
final class TestableDuplicateController extends DuplicateController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingService $bookingService,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
) {
parent::__construct($bookingService, $logger);
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -8,6 +8,7 @@ use App\Controller\Admin\AccommodationBooking\EditController;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository;
@@ -46,6 +47,39 @@ class EditControllerTest extends TestCase
self::assertSame($status, $booking->getStatus(), 'editing leaves the status where it was');
}
public function testAnImplausiblePriceComesBackAsAFormErrorWithoutSaving(): void
{
// The office can fix this — in the house's price rows — so it belongs on the form rather
// than on an error page, and nothing may be flushed in the meantime.
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('refreshPriceSnapshot')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('has')->willReturn(false);
$form->expects(self::once())->method('addError');
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableEditController(
$entityManager,
$this->createStub(LoggerInterface::class),
$this->createStub(BoardServiceRepository::class),
$this->createStub(AdditionalServiceRepository::class),
$this->createStub(UserRepository::class),
$bookingService,
$form,
);
$response = $controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), 'the form is re-rendered, not redirected');
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
self::assertSame(1, $meta['page']);
self::assertSame(5, $meta['perPage']);
self::assertSame(12, $meta['total'], 'total is the whole export, not this page');
self::assertSame(3, $meta['totalPages']);
}
public function testCountersAreIdenticalOnEveryPage(): void
{
// Whole-export figures repeated per page, so a consumer can abort after page 1.
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
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 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);
self::assertSame([], array_intersect($first, $second));
}
public function testTheLastPageIsShort(): void
{
$data = $this->call(new TravelExportQuery(3, 5))['data'];
self::assertCount(2, $data);
self::assertSame([11, 12], array_column($data, 'busProId'));
}
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
{
$body = $this->call(new TravelExportQuery(99, 5));
self::assertSame([], $body['data']);
self::assertSame(12, $body['meta']['total']);
}
public function testDefaultsToTheFirstPageOfFiveHundred(): void
{
$body = $this->call();
self::assertSame(1, $body['meta']['page']);
self::assertSame(500, $body['meta']['perPage']);
self::assertSame(1, $body['meta']['totalPages']);
self::assertCount(12, $body['data']);
}
public function testCarriesTheBuilderCounters(): void
{
$meta = $this->call(null, 7, 21)['meta'];
self::assertSame(7, $meta['skipped']);
self::assertSame(21, $meta['pickupsWithoutMasterData']);
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
}
/**
* A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body
* shape TravelController::hotelAvailability() uses: the retry logic keys on the status code.
*/
public function testABuilderFailureIsNotDowngradedToATwoHundred(): void
{
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
$this->expectException(\RuntimeException::class);
$this->controller($builder)->travels(new TravelExportQuery());
}
/**
* @return array<string, mixed>
*/
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
{
$rows = [];
for ($i = 1; $i <= 12; ++$i) {
$rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []];
}
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new TravelExport(
$rows,
$skipped,
$withoutMasterData,
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
));
$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): ExportController
{
$controller = new ExportController($builder);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse -- which is what this endpoint produces in production anyway.
$controller->setContainer(new Container());
return $controller;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Controller\Api\TravelController;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
*
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
* `/api/travels/` competes with every date code, and a path falling through to the wrong
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
* by reflecting over the real attributes, so this breaks if the declarations change.
*/
class TravelControllerRoutingTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function pathProvider(): array
{
return [
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
// the segment across slashes, and this four-segment path is then swallowed by byCode().
'availability is not shadowed by the code lookup' => [
'/api/travels/12794/225444/2026-12-30/availability',
'api_travel_hotel_availability',
],
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
];
}
#[DataProvider('pathProvider')]
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
{
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
self::assertSame($expected, $matcher->match($path)['_route']);
}
public function testDateCodeNeverSpansSlashes(): void
{
$route = $this->collection()->get('api_travel_single_code');
self::assertNotNull($route);
self::assertStringNotContainsString(
'.+',
(string) $route->getRequirement('dateCode'),
'dateCode must keep the single-segment class, or it shadows the routes below it',
);
}
/**
* The export must stay out of `/api/travels/`, where it would be one date code away from
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
*/
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
{
foreach ($this->collection()->all() as $name => $route) {
self::assertStringNotContainsString(
'/api/travels/export',
$route->getPath(),
$name.' must not sit in the date code namespace',
);
}
}
/**
* Reflects the controller's own #[Route] attributes, in declaration order.
*/
private function collection(): RouteCollection
{
$collection = new RouteCollection();
foreach ([TravelController::class, ExportController::class] as $controller) {
$this->addRoutes($collection, $controller);
}
return $collection;
}
/**
* @param class-string $controller
*/
private function addRoutes(RouteCollection $collection, string $controller): void
{
$class = new \ReflectionClass($controller);
$prefix = '';
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
$prefix = (string) $attribute->newInstance()->path;
}
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
$route = $attribute->newInstance();
$collection->add((string) $route->name, new Route(
$prefix.$route->path,
$route->defaults,
$route->requirements,
[],
'',
[],
$route->methods,
));
}
}
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Booking\Create;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Create\Step2Controller;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\UserInterface;
class Step2ControllerTest extends TestCase
{
public function testSendsCustomerBackToStep1WhenASelectedRoomIsNoLongerBookable(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]);
$bookingDto = $this->createBookingDto($travel);
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
$bookingService->expects($this->never())->method('preselectDefaultServices');
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with(
'Room selection exceeds what the current travel data still offers.',
[
'travelId' => 4711,
'travelCode' => 'TRV-4711',
'hotelId' => 1,
'rooms' => [[
'roomId' => 1408,
'label' => 'Doppelzimmer',
'requestedQuantity' => 2,
'bookableQuantity' => 0,
]],
]
);
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
$this->assertSame(
[['error', 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.']],
$controller->flashes
);
}
public function testSendsCustomerBackToStep1WithTheCappedQuantityPrefilled(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1408, 'Doppelzimmer', minPax: 2)]);
$bookingDto = $this->createBookingDto($travel);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, 'Doppelzimmer', quantity: 1, capacity: 2),
];
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 3, 1)]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$controller = $this->createController(
$bookingService,
$bookingSessionService,
$this->createStub(LoggerInterface::class)
);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
$this->assertSame(
[['error', 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.']],
$controller->flashes
);
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'Step 1 reopens on the capped selection');
}
public function testSendsCustomerToTheErrorPageWhenTheTravelSoldOutCompletely(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$bookingDto = $this->createBookingDto($this->createTravel([]));
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with('Travel has no bookable rooms left while a booking is in progress.', $this->anything());
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_error', $response->getTargetUrl());
$this->assertSame(
[['error', 'Für diese Reise sind leider keine Zimmer mehr verfügbar.']],
$controller->flashes
);
}
public function testContinuesWhenOnlyUnselectedRoomsWereDropped(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 3)]);
$bookingDto = $this->createBookingDto($travel);
$bookingDto->roomSelections = [
1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 2, capacity: 3),
];
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->exactly(2))
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())->method('error');
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertSame([], $controller->flashes);
$this->assertInstanceOf(Response::class, $response);
$this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants');
}
private function createController(
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
LoggerInterface $logger,
): TestableStep2Controller {
$form = $this->createStub(FormInterface::class);
$form->method('isSubmitted')->willReturn(false);
return new TestableStep2Controller(
$bookingService,
$bookingSessionService,
$this->createStub(BookingCreateContextFactory::class),
$this->createStub(TravelDataProvider::class),
$this->createStub(RoomAssigner::class),
$this->createStub(ParticipantDataPrefiller::class),
$logger,
$form,
);
}
private function createBookingDto(Travel $travel): BookingDto
{
$bookingDto = new BookingDto($travel, 1);
$bookingDto->currentStep = 2;
return $bookingDto;
}
/**
* @param array<int, Room> $rooms
*/
private function createTravel(array $rooms): Travel
{
$travel = new Travel();
$travel->id = 4711;
$travel->code = 'TRV-4711';
$travel->rooms = $rooms;
return $travel;
}
private function createRoom(int $id, string $label, int $minPax): Room
{
$room = new Room();
$room->id = $id;
$room->label = $label;
$room->available = 4;
$room->minPax = $minPax;
$room->status = Constants::STATUS_AVAILABLE;
return $room;
}
private function createRoomSelection(int $id, string $label, int $quantity, int $capacity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->id = $id;
$selection->label = $label;
$selection->quantity = $quantity;
$selection->capacity = $capacity;
return $selection;
}
}
final class TestableStep2Controller extends Step2Controller
{
/**
* @var array<int, array{string, mixed}>
*/
public array $flashes = [];
/**
* @var array<string, mixed>
*/
public array $renderParameters = [];
/**
* @var FormInterface<mixed>
*/
private readonly FormInterface $form;
/**
* @param FormInterface<mixed> $form
*/
public function __construct(
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
BookingCreateContextFactory $createContextFactory,
TravelDataProvider $travelDataService,
RoomAssigner $roomAssignmentService,
ParticipantDataPrefiller $prepopulationService,
LoggerInterface $logger,
FormInterface $form,
) {
$this->form = $form;
parent::__construct(
$bookingService,
$bookingSessionService,
$createContextFactory,
$travelDataService,
$roomAssignmentService,
$prepopulationService,
$logger,
);
}
protected function getUser(): ?UserInterface
{
return null;
}
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = [$type, $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderParameters = $parameters;
return new Response('');
}
}
@@ -8,11 +8,13 @@ use App\Controller\Groups\Booking\Step4Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -302,6 +304,96 @@ class Step4ControllerTest extends TestCase
self::assertNull($sessionManager->getDto($request));
}
public function testConfirmPostEndsOnTheErrorPageWhenThePriceIsImplausible(): void
{
// A mistyped catalog price is the one way an implausible total still gets computed, and
// the customer must not meet it as a 500 — SQLSTATE 22003 on /admin is what started this.
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm', 'POST');
$request->setSession($session);
$request->headers->set('HX-Request', 'true');
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->method('finalizeBooking')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm);
$response = $controller->confirm($request);
self::assertSame('/app_groups_booking_error', $response->headers->get('HX-Redirect'));
// The session survives: the desk still has the inquiry to pick up by phone.
self::assertNotNull($sessionManager->getDto($request));
}
public function testIndexPostEndsOnTheErrorPageWhenThePriceIsImplausible(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->method('finalizeBooking')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$inquiryForm = $this->createStub(FormInterface::class);
$inquiryForm->method('handleRequest')->willReturnSelf();
$inquiryForm->method('isSubmitted')->willReturn(true);
$inquiryForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_error', $response->getTargetUrl());
}
public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void
{
$dto = new AccommodationBookingDto();
@@ -358,6 +450,7 @@ final class TestableStep4Controller extends Step4Controller
$bookingService,
$sessionManager,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
new NullLogger(),
);
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\BusProNet\Model\Travel;
use App\Controller\SecurityController;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityControllerTest extends TestCase
{
private const AUTHORIZE_PATH = '/authorize?response_type=code&client_id=abc123&redirect_uri=https%3A%2F%2Fephub.example%2Fcallback';
public function testAnAuthorizeTargetPathRendersTheSelfExplainingLoginPageNamingTheClient(): void
{
$controller = $this->createController();
$controller->clientManager
->expects(self::once())
->method('find')
->with('abc123')
->willReturn(new Client('EPHub', 'abc123', null));
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertTrue($controller->renderedParameters['oauth2']);
self::assertSame('EPHub', $controller->renderedParameters['client_name']);
}
public function testWithoutAnAuthorizeTargetPathTheStandardLoginPageIsRendered(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::never())->method('find');
$response = $controller->login(...$this->arguments($controller, null));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login.html.twig', $controller->renderedView);
self::assertFalse($controller->renderedParameters['oauth2']);
self::assertNull($controller->renderedParameters['client_name']);
}
/**
* The name is cosmetic and this runs before authentication, so an unknown client must degrade
* to neutral wording rather than break the login.
*/
public function testAnUnresolvableClientIdFallsBackToNeutralWording(): void
{
$controller = $this->createController();
$controller->clientManager
->expects(self::once())
->method('find')
->willReturn(null);
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertNull($controller->renderedParameters['client_name']);
}
public function testAnAuthorizeTargetPathWithoutAClientIdFallsBackToNeutralWording(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::never())->method('find');
$response = $controller->login(...$this->arguments($controller, '/authorize'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertNull($controller->renderedParameters['client_name']);
}
/**
* A booking left half-finished in the same browser used to overwrite the pending target path
* with booking step 1, so the application that asked for the authorization never got its code.
*/
public function testAPendingAuthorizationWinsOverAHalfFinishedBooking(): void
{
$controller = $this->createController(bookingDto: $this->createBookingDto());
$controller->clientManager
->expects(self::once())
->method('find')
->willReturn(new Client('EPHub', 'abc123', null));
$session = $this->createSession(self::AUTHORIZE_PATH);
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertSame(self::AUTHORIZE_PATH, $session->get('_security.main.target_path'));
}
public function testAHalfFinishedBookingStillWinsWhenThereIsNoAuthorization(): void
{
$controller = $this->createController(bookingDto: $this->createBookingDto());
$controller->clientManager->expects(self::never())->method('find');
$session = $this->createSession(null);
$response = $controller->login(...$this->arguments($controller, null, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('booking/create/authenticate.html.twig', $controller->renderedView);
self::assertSame('/booking/create/step-1', $session->get('_security.main.target_path'));
}
/**
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface}
*/
private function arguments(TestableSecurityController $controller, ?string $targetPath, ?Session $session = null): array
{
$request = Request::create('/', 'GET');
$request->setSession($session ?? $this->createSession($targetPath));
return [
$controller->authenticationUtils,
$request,
$controller->bookingSessionManager,
$controller->summaryAssembler,
$controller->clientManager,
];
}
private function createSession(?string $targetPath): Session
{
$session = new Session(new MockArraySessionStorage());
if (null !== $targetPath) {
$session->set('_security.main.target_path', $targetPath);
}
return $session;
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 123;
return new BookingDto($travel, 77);
}
private function createController(?BookingDto $bookingDto = null): TestableSecurityController
{
$authenticationUtils = $this->createStub(AuthenticationUtils::class);
$authenticationUtils->method('getLastAuthenticationError')->willReturn(null);
$authenticationUtils->method('getLastUsername')->willReturn('');
$bookingSessionManager = $this->createStub(BookingSessionManager::class);
$bookingSessionManager->method('getBookingDto')->willReturn($bookingDto);
return new TestableSecurityController(
$authenticationUtils,
$bookingSessionManager,
$this->createStub(BookingSummaryAssembler::class),
$this->createMock(ClientManagerInterface::class),
);
}
}
final class TestableSecurityController extends SecurityController
{
/**
* @var array<string, mixed>
*/
public array $renderedParameters = [];
public string $renderedView = '';
public function __construct(
public readonly AuthenticationUtils $authenticationUtils,
public readonly BookingSessionManager $bookingSessionManager,
public readonly BookingSummaryAssembler $summaryAssembler,
public readonly ClientManagerInterface $clientManager,
) {
}
protected function getUser(): ?UserInterface
{
return null;
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return new Response('ok');
}
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return '/booking/create/step-1';
}
}
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Tests\Entity\Groups;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -87,6 +89,36 @@ class AccommodationBookingTest extends TestCase
}
}
public function testAnImplausibleTotalIsRefusedRatherThanStored(): void
{
// The pax count and the nights are capped, so what gets here is a catalog price with a
// digit too many. A stored total would end up on an offer; the INT column would answer
// it with SQLSTATE 22003 and say nothing about the cause.
$booking = new AccommodationBooking();
$booking->setDateFrom(new \DateTimeImmutable('2026-09-23'));
$booking->setDateTo(new \DateTimeImmutable('2026-09-25'));
$booking->setPaxCount(40);
$this->expectException(ImplausibleBookingPriceException::class);
$booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1], AccommodationBooking::MAX_TOTAL_PRICE + 1, 'EUR', 1);
}
public function testATotalAtTheCeilingIsStillStored(): void
{
$booking = new AccommodationBooking();
$booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE], AccommodationBooking::MAX_TOTAL_PRICE, 'EUR', 1);
self::assertSame(AccommodationBooking::MAX_TOTAL_PRICE, $booking->getTotalPrice());
}
public function testTheCeilingLeavesTheIntColumnRoomToSpare(): void
{
// The ceiling exists to keep implausible prices out, not to use up the column: a total
// that needs more room than this is a mistake, never a wider column.
self::assertLessThan(2147483647, AccommodationBooking::MAX_TOTAL_PRICE);
}
public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void
{
// Origin answers "how did this come about" and is deliberately not the same
@@ -100,4 +132,117 @@ class AccommodationBookingTest extends TestCase
self::assertSame('Buchung', $booking->recordLabel(), $origin->value);
}
}
public function testACopyGetsItsOwnIdentityAndAccessLinkToken(): void
{
// Two records behind one uuid would mean two records behind one customer access link.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertNull($copy->getId());
self::assertNotSame($booking->getUuid(), $copy->getUuid());
}
public function testACopyStartsAsAFreshOfficeDraftThatNobodyHasSeen(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
self::assertSame(AccommodationBookingOrigin::Offer, $copy->getOrigin());
self::assertNull($copy->getAccessLinkIssuedAt());
self::assertNull($copy->getAcceptedAt());
self::assertNull($copy->getConfirmedAt());
self::assertNull($copy->getUpdatedAt());
self::assertNull($copy->getCreatedBy());
self::assertNull($copy->getUpdatedBy());
self::assertSame('Angebot', $copy->recordLabel());
}
public function testACopyCarriesThePricingAndTheDiscountsVerbatim(): void
{
// The copy is a variation of an offer that was already quoted, so it shows the same
// numbers rather than a re-quote at today's catalog prices.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getPriceBreakdown(), $copy->getPriceBreakdown());
self::assertSame($booking->getTotalPrice(), $copy->getTotalPrice());
self::assertSame($booking->getPricingCurrency(), $copy->getPricingCurrency());
self::assertSame($booking->getPricingVersion(), $copy->getPricingVersion());
self::assertSame(10, $copy->getAccommodationDiscount());
self::assertSame(5, $copy->getBoardServiceDiscount());
self::assertSame(20, $copy->getAdditionalServicesDiscount());
self::assertSame(2500, $copy->getTotalDiscountAmount());
self::assertSame('Treuerabatt', $copy->getTotalDiscountLabel());
}
public function testACopyKeepsWhatTheOfficeWouldOtherwiseKeyInAgain(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getDateFrom(), $copy->getDateFrom());
self::assertSame($booking->getDateTo(), $copy->getDateTo());
self::assertSame(42, $copy->getPaxCount());
self::assertSame('Vollpension', $copy->getBoardServiceLabel());
self::assertSame(1900, $copy->getBoardServicePrice());
self::assertSame(7, $copy->getBoardServiceOriginalId());
self::assertSame($booking->getAdditionalServices(), $copy->getAdditionalServices());
self::assertSame('Skiclub Nord', $copy->getGroupName());
self::assertSame('[email protected]', $copy->getEmail());
}
public function testCloningLeavesTheOriginalAlone(): void
{
$booking = $this->confirmedBooking();
clone $booking;
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertNotNull($booking->getConfirmedAt());
self::assertSame('Skiclub Nord', $booking->getGroupName());
}
/**
* A record that has been all the way through the lifecycle, so a copy of it has the most
* to reset.
*/
private function confirmedBooking(): AccommodationBooking
{
$now = new \DateTimeImmutable('2026-01-15 10:00:00');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$booking->setAccessLinkIssuedAt($now);
$booking->setAcceptedAt($now);
$booking->setConfirmedAt($now);
$booking->setCreatedAt($now);
$booking->setUpdatedAt($now);
$booking->setCreatedBy(new User('[email protected]'));
$booking->setUpdatedBy(new User('[email protected]'));
$booking->setDateFrom(new \DateTimeImmutable('2026-03-01'));
$booking->setDateTo(new \DateTimeImmutable('2026-03-08'));
$booking->setPaxCount(42);
$booking->setGroupName('Skiclub Nord');
$booking->setEmail('[email protected]');
$booking->setBoardServiceLabel('Vollpension');
$booking->setBoardServicePrice(1900);
$booking->setBoardServiceOriginalId(7);
$booking->addAdditionalServiceSnapshot('Skipass', 12000, 'per_person', 3);
$booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(5);
$booking->setAdditionalServicesDiscount(20);
$booking->setTotalDiscountAmount(2500);
$booking->setTotalDiscountLabel('Treuerabatt');
$booking->setPriceSnapshot(['total' => 500000], 480000, 'EUR', 1);
return $booking;
}
}
@@ -109,6 +109,60 @@ class AccommodationBookingTypeTest extends TestCase
self::assertCount(0, $this->validate($booking, ['Default']));
}
/**
* The overflow this guards against reached production as an out-of-range write on
* total_price: every figure in the breakdown is multiplied by the pax count and the
* nights, so an implausible input for either produces an implausible total.
*/
public function testADepartureBeforeTheArrivalIsRejected(): void
{
$booking = $this->bookingFor('2026-09-23', '2026-09-20', 30);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('dateTo', $violations[0]->getPropertyPath());
}
public function testAMistypedYearIsRejectedRatherThanPriced(): void
{
// 2226 instead of 2026 — one wrong digit, 200 years of running costs.
$booking = $this->bookingFor('2026-09-23', '2226-09-25', 30);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('dateTo', $violations[0]->getPropertyPath());
}
public function testAnImplausiblePaxCountIsRejected(): void
{
$booking = $this->bookingFor('2026-09-23', '2026-09-25', AccommodationBooking::MAX_PAX + 1);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('paxCount', $violations[0]->getPropertyPath());
}
public function testTheLargestBookingOnRecordStillPasses(): void
{
// 150 people for 18 nights: the ceilings must not get in the way of real business.
$booking = $this->bookingFor('2026-09-23', '2026-10-11', 150);
self::assertCount(0, $this->validate($booking, ['Default']));
}
private function bookingFor(string $dateFrom, string $dateTo, int $paxCount): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setDateFrom(new \DateTimeImmutable($dateFrom));
$booking->setDateTo(new \DateTimeImmutable($dateTo));
$booking->setPaxCount($paxCount);
return $booking;
}
/**
* @return string[]
*/
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\AccommodationBookingDto;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
class AccommodationBookingDtoTest extends TestCase
{
/**
* Step 2 is the screen the customer types the head count on, and the one that prices it.
* Bounding it only in step_3 left the figure to surface two steps later, as a booking that
* could not be stored.
*/
#[DataProvider('implausibleHeadCounts')]
public function testAnImplausibleHeadCountIsRejectedWhereItIsEntered(string $property, int $value): void
{
$dto = new AccommodationBookingDto();
$dto->{$property} = $value;
$properties = array_map(
static fn ($violation) => $violation->getPropertyPath(),
iterator_to_array($this->validate($dto, ['step_2'])),
);
self::assertContains($property, $properties);
}
/**
* @return iterable<string, array{string, int}>
*/
public static function implausibleHeadCounts(): iterable
{
yield 'pax above the ceiling' => ['paxCount', AccommodationBooking::MAX_PAX + 1];
yield 'pax below one' => ['paxCount', 0];
yield 'negative pax' => ['paxCount', -5];
yield 'minors above the ceiling' => ['minorsCount', AccommodationBooking::MAX_PAX + 1];
yield 'negative minors' => ['minorsCount', -1];
yield 'children above the ceiling' => ['childrenCount', AccommodationBooking::MAX_PAX + 1];
}
public function testAFullHouseStillPasses(): void
{
$dto = new AccommodationBookingDto();
$dto->paxCount = AccommodationBooking::MAX_PAX;
$dto->minorsCount = 4;
$dto->childrenCount = 60;
$dto->selectedBoardServiceId = 1;
self::assertCount(0, $this->validate($dto, ['step_2']));
}
/**
* @param string[] $groups
*/
private function validate(AccommodationBookingDto $dto, array $groups): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
->validate($dto, null, $groups);
}
}
@@ -10,6 +10,7 @@ use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -487,7 +488,7 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
*/
private function createTravelWithExportedSkiPasses(array $serviceIds): Travel
{
$parser = new TravelParser();
$parser = new TravelParser(new AgeConstraintParserRegistry(), true);
$crawler = XmlCrawlerFactory::create(file_get_contents('/var/www/html/var/xmlexport/Ziel_2456.xml'));
$travelNode = $crawler->filterXPath('//reise/termin')->first();
$travel = $parser->parse($travelNode);
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<!-- Two hotels on one date: the cross product must produce one row per pair. -->
<termin id="1" idbuspro="900" idprodukt="1" termin="26.12.2026" bis="30.12.2026" code="SBW261226" reiseart="F">
<text> Semesterende - Portes du Soleil </text>
<hotel idbuspro="1" code="H1"/>
<hotel idbuspro="2" code="H2"/>
<zustiege>
<zustieg id="1" idbuspro="10" zeit="26.12.2026 23:59:00"/>
<!-- Time only, and earlier than the 23:59 above: rolls onto the next calendar day. -->
<zustieg id="2" idbuspro="11" zeit="02:15"/>
<!-- No zeit at all: the entry survives with both time fields null. -->
<zustieg id="3" idbuspro="12"/>
<!-- Referenced but absent from zustiege.xml. -->
<zustieg id="4" idbuspro="99" zeit="26.12.2026 21:00:00"/>
</zustiege>
</termin>
<!-- Summer date, to pin the +02:00 offset. -->
<termin id="2" idbuspro="901" termin="15.07.2027" bis="22.07.2027" code="SBW270715">
<text>Sommer</text>
<hotel idbuspro="1" code="H1"/>
<zustiege>
<zustieg id="1" idbuspro="10" zeit="15.07.2027 08:00:00"/>
<!-- Time only and after the full datetime above: stays on the departure date. -->
<zustieg id="2" idbuspro="11" zeit="09:30"/>
</zustiege>
</termin>
<!-- Hotel absent from hotel.xml: the row is dropped and counted. -->
<termin id="3" idbuspro="902" termin="01.02.2027" bis="08.02.2027" code="SBW270201">
<text>Ohne Hotel</text>
<hotel idbuspro="404" code="GHOST"/>
</termin>
<!-- Unparseable date: both its hotel rows are dropped and counted. -->
<termin id="4" idbuspro="903" termin="kaputt" bis="08.02.2027" code="SBW270301">
<text>Kaputtes Datum</text>
<hotel idbuspro="1" code="H1"/>
<hotel idbuspro="2" code="H2"/>
</termin>
</reise>
</reisen>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<termin id="1" idbuspro="900" termin="26.12.2026" bis="30.12.2026" code="SBW261226">
<text>Zweiter Treffer</text>
<hotel idbuspro="1" code="H1"/>
</termin>
</reise>
</reisen>
@@ -12,6 +12,7 @@ use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
@@ -65,6 +66,18 @@ class AccommodationBookingServiceTest extends TestCase
$service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05'));
}
public function testApplyDatesRejectsAnImplausiblyLongStay(): void
{
// The customer picks from a calendar, so this is a hand-built URL rather than a typo —
// the cap is the same one the backoffice form enforces on the entity.
$service = $this->createServiceWithAccommodation();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Der Aufenthalt darf höchstens %d Nächte umfassen.', AccommodationBooking::MAX_NIGHTS));
$service->applyDates(new AccommodationBookingDto(), '2026-02-28', '2226-03-01');
}
public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
@@ -962,6 +975,33 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNull($booking->getTotalPrice(), 'the frozen price belonged to the old house');
}
public function testChangeAccommodationLeavesNothingHalfAppliedWhenTheNewHousePricesImplausibly(): void
{
// The swap and the dropped services are already on the managed entity by the time the
// price is computed, so a refusal has to put the row back rather than leave a booking
// sitting at a new house with no price.
$breakdown = ['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1, 'currency' => 'EUR'];
$breakdownCalculator = $this->createStub(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('computeCurrent')->willReturn($breakdown);
$breakdownCalculator->method('withDiscounts')
->willReturn($breakdown + ['discounts' => [], 'discountedTotal' => AccommodationBooking::MAX_TOTAL_PRICE + 1]);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('refresh');
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(
entityManager: $entityManager,
breakdownCalculator: $breakdownCalculator,
);
$booking = $this->draftWithServices();
$this->expectException(ImplausibleBookingPriceException::class);
$service->changeAccommodation($booking, (new Accommodation())->setName('Berghaus')->setCalendarCode('BERG'));
}
public function testChangeAccommodationNoOpsForANonDraft(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
@@ -1081,6 +1121,57 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
}
public function testDuplicatePersistsACopyAndLeavesTheOriginalAlone(): void
{
$booking = (new AccommodationBooking())
->setGroupName('Skiclub Nord')
->setPaxCount(30);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$persisted = null;
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())
->method('persist')
->willReturnCallback(function (object $entity) use (&$persisted): void { $persisted = $entity; });
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$copy = $service->duplicate($booking);
self::assertSame($persisted, $copy);
self::assertNotSame($booking, $copy);
self::assertSame(30, $copy->getPaxCount());
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertSame('Skiclub Nord', $booking->getGroupName());
}
public function testDuplicateMarksTheCopyByName(): void
{
$booking = (new AccommodationBooking())->setGroupName('Skiclub Nord');
$copy = $this->createServiceWithAccommodation()->duplicate($booking);
self::assertSame('Skiclub Nord (Kopie)', $copy->getGroupName());
}
public function testDuplicateNeverRecomputesThePrice(): void
{
// The copy has to show what the original was quoted at, not today's catalog prices.
$booking = new AccommodationBooking();
$booking->setPriceSnapshot(['total' => 500000], 480000, 'CHF', 1);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('computeCurrent');
$copy = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator)->duplicate($booking);
self::assertSame(['total' => 500000], $copy->getPriceBreakdown());
self::assertSame(480000, $copy->getTotalPrice());
self::assertSame('CHF', $copy->getPricingCurrency());
}
private function createBookingWithContactData(): AccommodationBooking
{
return (new AccommodationBooking())
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\BusProNet\Service\BookingStatusRuleRegistry;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager;
use App\Service\ParticipantEligibilityChecker;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
class BookingConfiguratorRoomReconciliationTest extends TestCase
{
public function testKeepsQuantitiesOfRoomsThatAreStillBookable(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
$this->assertSame(2, $bookingDto->roomSelections[1408]->capacity);
$this->assertSame(4, $bookingDto->roomSelections[1408]->maxQuantity);
}
public function testReportsAndRemovesASelectedRoomThatIsNoLongerBookable(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertSame(1408, $adjustments[0]->roomId);
$this->assertSame(2, $adjustments[0]->requestedQuantity);
$this->assertSame(0, $adjustments[0]->bookableQuantity);
$this->assertTrue($adjustments[0]->isDropped());
$this->assertSame([1409], array_keys($bookingDto->roomSelections));
}
public function testCapsAQuantityThatExceedsTheRemainingContingent(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 1, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 3),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertSame(1408, $adjustments[0]->roomId);
$this->assertSame('Aus der Session', $adjustments[0]->label);
$this->assertSame(3, $adjustments[0]->requestedQuantity);
$this->assertSame(1, $adjustments[0]->bookableQuantity);
$this->assertFalse($adjustments[0]->isDropped());
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'The selection carries the capped quantity');
$this->assertSame(1, $bookingDto->roomSelections[1408]->maxQuantity);
}
public function testLeavesAQuantityThatStillFitsTheReducedContingent(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 2, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
}
public function testRemovesAnUnselectedRoomThatIsNoLongerBookableWithoutReportingIt(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 0, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 1),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408], array_keys($bookingDto->roomSelections));
}
public function testAddsARoomThatBecameBookableAgain(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 1),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
$this->assertSame('Einzelzimmer', $bookingDto->roomSelections[1409]->label);
}
public function testEmptiesTheSelectionsWhenTheTravelSoldOutCompletely(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertTrue($adjustments[0]->isDropped());
$this->assertSame([], $bookingDto->roomSelections);
}
private function bookingService(): BookingConfigurator
{
return new BookingConfigurator(
$this->createStub(BookingSessionManager::class),
$this->createStub(TravelDataProvider::class),
$this->createStub(ParticipantEligibilityChecker::class),
$this->createStub(BookingStatusRuleRegistry::class),
$this->createStub(AgencyLoader::class),
'F'
);
}
/**
* @param array<int, Room> $rooms
*/
private function createTravel(array $rooms): Travel
{
$travel = new Travel();
$travel->id = 4711;
$travel->code = 'TRV-4711';
$travel->rooms = $rooms;
return $travel;
}
private function createRoom(int $id, string $label, int $available, int $minPax): Room
{
$room = new Room();
$room->id = $id;
$room->label = $label;
$room->available = $available;
$room->minPax = $minPax;
$room->status = Constants::STATUS_AVAILABLE;
$room->price = 199.0;
return $room;
}
private function createRoomSelection(int $id, int $quantity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->id = $id;
$selection->label = 'Aus der Session';
$selection->quantity = $quantity;
$selection->capacity = 2;
$selection->maxQuantity = 9;
return $selection;
}
}
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -311,6 +313,94 @@ class BookingPricingAssemblerTest extends TestCase
return $participant;
}
public function testBoardingSurchargeIsChargedForBothBusLegs(): void
{
// Travel 12939: two participants boarding and returning at point 18 (€17,90),
// which BusPro bills as two TRA positions of €35,80.
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertNotNull($group, 'Transportation group should exist');
$this->assertEqualsWithDelta(71.6, $group['groupTotal'], 0.001, 'Both legs are charged at €17,90 per participant');
}
public function testBoardingSurchargeSkipsTheReturnLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'PKW');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the outbound leg is charged');
}
public function testBoardingSurchargeSkipsTheOutboundLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('PKW', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the return leg is charged');
}
public function testBoardingSurchargeTotalMatchesTheSumOfTheParticipantTotals(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$participantCalculator = new ParticipantPricingCalculator(new RoomPricingCalculator());
$participantTotal = array_sum($participantCalculator->calculateAllParticipantIndividualPrices($bookingDto));
$this->assertEqualsWithDelta($group['groupTotal'], $participantTotal, 0.001, 'Summary and per-participant totals must agree');
}
private function createBusBookingDto(string $outboundSubType, string $inboundSubType): BookingDto
{
$travel = new Travel();
$travel->insurances = [];
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [];
foreach ([0, 1] as $index) {
$participant = new ParticipantDto();
$participant->index = $index;
$participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', $outboundSubType);
$participant->transportationInbound = $this->createTransportationService(203235, 'Bus-Rückfahrt', $inboundSubType);
$participant->pickup = $this->createBoardingPoint(18, 17.9);
$participant->dropOff = $this->createBoardingPoint(18, 17.9);
$bookingDto->participants[] = $participant;
}
return $bookingDto;
}
private function createTransportationService(int $id, string $label, string $subType): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = $subType;
$service->price = 0.0;
return $service;
}
private function createBoardingPoint(int $id, float $price): Pickup
{
$pickup = new Pickup();
$pickup->id = $id;
$pickup->city = 'Teststadt';
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
return $pickup;
}
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
{
$roomPricingCalculator = new RoomPricingCalculator();
+235
View File
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Service\TravelExportBuilder;
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 TravelExportBuilderTest extends TestCase
{
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
{
$rows = $this->build()->rows;
$keys = array_map(
static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'],
$rows,
);
self::assertSame(['900-1', '900-2', '901-1'], $keys);
self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide');
}
public function testCarriesTravelAndHotelMasterData(): void
{
$row = $this->rowFor('900-1');
self::assertSame(900, $row['busProId']);
self::assertIsInt($row['busProId']);
self::assertSame('SBW261226', $row['code']);
self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed');
self::assertSame('2026-12-26', $row['dateFrom']);
self::assertSame('2026-12-30', $row['dateTo']);
self::assertSame([
'busProId' => 1,
'code' => 'H1',
'name' => 'Hotel Eins',
'costUnit' => '84 85',
'country' => 'CH',
'countryCode' => 'ch',
], $row['hotel']);
}
public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertSame('23:59', $pickups[10]['time']);
self::assertSame('2026-12-26T23:59:00+01:00', $pickups[10]['datetime']);
// Time only, so it defaults to the departure date -- which would put it before the
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
self::assertSame('02:15', $pickups[11]['time']);
self::assertSame('2026-12-27T02:15:00+01:00', $pickups[11]['datetime']);
}
public function testEmitsTheSummerOffset(): void
{
$pickups = $this->rowFor('901-1')['pickups'];
self::assertSame('2027-07-15T08:00:00+02:00', $pickups[0]['datetime']);
}
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
{
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
self::assertSame('2027-07-15T09:30:00+02:00', $pickups[11]['datetime'], 'no roll when the order already holds');
}
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertArrayHasKey(12, $pickups);
self::assertNull($pickups[12]['time']);
self::assertNull($pickups[12]['datetime']);
self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time');
}
public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void
{
$export = $this->build();
$pickups = array_column($export->rows[0]['pickups'], null, 'busProId');
// Dropping it would hide a real departure point from the disposition UI.
self::assertArrayHasKey(99, $pickups);
self::assertNull($pickups[99]['code']);
self::assertNull($pickups[99]['city']);
self::assertNull($pickups[99]['street']);
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
self::assertSame('2026-12-26T21:00:00+01:00', $pickups[99]['datetime']);
// Counted once, not once per row that references it.
self::assertSame(1, $export->pickupsWithoutMasterData);
}
public function testPickupsKeepSourceOrder(): void
{
$order = array_column($this->rowFor('900-1')['pickups'], 'busProId');
self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time');
}
public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void
{
$rows = $this->build('<?xml version="1.0"?><reisen><reise><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027"><hotel idbuspro="1"/></termin></reise></reisen>')->rows;
self::assertSame([], $rows[0]['pickups']);
}
public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId']));
// One hotel on travel 902, two on the travel with the unparseable date.
self::assertSame(3, $export->skipped);
}
public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId']));
}
public function testCollapsesADuplicatePairLastWins(): void
{
$export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']);
$matching = array_values(array_filter(
$export->rows,
static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'],
));
self::assertCount(1, $matching);
self::assertSame('Zweiter Treffer', $matching[0]['product']);
}
/**
* @return array<string, mixed>
*/
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) {
return $row;
}
}
self::fail('No row for '.$key);
}
/**
* @param list<string> $files
*/
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'),
'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'),
];
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing(array_map(
static fn (string $path): FileAttributes => new FileAttributes($path),
$files,
)));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => $contents[$path] ?? ''
);
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn([
1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'),
2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'),
]);
$pickupLoader = $this->createStub(PickupLoader::class);
$pickupLoader->method('loadAll')->willReturn([
10 => $this->pickup(10),
11 => $this->pickup(11),
12 => $this->pickup(12),
]);
return (new TravelExportBuilder(
$hotelLoader,
$pickupLoader,
new TravelParser(new AgeConstraintParserRegistry([]), false),
$filesystem,
new TagAwareAdapter(new ArrayAdapter()),
new NullLogger(),
))->build();
}
private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel
{
$hotel = new Hotel();
$hotel->id = $id;
$hotel->code = $code;
$hotel->name = $name;
$hotel->costUnit = $costUnit;
$hotel->country = $country;
$hotel->countryCode = $countryCode;
return $hotel;
}
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;
}
}