Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221008f90 | ||
|
|
b27639c7fa | ||
|
|
ca1ab17595 | ||
|
|
c5685e6d7c | ||
|
|
0a5602fd41 | ||
|
|
f3745f918e | ||
|
|
65c1a7bd12 | ||
|
|
d0e7ba017c | ||
|
|
be0e15b3d4 | ||
|
|
f5e6d2105d | ||
|
|
0c6126010f | ||
|
|
29f295abf6 | ||
|
|
2aa98b81fe | ||
|
|
56adf72d12 |
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* The whole travel-date x hotel export plus the counters that describe how it was built.
|
||||
*
|
||||
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
|
||||
* makes the counters identical on every page: they describe the export, not the page, so a
|
||||
* consumer can read them once and abort the page walk on the first response.
|
||||
*
|
||||
* @see \App\Service\TravelExportBuilder
|
||||
*/
|
||||
final readonly class TravelExport
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||
* @param int $skipped (travel, hotel) pairs dropped as unusable
|
||||
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public int $skipped,
|
||||
public int $pickupsWithoutMasterData,
|
||||
public \DateTimeImmutable $generatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
final readonly class TravelExportQuery
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\Positive]
|
||||
public int $page = 1,
|
||||
|
||||
#[Assert\Range(min: 1, max: 1000)]
|
||||
public int $perPage = 500,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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') %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -7,50 +7,7 @@
|
||||
Login<br>MyE&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">
|
||||
|
||||
@@ -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&P
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-white">
|
||||
Melde dich mit deinem MyE&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(' '));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user