Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:04:05 +02:00
fromme d0e7ba017c fix: resync room selections with travel data before step 2 2026-09-29 10:59:13 +02:00
fromme be0e15b3d4 chore: update project dependencies 2026-09-29 10:12:41 +02:00
fromme f5e6d2105d feat: make boarding point price levelling switchable 2026-09-29 10:11:03 +02:00
fromme 0c6126010f fix: always adopt outbound pickup price for inbound when set 2026-09-28 16:33:36 +02:00
fromme 29f295abf6 feat: duplicate an accommodation booking into a new draft
addresses #869f6z44f
2026-09-28 08:55:01 +02:00
fromme 2aa98b81fe feat: dedicated login page for oauth2 logins 2026-09-27 13:43:17 +02:00
fromme 56adf72d12 fix: refuse implausible accommodation booking prices 2026-09-26 15:39:04 +02:00
fromme 773cd4f847 fix: keep post-booking newsletter failures out of the booking flow 2026-09-26 14:00:26 +02:00
fromme 876e69e62c feat: offer a newsletter opt-in for the other participants
addresses #869etn6c5
2026-09-26 13:53:10 +02:00
fromme 96b7b3f451 fix: anonymize user first and last names 2026-09-23 17:22:19 +02:00
fromme e751eed4a0 chore: update project dependencies 2026-09-23 16:53:49 +02:00
fromme bad916cfb7 feat: identify accounts by a uuid in the oauth2 sub claim 2026-09-23 15:17:09 +02:00
fromme cc3d32fcbb feat: gate oauth2 authorization on per-client roles 2026-09-23 13:46:56 +02:00
fromme 79709e3a3e feat: give client_credentials tokens a one-hour lifetime 2026-09-22 16:56:06 +02:00
fromme ab7d00b35d feat: emit team admin role in userinfo claims when applicable 2026-09-21 11:43:36 +02:00
fromme 679a864906 chore: update project dependencies 2026-09-21 10:10:59 +02:00
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00
fromme 00637042e2 chore: adjust tests to changed role labels 2026-09-21 10:02:29 +02:00
fromme fbb3ebb8c1 fix: make bpn response failures diagnosable 2026-09-19 11:51:15 +02:00
fromme ec83ad598f feat: identify the authenticated account in the userinfo claims 2026-09-19 10:54:06 +02:00
fromme 2cb4871268 chore: update wording of role labels 2026-09-17 18:18:13 +02:00
fromme c1415dee41 chore: update project dependencies 2026-09-17 18:14:41 +02:00
fromme 540cd8eeb7 feat: reserve administrative roles for staff email addresses 2026-09-17 18:13:52 +02:00
fromme 725e32daab feat: gender role label correctly 2026-09-16 15:33:40 +02:00
fromme 160ebef39e fix: stop offering unbookable services in the booking edit flow 2026-09-16 15:28:07 +02:00
fromme 672fc30d7e doc: update bpn xml api reference 2026-09-16 15:25:50 +02:00
fromme ba4f66f117 chore: enable basic auth in staging environment 2026-09-16 15:25:16 +02:00
fromme 12fe4d8e15 feat: offer contact validation flow 2026-09-16 10:40:01 +02:00
135 changed files with 8451 additions and 1248 deletions

No files matched your search

+9
View File
@@ -92,6 +92,15 @@ APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 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 # Emails
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
Generated
+130 -269
View File
@@ -935,27 +935,25 @@
}, },
{ {
"name": "doctrine/lexer", "name": "doctrine/lexer",
"version": "3.0.1", "version": "3.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/lexer.git", "url": "https://github.com/doctrine/lexer.git",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.1" "php": "^8.1"
}, },
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^12", "doctrine/coding-standard": "^14",
"phpstan/phpstan": "^1.10", "phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5.58 || ^12.5.4"
"psalm/plugin-phpunit": "^0.18.3",
"vimeo/psalm": "^5.21"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -992,7 +990,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/lexer/issues", "issues": "https://github.com/doctrine/lexer/issues",
"source": "https://github.com/doctrine/lexer/tree/3.0.1" "source": "https://github.com/doctrine/lexer/tree/3.0.2"
}, },
"funding": [ "funding": [
{ {
@@ -1008,7 +1006,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2026-06-14T20:44:06+00:00"
}, },
{ {
"name": "doctrine/migrations", "name": "doctrine/migrations",
@@ -1115,16 +1113,16 @@
}, },
{ {
"name": "doctrine/orm", "name": "doctrine/orm",
"version": "3.7.1", "version": "3.7.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/orm.git", "url": "https://github.com/doctrine/orm.git",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe" "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe", "url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe", "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1148,7 +1146,7 @@
"doctrine/coding-standard": "^14.0", "doctrine/coding-standard": "^14.0",
"phpbench/phpbench": "^1.0", "phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.4", "phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "2.1.23", "phpstan/phpstan": "2.2.14",
"phpstan/phpstan-deprecation-rules": "^2", "phpstan/phpstan-deprecation-rules": "^2",
"phpunit/phpunit": "^10.5.0 || ^11.5", "phpunit/phpunit": "^10.5.0 || ^11.5",
"psr/log": "^1 || ^2 || ^3", "psr/log": "^1 || ^2 || ^3",
@@ -1199,9 +1197,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/orm/issues", "issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/3.7.1" "source": "https://github.com/doctrine/orm/tree/3.7.2"
}, },
"time": "2026-09-14T05:45:59+00:00" "time": "2026-09-23T05:28:00+00:00"
}, },
{ {
"name": "doctrine/persistence", "name": "doctrine/persistence",
@@ -3374,16 +3372,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.14.0", "version": "3.14.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/CarbonPHP/carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" "reference": "34e9109535165bf1d7ff17a610492372ae73a896"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "reference": "34e9109535165bf1d7ff17a610492372ae73a896",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3475,7 +3473,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-12T20:45:19+00:00" "time": "2026-09-27T12:07:26+00:00"
}, },
{ {
"name": "nyholm/psr7", "name": "nyholm/psr7",
@@ -3676,20 +3674,25 @@
}, },
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"shasum": "" "shasum": ""
}, },
"require": { "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", "type": "library",
"extra": { "extra": {
@@ -3713,7 +3716,7 @@
} }
], ],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure", "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org", "homepage": "https://www.phpdoc.org",
"keywords": [ "keywords": [
"FQSEN", "FQSEN",
"phpDocumentor", "phpDocumentor",
@@ -3723,9 +3726,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", "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", "name": "phpdocumentor/reflection-docblock",
@@ -3851,16 +3854,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "5.9.0", "version": "5.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3889,6 +3892,7 @@
"dealerdirect/phpcodesniffer-composer-installer": "dev-main", "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0", "dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*", "ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2", "friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5", "mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1", "mpdf/mpdf": "^8.1.1",
@@ -3898,11 +3902,12 @@
"phpstan/phpstan-phpunit": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0", "phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7", "squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5" "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
}, },
"suggest": { "suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer", "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer", "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -3954,9 +3959,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
}, },
"time": "2026-07-12T19:17:39+00:00" "time": "2026-09-17T03:54:50+00:00"
}, },
{ {
"name": "phpseclib/phpseclib", "name": "phpseclib/phpseclib",
@@ -4070,16 +4075,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.5", "version": "2.3.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" "reference": "1427af4647235b4a73b13940b1f1258d3584f36e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "reference": "1427af4647235b4a73b13940b1f1258d3584f36e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4111,9 +4116,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "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", "name": "psr/cache",
@@ -4695,35 +4700,33 @@
}, },
{ {
"name": "sabberworm/php-css-parser", "name": "sabberworm/php-css-parser",
"version": "v9.4.0", "version": "v9.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-iconv": "*", "ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
}, },
"require-dev": { "require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0", "php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3", "phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2", "phpstan/phpstan": "1.12.33 || 2.2.9",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12",
"phpunit/phpunit": "8.5.52", "phpunit/phpunit": "8.5.54",
"rawr/phpunit-data-provider": "3.3.1", "rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6", "rector/rector": "1.2.10 || 2.6.2",
"rector/type-perfect": "1.0.0 || 2.1.3", "rector/type-perfect": "1.0.0 || 2.1.4",
"squizlabs/php_codesniffer": "4.0.1", "squizlabs/php_codesniffer": "4.0.4"
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
}, },
"suggest": { "suggest": {
"ext-mbstring": "for parsing UTF-8 CSS" "ext-mbstring": "for parsing UTF-8 CSS"
@@ -4731,7 +4734,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "9.5.x-dev" "dev-main": "9.6.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -4769,9 +4772,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
}, },
"time": "2026-06-18T15:10:53+00:00" "time": "2026-09-20T15:02:00+00:00"
}, },
{ {
"name": "spatie/crypto", "name": "spatie/crypto",
@@ -11058,16 +11061,16 @@
}, },
{ {
"name": "symfony/webpack-encore-bundle", "name": "symfony/webpack-encore-bundle",
"version": "v2.4.1", "version": "2.4.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/webpack-encore-bundle.git", "url": "https://github.com/symfony/webpack-encore-bundle.git",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" "reference": "0cbc3485f127cd9f85e395f8177607ef00535634"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", "reference": "0cbc3485f127cd9f85e395f8177607ef00535634",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11110,7 +11113,7 @@
"description": "Integration of your Symfony app with Webpack Encore", "description": "Integration of your Symfony app with Webpack Encore",
"support": { "support": {
"issues": "https://github.com/symfony/webpack-encore-bundle/issues", "issues": "https://github.com/symfony/webpack-encore-bundle/issues",
"source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1" "source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2"
}, },
"funding": [ "funding": [
{ {
@@ -11130,7 +11133,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-24T07:21:58+00:00" "time": "2026-09-17T12:45:02+00:00"
}, },
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
@@ -11208,161 +11211,18 @@
], ],
"time": "2026-08-30T00:47:26+00:00" "time": "2026-08-30T00:47:26+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "twig/extra-bundle", "name": "twig/extra-bundle",
"version": "v3.24.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git", "url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11411,7 +11271,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11423,20 +11283,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-02-07T08:07:38+00:00" "time": "2026-09-11T08:59:50+00:00"
}, },
{ {
"name": "twig/html-extra", "name": "twig/html-extra",
"version": "v3.28.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/html-extra.git", "url": "https://github.com/twigphp/html-extra.git",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7" "reference": "7147611979df81edb78baf7d003860b72e335c3f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "url": "https://api.github.com/repos/twigphp/html-extra/zipball/7147611979df81edb78baf7d003860b72e335c3f",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "reference": "7147611979df81edb78baf7d003860b72e335c3f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11479,7 +11339,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/html-extra/tree/v3.28.0" "source": "https://github.com/twigphp/html-extra/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11491,20 +11351,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-25T06:50:01+00:00" "time": "2026-09-06T20:35:36+00:00"
}, },
{ {
"name": "twig/intl-extra", "name": "twig/intl-extra",
"version": "v3.26.0", "version": "v3.30.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/intl-extra.git", "url": "https://github.com/twigphp/intl-extra.git",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11543,7 +11403,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.26.0" "source": "https://github.com/twigphp/intl-extra/tree/v3.30.0"
}, },
"funding": [ "funding": [
{ {
@@ -11555,7 +11415,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-19T20:44:48+00:00" "time": "2026-09-25T05:50:11+00:00"
}, },
{ {
"name": "twig/string-extra", "name": "twig/string-extra",
@@ -11626,16 +11486,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.28.0", "version": "v3.30.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11690,7 +11550,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.28.0" "source": "https://github.com/twigphp/Twig/tree/v3.30.0"
}, },
"funding": [ "funding": [
{ {
@@ -11702,7 +11562,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-03T20:44:34+00:00" "time": "2026-09-25T13:20:01+00:00"
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
@@ -11921,16 +11781,16 @@
}, },
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.4.4", "version": "3.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/semver.git", "url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11972,7 +11832,7 @@
"homepage": "http://robbast.nl" "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": [ "keywords": [
"semantic", "semantic",
"semver", "semver",
@@ -11982,7 +11842,7 @@
"support": { "support": {
"irc": "ircs://irc.libera.chat:6697/composer", "irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues", "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": [ "funding": [
{ {
@@ -11994,7 +11854,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-20T19:15:30+00:00" "time": "2026-09-24T14:38:51+00:00"
}, },
{ {
"name": "composer/xdebug-handler", "name": "composer/xdebug-handler",
@@ -12234,23 +12094,23 @@
}, },
{ {
"name": "fidry/cpu-core-counter", "name": "fidry/cpu-core-counter",
"version": "1.3.0", "version": "1.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git", "url": "https://github.com/theofidry/cpu-core-counter.git",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678" "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678", "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0" "php": "^7.2 || ^8.0"
}, },
"require-dev": { "require-dev": {
"fidry/makefile": "^0.2.0", "fidry/makefile": "^0.2.0 || ^1.1.2",
"fidry/php-cs-fixer-config": "^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0", "phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0", "phpstan/phpstan": "^2.0",
@@ -12258,6 +12118,7 @@
"phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26", "phpunit/phpunit": "^8.5.31 || ^9.5.26",
"symfony/polyfill-mbstring": "^1.33",
"webmozarts/strict-phpunit": "^7.5" "webmozarts/strict-phpunit": "^7.5"
}, },
"type": "library", "type": "library",
@@ -12283,7 +12144,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues", "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": [ "funding": [
{ {
@@ -12291,20 +12152,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-14T07:29:31+00:00" "time": "2026-09-27T17:40:20+00:00"
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.25", "version": "v3.95.27",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff" "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12338,15 +12199,15 @@
"require-dev": { "require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0", "facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7", "infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.10.0", "justinrainbow/json-schema": "^6.12.0",
"keradus/cli-executor": "^2.3", "keradus/cli-executor": "^2.3",
"php-coveralls/php-coveralls": "^2.9.1", "php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3",
"symfony/polyfill-php85": "^1.38", "symfony/polyfill-php85": "^1.41",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6"
}, },
"suggest": { "suggest": {
"ext-dom": "For handling output formats in XML", "ext-dom": "For handling output formats in XML",
@@ -12387,7 +12248,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27"
}, },
"funding": [ "funding": [
{ {
@@ -12395,7 +12256,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-08T11:11:37+00:00" "time": "2026-09-22T09:54:55+00:00"
}, },
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
@@ -12634,11 +12495,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.14", "version": "2.2.16",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12694,7 +12555,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-12T21:39:33+00:00" "time": "2026-09-25T09:31:51+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -13194,16 +13055,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "12.5.35", "version": "12.5.36",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab" "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13272,7 +13133,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "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": [ "funding": [
{ {
@@ -13280,7 +13141,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-09-09T04:48:50+00:00" "time": "2026-09-25T08:47:40+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -19,6 +19,9 @@ league_oauth2_server:
doctrine: null doctrine: null
client: client:
allow_plaintext_secrets: false allow_plaintext_secrets: false
# App\Entity\OAuth2Client adds the required_roles column that
# App\EventListener\AuthorizationCodeListener gates authorization on.
classname: App\Entity\OAuth2Client
when@test: when@test:
league_oauth2_server: league_oauth2_server:
+13
View File
@@ -185,6 +185,10 @@ services:
arguments: arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%' $retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\BusProNet\XmlParser\TravelParser:
arguments:
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
App\Service\TravelDataProvider: App\Service\TravelDataProvider:
arguments: arguments:
$logger: '@monolog.logger.core' $logger: '@monolog.logger.core'
@@ -203,6 +207,10 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\BusProNet\Utility\CountryCodeMapper:
arguments:
$logger: '@monolog.logger.bpn'
App\BusProNet\XmlLoader\HotelLoader: App\BusProNet\XmlLoader\HotelLoader:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
@@ -215,6 +223,11 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\Service\TravelExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator: App\Service\VoucherValidator:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
+6 -8
View File
@@ -77,33 +77,31 @@ $rsyncOptions = [
'timeout' => 300, 'timeout' => 300,
]; ];
// Shared by all hosts. The deploy path only differs by the host alias. // Shared by all hosts.
set('deploy_path', '/usr/home/myepsf/public_html/{{alias}}');
set('bin/php', '/usr/bin/php'); set('bin/php', '/usr/bin/php');
set('http_user', 'myepsf'); set('http_user', 'myepsf');
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir. // Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
set('rsync_src', __DIR__); set('rsync_src', __DIR__);
set('rsync', $rsyncOptions); set('rsync', $rsyncOptions);
// The web adapter fetches its probe file back through the live docroot, so --web-path must
// be the current symlink, never {{release_or_current_path}}: that one resolves (and memoizes)
// to the new release dir, which the FPM worker's realpath cache does not see yet.
set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url={{web_url}}');
host('prod') host('prod')
->setHostname('dedi10193.your-server.de') ->setHostname('dedi10193.your-server.de')
->setDeployPath('/usr/www/users/myepsf/prod')
->setRemoteUser('myepsf') ->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->set('web_url', 'https://my.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
; ;
host('staging') host('staging')
->setHostname('dedi10193.your-server.de') ->setHostname('dedi10193.your-server.de')
->setDeployPath('/usr/www/users/myepsf/staging')
->setRemoteUser('myepsf') ->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->set('web_url', 'https://my.ep-reisen.net') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
->add('shared_files', [ ->add('shared_files', [
'public/.htaccess',
'public/.htpasswd', 'public/.htpasswd',
]) ])
; ;
+149 -8
View File
@@ -10,7 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Token endpoint: `POST /token` (unauthenticated). - Token endpoint: `POST /token` (unauthenticated).
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently. - 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`. - 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`). - 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>`. - Send the token as `Authorization: Bearer <access_token>`.
@@ -47,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. - `{"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). - `{"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. 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.
@@ -59,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
#### `GET /api/hotels` — scope `api` #### `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` #### `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` #### `GET /api/countries` — scope `api`
@@ -122,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 ### Travels
#### `GET /api/travels` — scope `api` #### `GET /api/travels` — scope `api`
@@ -379,9 +497,9 @@ Responses:
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes: OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
- always: `email` - always: `sub` (UUID string, see below), `email`
- scope `id`: `person_id`, `address_id` - scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`) - scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
- scope `profile`: `profile` object: - scope `profile`: `profile` object:
```json ```json
@@ -394,7 +512,30 @@ OIDC-style claims for the authenticated user. The response contains only the cla
} }
``` ```
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity. ##### Identifying the account: match on `sub`
A BusPro address record holds several email addresses (private and business, say), and BusPro
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
that is intended, not a duplicate: administrative roles are reserved for the company address, so
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
`@ep-reisen.de` address is an administrator.
All of those accounts report the **same** `person_id` and `address_id`, and the same
`profile.communication.email` — that value is the first contact address on the BusPro record and
is **not necessarily** the one signed in with. So:
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
unique across accounts. The only claim that identifies an account. Match your local user on it
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
- **`email`** — the address this session actually authenticated with. Distinct per account, but
treat it as a display and contact value; it is not the account key.
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
person's accounts, so they identify a *human*, never an account. Do not match on them.
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
`profile.communication.email` will disagree with MyE&P in both directions.
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials. `400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
@@ -406,7 +547,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
## Client implementation notes ## Client implementation notes
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401. 1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint. 2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary. 3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries. 4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
+4
View File
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
- admin users also receive default hotel code `SSL` - admin users also receive default hotel code `SSL`
- no matched role means `ROLE_CUSTOMER` - no matched role means `ROLE_CUSTOMER`
These are the CRM's claims. The portal filters them before storing anything (staff-only
roles, approval markers), so a proxy must return the claims unfiltered — see
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
Candidate SQL for selected customer selections: Candidate SQL for selected customer selections:
```sql ```sql
+13 -1
View File
@@ -166,6 +166,18 @@ genuinely missing, which is a useful way to audit an address: the placeholder ac
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
`telefonprivat` and no `nationalitaet`. `telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
the placeholder records and is stricter than what BusPro applies to ordinary participants.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
been measured yet.
Note `650` again: both of those are hard rejections delivered under the code Note `650` again: both of those are hard rejections delivered under the code
`Notification::isError()` treats as success. `Notification::isError()` treats as success.
@@ -196,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) | | `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | | `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room | | `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()` | | `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` | | `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | | | `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | | `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
+11
View File
@@ -225,6 +225,17 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId); 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 **N — the departure surcharge is computed.** Booking responses carry a position
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag` `art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees — the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
+36 -6
View File
@@ -18,17 +18,47 @@ Symfony roles:
| `IDSelektionsstamm` | Role | | `IDSelektionsstamm` | Role |
| --- | --- | | --- | --- |
| `1070` | `ROLE_TEAMER` | | `1070` | `ROLE_TEAMER` |
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) | | `1292` | `ROLE_ADMIN` |
| `1293` | `ROLE_MANAGER` | | `1293` | `ROLE_MANAGER` |
| `1477` | `ROLE_GROUPS_MANAGER` | | `1477` | `ROLE_GROUPS_MANAGER` |
| `1478` | `ROLE_GROUPS_ADMIN` | | `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` | | `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` | | nothing matched | `ROLE_CUSTOMER` |
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
one of these roles. one of these roles.
These are *claims*, not grants. What an account ends up holding is decided by
`Role::sync()` (`src/Security/Role.php`) on every login — see
[From claim to role](#from-claim-to-role).
---
## From claim to role
BusPro backend users can edit their own CRM selections, so the claims above are
filtered before anything is stored:
| Role | Claimed without a staff address | Claimed with a staff address |
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
administrator approves it in `/admin/user`. Nominations are announced to
`role_nomination_notification_emails`.
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
and a role approved earlier is **revoked** on the next login. A leftover marker from
before the rule cannot be approved and disappears on that login too.
- Anything the CRM stops claiming is revoked, approved or not.
- An account left without any effective role gets `ROLE_CUSTOMER`.
--- ---
## The four tables ## The four tables
@@ -398,11 +428,11 @@ BusProNet host is the fix, and no schema change is needed.
## Portal side: a correct XML is not enough ## Portal side: a correct XML is not enough
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User` `BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
entity at login (`setRoles($crmAttributes->roles)` / through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
after fixing BusPro the affected user must **log out and log in again** before must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set. the account has a staff address and an administrator has approved the nomination.
--- ---
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
* gates the authorization code grant on.
*
* The column defaults to an empty list and an empty list denies, so every client is
* refused until its roles are set. The two clients in actual use are therefore set here
* rather than by hand after the deploy: it closes the window in which both staff tools
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
* review can see them.
*
* Matched on name, not identifier: client identifiers are generated per environment and
* differ between local, staging and production, while the names do not.
*
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
* broker), both R&D only and not in use. The client_credentials clients never reach the
* authorization endpoint and are unaffected either way.
*/
final class Version20260923120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
}
public function up(Schema $schema): void
{
// Added nullable and backfilled before being tightened: a NOT NULL column with no
// default leaves existing rows holding an empty string, which is not valid JSON and
// fails to hydrate -- the row cannot even reach the listener that would deny it.
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
// mirrors ephub's App\Security\Role::ELIGIBLE
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
'name' => 'EPHub',
]);
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
'name' => 'MyEPTeam',
]);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Uid\Uuid;
/**
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
*
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
* would come out time-ordered and share a suffix. Guessing one account's identifier from
* another's is the property this column exists to remove, so the backfill has to be as
* random as the ones App\Entity\User generates from now on.
*
* One statement per row, which is why it is emitted rather than written out: the values
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
* there is no single default that could satisfy a unique column.
*/
final class Version20260923150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
'uuid' => Uuid::v4()->toRfc4122(),
'id' => $id,
]);
}
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
$this->addSql('ALTER TABLE user DROP uuid');
}
}
+73 -24
View File
@@ -602,10 +602,12 @@ class ApiClient
'port' => $this->selectedPort, 'port' => $this->selectedPort,
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$responseXml = substr($response, 10); try {
$responseXml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $responseXml); $this->dumpXmlToFile('response', $requestId, $responseXml);
@@ -714,18 +716,12 @@ class ApiClient
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message try {
$xml = substr($response, 10); // the length header is consumed and verified by receive(), this is the payload
$xml = $this->receive($socket);
if ('' === $xml) { } finally {
$this->logger->error('Empty response body received from API (header-only response)', [ $this->disconnect($socket);
'request_id' => $requestId,
'raw_length' => strlen($response),
]);
throw new ApiClientException('Empty response body received from API');
} }
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
@@ -735,14 +731,19 @@ class ApiClient
try { try {
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs); return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
} catch (ResponseParserException $e) { } catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
$this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
} }
$this->logger->error('Unexpected response received from API', [
'request_id' => $requestId,
]);
throw new ApiClientException('Unexpected response received from API');
} }
/** /**
@@ -773,10 +774,12 @@ class ApiClient
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$xml = substr($response, 10); try {
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) { if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
@@ -898,8 +901,14 @@ class ApiClient
} }
/** /**
* Reads a single response message and returns its payload. The protocol prepends the
* payload length as a 10 byte header, so the announced length is compared against what
* actually arrived: a peer closing mid-stream would otherwise yield a silently
* truncated body that only surfaces later as an unexplained parse failure.
*
* @param resource $socket * @param resource $socket
* *
* @throws ApiClientException
* @throws TimeoutException * @throws TimeoutException
* @throws ImmediateConnectionCloseException * @throws ImmediateConnectionCloseException
*/ */
@@ -963,7 +972,47 @@ class ApiClient
throw new ImmediateConnectionCloseException('Server closed connection without sending data'); throw new ImmediateConnectionCloseException('Server closed connection without sending data');
} }
return $response; return $this->extractPayload($response);
}
/**
* @throws ApiClientException
*/
private function extractPayload(string $response): string
{
if (10 > strlen($response)) {
$this->logger->error('Incomplete response header received from API', [
'bytes_received' => strlen($response),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
}
$header = substr($response, 0, 10);
$announcedLength = (int) trim($header);
$payload = substr($response, 10);
if (1 > $announcedLength) {
$this->logger->error('Empty response body announced by API', [
'header' => trim($header),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
if (strlen($payload) !== $announcedLength) {
$this->logger->error('Truncated response received from API', [
'bytes_received' => strlen($payload),
'announced_length' => $announcedLength,
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
}
return $payload;
} }
/** @param resource $socket */ /** @param resource $socket */
+25
View File
@@ -143,6 +143,31 @@ class Booking
}); });
} }
/**
* Checks whether a participant already holds a service in this booking.
*
* This is the BusPro-side baseline, not the working edit selection: it answers
* "did the participant have this service when the booking was last read back".
* Services already held may be kept regardless of their current status or
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
* Teilnehmerzuordnung überein" on the next update.
*
* @param int $participantIndex The participant index to search for
* @param int $serviceId The service ID to look for
*
* @return bool True if the participant already holds the service
*/
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
{
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
return true;
}
}
return false;
}
/** /**
* Retrieves transportation service for a specific participant and direction. * Retrieves transportation service for a specific participant and direction.
* *
+11
View File
@@ -35,4 +35,15 @@ class Hotel
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $country = null; 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;
} }
+15 -1
View File
@@ -61,6 +61,13 @@ class PersonalData
/** @var list<string> */ /** @var list<string> */
public array $hotelCodes = []; public array $hotelCodes = [];
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
// under any of the addresses on its record, and each of those is a separate local account with
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
// on by the caller in the same way as the roles and hotel codes above.
public ?string $subject = null;
public ?string $loginEmail = null;
public function __construct() public function __construct()
{ {
$this->address = new Address(); $this->address = new Address();
@@ -132,14 +139,21 @@ class PersonalData
* Creates a structured array containing user profile information * Creates a structured array containing user profile information
* suitable for JWT claims or user session data. * suitable for JWT claims or user session data.
* *
* `sub` and `email` describe the account that authenticated and are read from the patched-on
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
* person, and `profile.communication.email` is the first contact address on the BusPro record,
* which is not necessarily the one signed in with. Deliberately without a fallback to that
* address — a caller that forgets to patch them gets null rather than a wrong identity.
*
* @return array<string, mixed> The claims array with user profile data * @return array<string, mixed> The claims array with user profile data
*/ */
public function getClaims(): array public function getClaims(): array
{ {
return [ return [
'sub' => $this->subject,
'person_id' => $this->personId, 'person_id' => $this->personId,
'address_id' => $this->addressId, 'address_id' => $this->addressId,
'email' => $this->communication->email, 'email' => $this->loginEmail,
'roles' => $this->roles, 'roles' => $this->roles,
'profile' => [ 'profile' => [
'first_name' => $this->firstName, 'first_name' => $this->firstName,
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
use Psr\Log\LoggerInterface;
/**
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
*
* BusPro has no country vocabulary: the field is free text that has accumulated three
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
* under several tokens. The table below is therefore part of the API contract rather than
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
* guess, never to an empty string.
*/
class CountryCodeMapper
{
/**
* Keys are upper-cased tokens, values lowercase ISO-2.
*
* Entries worth their explanation:
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
* Slovenia already arrives under its own `SI`.
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
* - `UK` is the colloquial form of the ISO `GB`.
*
* @var array<string, string>
*/
private const MAP = [
'A' => 'at',
'AT' => 'at',
'AUT' => 'at',
'B' => 'be',
'CA' => 'ca',
'CH' => 'ch',
'CZ' => 'cz',
'D' => 'de',
'DEU' => 'de',
'DK' => 'dk',
'E' => 'es',
'ES' => 'es',
'F' => 'fr',
'FI' => 'fi',
'FL' => 'li',
'FR' => 'fr',
'GR' => 'gr',
'GRE' => 'gr',
'HR' => 'hr',
'HRV' => 'hr',
'HU' => 'hu',
'I' => 'it',
'IL' => 'il',
'IR' => 'ie',
'IS' => 'is',
'IT' => 'it',
'LT' => 'lt',
'LV' => 'lv',
'MT' => 'mt',
'N' => 'no',
'NL' => 'nl',
'PL' => 'pl',
'PRT' => 'pt',
'PT' => 'pt',
'RUS' => 'ru',
'S' => 'se',
'SI' => 'si',
'SLO' => 'sk',
'UK' => 'gb',
'USA' => 'us',
'ZA' => 'za',
];
/**
* Tokens already reported in this process, so a master data file full of the same
* unknown token produces one log line rather than one per hotel.
*
* @var array<string, true>
*/
private array $reported = [];
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/**
* @param string|null $token the raw `hotel/land` value
*
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
*/
public function map(?string $token): ?string
{
if (null === $token) {
return null;
}
$normalized = strtoupper(trim($token));
if ('' === $normalized) {
return null;
}
if (isset(self::MAP[$normalized])) {
return self::MAP[$normalized];
}
if (false === isset($this->reported[$normalized])) {
$this->reported[$normalized] = true;
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
}
return null;
}
}
+34
View File
@@ -20,4 +20,38 @@ final class XmlCrawlerFactory
return $crawler; return $crawler;
} }
/**
* Explains why create() returned an empty Crawler. addXmlContent() discards the
* libxml errors, so an empty, a malformed and a truncated payload are
* indistinguishable afterwards; this re-parses to recover the reason. Only worth
* calling on the failure path.
*/
public static function diagnose(string $xml): string
{
if ('' === trim($xml)) {
return sprintf('empty response (%d bytes)', strlen($xml));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
simplexml_load_string($xml);
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return sprintf('unknown XML error (%d bytes)', strlen($xml));
}
return sprintf('%s (%d bytes)', implode('; ', array_unique($messages)), strlen($xml));
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
} }
+15
View File
@@ -4,14 +4,25 @@ namespace App\BusProNet\XmlLoader;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
use League\Flysystem\FilesystemOperator;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
class HotelLoader extends AbstractLoader class HotelLoader extends AbstractLoader
{ {
public function __construct(
private readonly CountryCodeMapper $countryCodeMapper,
CacheInterface $cache,
FilesystemOperator $xmlExport,
) {
parent::__construct($cache, $xmlExport);
}
/** @return array<int, Hotel> */ /** @return array<int, Hotel> */
public function loadAll(?string $filename = 'hotel.xml'): array public function loadAll(?string $filename = 'hotel.xml'): array
{ {
@@ -87,6 +98,10 @@ class HotelLoader extends AbstractLoader
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land')); $hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse')); $hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon')); $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; return $hotel;
} }
+23 -5
View File
@@ -92,6 +92,13 @@ class TravelLoader extends AbstractLoader
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) { ->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
$hotelId = (int) $hotelNode->attr('idbuspro'); $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] = [ $mapping[$travelId]['hotels'][$hotelId] = [
'id' => $hotelId, 'id' => $hotelId,
'code' => $hotelNode->attr('code'), 'code' => $hotelNode->attr('code'),
@@ -281,9 +288,9 @@ class TravelLoader extends AbstractLoader
/** /**
* Apply availability data to travel services. * Apply availability data to travel services.
* *
* Updates the availability status of additional and transportation * Updates the remaining contingent and the live status of additional and
* services, and the allowed booking status, based on the provided * transportation services, and the allowed booking status, based on the
* availability data. * provided availability data.
* *
* @param Travel $travel The travel object to update * @param Travel $travel The travel object to update
* @param ServiceAvailabilityResponse $availabilities The availability data for services * @param ServiceAvailabilityResponse $availabilities The availability data for services
@@ -293,8 +300,19 @@ class TravelLoader extends AbstractLoader
$serviceAvailabilities = $availabilities->getServices(); $serviceAvailabilities = $availabilities->getServices();
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) { foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
if (array_key_exists($service->id, $serviceAvailabilities)) { if (false === array_key_exists($service->id, $serviceAvailabilities)) {
$service->available = $serviceAvailabilities[$service->id]->available; continue;
}
$availability = $serviceAvailabilities[$service->id];
$service->available = $availability->available;
// The live status is the only signal that a contingent has run out: BusPro moves a
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
// response actually carries one, so a response that omits the attribute does not wipe
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
if (null !== $availability->status && '' !== trim($availability->status)) {
$service->status = $availability->status;
} }
} }
+10 -4
View File
@@ -8,6 +8,11 @@ use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser class ApiResponseParser extends AbstractParser
{ {
public function __construct(
private readonly TravelParser $travelParser,
) {
}
/** /**
* @throws ResponseParserException * @throws ResponseParserException
*/ */
@@ -17,7 +22,7 @@ class ApiResponseParser extends AbstractParser
$crawler = XmlCrawlerFactory::create($xml); $crawler = XmlCrawlerFactory::create($xml);
if (0 === $crawler->count()) { if (0 === $crawler->count()) {
throw new ResponseParserException('Empty response received from server'); throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
} }
// Override type unless present in XML to catch error responses // Override type unless present in XML to catch error responses
@@ -56,7 +61,8 @@ class ApiResponseParser extends AbstractParser
case 'Vorgangdruck': case 'Vorgangdruck':
return (new DocumentsParser())->parseConfirmation($resultNode); return (new DocumentsParser())->parseConfirmation($resultNode);
} }
break;
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
case ApiClient::TYPE_BASE_DATA_COUNTRIES: case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return (new CountriesParser())->parse($resultNode); return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA: case ApiClient::TYPE_MUTABLE_DATA:
@@ -80,7 +86,7 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('No travel data node found in product data response'); 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: case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode); return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER: case ApiClient::TYPE_PURCHASE_VOUCHER:
@@ -92,6 +98,6 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e); throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
} }
throw new ResponseParserException('Unable to parse XML response'); throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
} }
} }
@@ -40,6 +40,7 @@ class CrmAttributesResponseParser
1477 => Role::GROUPS_MANAGER, 1477 => Role::GROUPS_MANAGER,
1478 => Role::GROUPS_ADMIN, 1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT, 1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN,
]; ];
/** /**
+48 -13
View File
@@ -29,8 +29,20 @@ class TravelParser extends AbstractParser
Constants::PSEUDO_PRICE_ROOM_CODE, 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( 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->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false); $travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
// Merge drop-off prices into pickups for split pricing support // A boarding point costs the same in both directions
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs); if ($this->levelBoardingPointPrices) {
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
}
$travel->guide = $this->getGuide($node); $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 * A boarding point is either free or costs the same in both directions:
* the inbound price to the pickup's priceInbound property. This enables * `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
* split pricing calculations where costs are distributed between outbound * BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
* and inbound transportation. * 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> $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) { foreach ($dropOffs as $pickupId => $dropOff) {
if (isset($dropOffs[$pickupId])) { if (false === isset($pickups[$pickupId])) {
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound; 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\Accommodation;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType; use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -49,7 +51,24 @@ class ChangeAccommodationController extends AbstractController
$selected = $form->get('accommodation')->getData(); $selected = $form->get('accommodation')->getData();
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) { 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.'); $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\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingCreateType; use App\Form\Admin\Groups\AccommodationBookingCreateType;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +48,7 @@ class CreateController extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid() && $this->refreshPriceSnapshot($form, $booking)) {
$this->bookingService->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking); $this->entityManager->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -65,4 +67,31 @@ class CreateController extends AbstractController
'form' => $form, '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\AccommodationBooking;
use App\Entity\Groups\AdditionalService; use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; use App\Entity\Groups\BoardService;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingType; use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
@@ -15,6 +16,8 @@ use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; 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 // 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 // change is its own named action (Angebot senden, bestätigen, absagen), so the
@@ -154,4 +165,31 @@ class EditController extends AbstractController
'form' => $form, 'form' => $form,
]); ]);
} }
/**
* The price is computed after validation, so an implausible total cannot be a constraint on
* the form — it is reported as one anyway, because the office's next move is to go and fix
* the house's prices, not to read an error page.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
{
try {
$this->bookingService->refreshPriceSnapshot($booking);
return true;
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
'paxCount' => $booking->getPaxCount(),
'nights' => $booking->getNights(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return false;
}
}
} }
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Bulk exports, for consumers that mirror our data rather than query it per row.
*
* Deliberately its own resource rather than a verb hung off the collection it exports.
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
* share a namespace with every date code and could only be kept apart from one by a
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
*/
#[Route('/api/exports')]
#[IsGranted('ROLE_OAUTH2_API')]
class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
) {
}
/**
* Every travel date x hotel pair with its pickups resolved, paginated.
*
* The counters in meta describe the whole export, not the page, so they repeat
* unchanged on every page and a consumer can abort after the first one.
*/
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
$total = count($export->rows);
return $this->json([
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
'meta' => [
'page' => $query->page,
'perPage' => $query->perPage,
'total' => $total,
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'generatedAt' => $export->generatedAt->format('c'),
],
]);
}
}
+14 -2
View File
@@ -26,8 +26,9 @@ class UserinfoController extends AbstractController
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])] #[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
public function index(): JsonResponse public function index(): JsonResponse
{ {
// basic scopes applicable to all authenticated users // basic scopes applicable to all authenticated users. `sub` is not gated on a scope of its
$scopes = ['email']; // own: it identifies the account every other claim describes, so it is always exported.
$scopes = ['sub', 'email'];
// extend scopes depending on granted permissions // extend scopes depending on granted permissions
if ($this->isGranted('ROLE_OAUTH2_ID')) { if ($this->isGranted('ROLE_OAUTH2_ID')) {
@@ -53,6 +54,17 @@ class UserinfoController extends AbstractController
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
} }
// Patch the identity of the account that authenticated. BusPro accepts any of the
// addresses on a person's record as a login and answers all of them with the same
// ids and the same first contact address, so only the local account tells the staff
// account and the private one apart — and they hold different roles.
//
// The uuid, not the primary key: consumers store this value and match their own
// accounts on it, so it must not disclose how many accounts exist or let one
// account's identifier be guessed from another's.
$data->subject = $user->getUuid();
$data->loginEmail = $user->getEmail();
// Patch current user's roles. The implicit ROLE_USER says nothing about the // Patch current user's roles. The implicit ROLE_USER says nothing about the
// account — every authenticated user holds it — and is not exported. // account — every authenticated user holds it — and is not exported.
$data->roles = Role::effectiveOnly($user->getRoles()); $data->roles = Role::effectiveOnly($user->getRoles());
@@ -13,6 +13,7 @@ use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory; use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
@@ -66,6 +67,12 @@ class Step2Controller extends AbstractBookingCreateController
// Enrich with fresh availability data // Enrich with fresh availability data
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); $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. // Ensure correct number of participants first, then prepopulate the applicant if needed.
if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) { if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) {
return $redirect; return $redirect;
@@ -120,6 +127,79 @@ class Step2Controller extends AbstractBookingCreateController
return $this->render('booking/create/step_2.html.twig', $templateData); 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. * Rejects a room selection that resolves to no participants at all.
* *
@@ -190,7 +270,14 @@ class Step2Controller extends AbstractBookingCreateController
$rooms = $travelData->getAvailableRooms(); $rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) { 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; $participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity;
} }
@@ -10,16 +10,14 @@ use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
use App\Exception\BookingSessionNotFoundException; use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\NewsletterProviderException;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type; use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory; use App\Service\BookingCreateContextFactory;
use App\Service\BookingNewsletterOptIn;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use App\Service\ParticipantFormSupport; use App\Service\ParticipantFormSupport;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -41,7 +39,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory, private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly NewsletterManager $doubleOptInService, private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService, private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -64,11 +62,15 @@ class Step4Controller extends AbstractBookingCreateController
return $redirect; return $redirect;
} }
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto); $user = $this->getUser();
$newsletterTargetEmail = $this->bookingNewsletterOptIn->resolveApplicantEmail($bookingCreateDto, $user instanceof User ? $user : null);
$newsletterOptInVisible = null !== $newsletterTargetEmail; $newsletterOptInVisible = null !== $newsletterTargetEmail;
$companions = $this->bookingNewsletterOptIn->resolveCompanions($bookingCreateDto, $newsletterTargetEmail);
$companionNewsletterOptInVisible = [] !== $companions;
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible, 'show_newsletter_opt_in' => $newsletterOptInVisible,
'show_companion_newsletter_opt_in' => $companionNewsletterOptInVisible,
'newsletter_target_email' => $newsletterTargetEmail, 'newsletter_target_email' => $newsletterTargetEmail,
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -94,7 +96,7 @@ class Step4Controller extends AbstractBookingCreateController
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.', $bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
); );
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
if (false === $bookingResponse->isBookingSuccessful()) { if (false === $bookingResponse->isBookingSuccessful()) {
@@ -110,7 +112,7 @@ class Step4Controller extends AbstractBookingCreateController
$errorMessage, $errorMessage,
); );
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
$newsletterOptInSelected = $newsletterOptInVisible $newsletterOptInSelected = $newsletterOptInVisible
@@ -118,19 +120,21 @@ class Step4Controller extends AbstractBookingCreateController
&& true === $form->get('newsletterOptIn')->getData(); && true === $form->get('newsletterOptIn')->getData();
if (true === $newsletterOptInSelected) { if (true === $newsletterOptInSelected) {
try { $this->bookingNewsletterOptIn->subscribeApplicant(
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto); $newsletterTargetEmail,
$this->doubleOptInService->requestConfirmation( $this->bookingNewsletterOptIn->resolveApplicantParticipant($bookingCreateDto),
$newsletterTargetEmail, );
$targetParticipant?->firstName, }
$targetParticipant?->lastName,
); $companionOptInSelected = $companionNewsletterOptInVisible
} catch (NewsletterProviderException|\InvalidArgumentException $e) { && $form->has('companionNewsletterOptIn')
$this->logger->warning('Newsletter confirmation request failed after booking', [ && true === $form->get('companionNewsletterOptIn')->getData();
'email' => $newsletterTargetEmail,
'error' => $e->getMessage(), if (true === $companionOptInSelected) {
]); $this->bookingNewsletterOptIn->subscribeCompanions(
} $companions,
$this->bookingNewsletterOptIn->resolveRequestedByName($bookingCreateDto),
);
} }
// Success: Store booking data in flash for conversion tracking // Success: Store booking data in flash for conversion tracking
@@ -171,7 +175,7 @@ class Step4Controller extends AbstractBookingCreateController
} }
} }
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
/** /**
@@ -185,6 +189,7 @@ class Step4Controller extends AbstractBookingCreateController
FormInterface $form, FormInterface $form,
bool $newsletterOptInVisible, bool $newsletterOptInVisible,
?string $newsletterTargetEmail, ?string $newsletterTargetEmail,
bool $companionNewsletterOptInVisible,
): Response { ): Response {
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto); $bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
@@ -193,45 +198,10 @@ class Step4Controller extends AbstractBookingCreateController
'form' => $form, 'form' => $form,
'newsletterOptInVisible' => $newsletterOptInVisible, 'newsletterOptInVisible' => $newsletterOptInVisible,
'newsletterTargetEmail' => $newsletterTargetEmail, 'newsletterTargetEmail' => $newsletterTargetEmail,
'companionNewsletterOptInVisible' => $companionNewsletterOptInVisible,
]); ]);
} }
private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string
{
$currentUser = $this->getUser();
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/** /**
* Clears travel data and availability cache after successful booking. * Clears travel data and availability cache after successful booking.
*/ */
@@ -116,6 +116,14 @@ class IndexController extends AbstractController
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.'); $this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
} }
$droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels();
if ([] !== $droppedDraftServiceLabels) {
$this->addFlash('info', sprintf(
'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
implode(', ', $droppedDraftServiceLabels)
));
}
// Fetch booking data for display (surcharges, canceled status, etc.) // Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user); $bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) { if (null === $bookingData || $bookingData instanceof Notification) {
@@ -185,6 +193,13 @@ class IndexController extends AbstractController
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'); $this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
} }
if ([] !== $result->unbookableServicesReverted) {
$this->addFlash('info', sprintf(
'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
implode(', ', $result->unbookableServicesReverted)
));
}
switch ($result->status) { switch ($result->status) {
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED: case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'); $this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
@@ -8,6 +8,7 @@ use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService; use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException; use App\Exception\AccommodationSessionNotFoundException;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\AccommodationBookingConfirmationType; use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType; use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
@@ -16,6 +17,7 @@ use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager; use App\Service\AccommodationSessionManager;
use App\Service\AccommodationTermsUrlProvider; use App\Service\AccommodationTermsUrlProvider;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -28,6 +30,7 @@ class Step4Controller extends AbstractController
private readonly AccommodationBookingService $bookingService, private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager, private readonly AccommodationSessionManager $sessionManager,
private readonly AccommodationTermsUrlProvider $termsUrlProvider, private readonly AccommodationTermsUrlProvider $termsUrlProvider,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -51,7 +54,9 @@ class Step4Controller extends AbstractController
} }
$dto->forceInquiry = true; $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'); return $this->redirectToRoute('app_groups_booking_success');
} }
@@ -102,7 +107,9 @@ class Step4Controller extends AbstractController
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation); $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')); return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
} }
@@ -148,12 +155,35 @@ class Step4Controller extends AbstractController
* groupedAdditionalServices: array<string, AdditionalService[]>, * groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[] * ungroupedAdditionalServices: AdditionalService[]
* } $services * } $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); $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->addFlash('groups_booking_result', $booking->getOrigin()->value);
$this->sessionManager->clear($request); $this->sessionManager->clear($request);
return null;
} }
} }
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyAbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractController extends SymfonyAbstractController
{
use HxTrait;
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
protected function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
protected function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ConfirmController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
// Checked for POST as well, so the modal cannot be submitted around the contact page.
if (!$this->bookingService->hasCompleteContactData($booking)) {
return $this->htmxRedirect($request, $this->generateUrl('app_groups_offer_contact', ['uuid' => $uuid]));
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\AccommodationContactType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ContactController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
/**
* Full page rather than modal: the form is as large as step 3 of a self-service booking,
* whose fields and constraints it shares. Once saved, the customer is back on the offer
* and accepts it from there as usual.
*/
#[Route(path: '/groups/booking/offer/{uuid}/contact', name: 'app_groups_offer_contact', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = $this->bookingService->contactDataFromBooking($booking);
$form = $this->createForm(AccommodationContactType::class, $dto, [
'email_readonly' => true,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->applyContactData($booking, $dto);
$this->addFlash('success', 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.');
return $this->redirectToOfferPage($request, $uuid);
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/contact.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
'form' => $form,
]);
}
}
+1 -102
View File
@@ -4,17 +4,8 @@ declare(strict_types=1);
namespace App\Controller\Groups\Offer; namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner; use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -22,14 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
use HxTrait;
public function __construct( public function __construct(
private readonly AccommodationBookingRepository $bookingRepository, private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner, private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) { ) {
} }
@@ -39,7 +25,7 @@ class IndexController extends AbstractController
* (session-gated) offer page — nothing downstream needs the signature again. * (session-gated) offer page — nothing downstream needs the signature again.
*/ */
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])] #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
public function access(string $uuid, Request $request): Response public function index(string $uuid, Request $request): Response
{ {
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
@@ -55,91 +41,4 @@ class IndexController extends AbstractController
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid])); return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
} }
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function view(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$priceBreakdown = $this->breakdownCalculator->compute($booking);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $priceBreakdown,
'ctx' => $ctx,
]);
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function confirm(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
private function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
} }
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ViewController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
]);
}
}
+53 -12
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler; use App\Service\BookingSummaryAssembler;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -21,13 +22,28 @@ class SecurityController extends AbstractController
Request $request, Request $request,
BookingSessionManager $bookingSessionService, BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService, BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager,
): Response { ): 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) // Check if this is a booking flow (BookingDto exists in session)
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); $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 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'); return $this->redirectToRoute('app_booking_create_step_1');
} }
@@ -39,21 +55,18 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError(); $error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername(); $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) // For booking flow, set target path to Step 1 (after successful auth, redirect there)
if (true === $isBookingFlow) { if (true === $isBookingFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); $session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
} }
// Render booking login template if in booking flow, otherwise standard login // Render booking login template if in booking flow, the self-explaining one when another
$template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig'; // 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) // Fetch CMS data for booking flow (uses cache warmed in IndexController)
$cmsData = null; $cmsData = null;
@@ -72,9 +85,37 @@ class SecurityController extends AbstractController
'travel_date_to' => $bookingDto?->travel->dateTo, 'travel_date_to' => $bookingDto?->travel->dateTo,
'cmsData' => $cmsData, 'cmsData' => $cmsData,
'oauth2' => $isOauth2, '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')] #[Route('/logout', name: 'app_logout')]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function logout(): void public function logout(): void
+79 -1
View File
@@ -12,11 +12,13 @@ use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType; use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)] #[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
@@ -24,6 +26,24 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
use BlameableEntity; use BlameableEntity;
use TimestampableEntity; 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\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -40,9 +60,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private ?\DateTimeImmutable $dateFrom = null; private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)] #[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Abreisedatum muss nach dem Anreisedatum liegen.')]
private ?\DateTimeImmutable $dateTo = null; private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column] #[ORM\Column]
#[Assert\Range(min: 0, max: self::MAX_PAX)]
private int $paxCount = 0; private int $paxCount = 0;
#[ORM\Column] #[ORM\Column]
@@ -68,6 +90,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $priceBreakdown = null; private ?array $priceBreakdown = null;
/** In minor units, like every other price here, and never above self::MAX_TOTAL_PRICE. */
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $totalPrice = null; private ?int $totalPrice = null;
@@ -177,6 +200,34 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
$this->uuid = Uuid::v4()->toRfc4122(); $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 public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -232,6 +283,25 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->dateFrom->diff($this->dateTo)->days; 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 public function getPaxCount(): int
{ {
return $this->paxCount; return $this->paxCount;
@@ -336,9 +406,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->priceBreakdown; 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 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->priceBreakdown = $priceBreakdown;
$this->totalPrice = $totalPrice; $this->totalPrice = $totalPrice;
$this->pricingCurrency = $currency; $this->pricingCurrency = $currency;
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
/**
* An OAuth2 client, extended with the roles an account must hold to authorize it.
*
* The bundle ships its own Client model and maps it for us. Replacing it (via
* league_oauth2_server.client.classname) is the supported way to add a column: the bundle
* maps AbstractClient as a mapped superclass and carries name, secret, redirectUris,
* grants, scopes, active and allowPlainTextPkce, then stops mapping its own Client, so
* this class only has to declare the table, the primary key and what it adds.
*
* Why the requirement lives here rather than in configuration: the alternative was a map
* in services.yaml keyed on the client identifier, and identifiers differ per environment,
* so that map would have needed an env var per client and would have drifted from the
* client table it describes. A column cannot drift from its own row.
*
* Nothing validates the contents. A misspelled role is not an error, it is a client that
* authorizes nobody, so AuthorizationCodeListener logs the requirement whenever it
* refuses — that is what makes the mistake visible without a database query.
*/
#[ORM\Entity]
#[ORM\Table(name: 'oauth2_client')]
class OAuth2Client extends AbstractClient
{
/**
* Declared protected on AbstractClient and mapped by the bundle only for its own
* Client, so the custom class has to map it itself. The type restates the parent's
* contract, which redeclaring the property would otherwise widen.
*
* @var non-empty-string
*/
#[ORM\Id]
#[ORM\Column(type: 'string', length: 32)]
protected string $identifier;
/**
* The roles that entitle an account to authorize this client. An empty list denies
* everyone: a row nobody has configured and a client that needs no roles are the same
* stored value, and of the two readings only "deny" is safe.
*
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $requiredRoles = [];
/**
* @return list<string>
*/
public function getRequiredRoles(): array
{
return $this->requiredRoles;
}
/**
* @param list<string> $requiredRoles
*/
public function setRequiredRoles(array $requiredRoles): self
{
$this->requiredRoles = $requiredRoles;
return $this;
}
}
+23
View File
@@ -6,6 +6,7 @@ use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -15,6 +16,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private ?int $id = null; private ?int $id = null;
/**
* The account's external identifier, exported as the OAuth2 `sub` claim.
*
* Separate from the primary key on purpose: `sub` is handed to other applications, which
* store it and match their own accounts on it, so it has to be stable, unique and free of
* anything the row's position in the table implies. The auto-increment id satisfied the
* first two and neither of the last: it disclosed how many accounts exist and made one
* account's identifier a guess away from the next.
*
* v4 rather than the framework's default v7 (see config/packages/uid.yaml): v7 embeds its
* creation timestamp and sorts, which is useful for a key and is exactly the leak this
* column exists to avoid. Matches Groups\AccommodationBooking::$uuid in shape and version.
*/
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 180, unique: true)] #[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email; private ?string $email;
@@ -54,6 +71,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct(string $email) public function __construct(string $email)
{ {
$this->email = $email; $this->email = $email;
$this->uuid = Uuid::v4()->toRfc4122();
} }
public function getId(): ?int public function getId(): ?int
@@ -61,6 +79,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->id; return $this->id;
} }
public function getUuid(): string
{
return $this->uuid;
}
public function getEmail(): ?string public function getEmail(): ?string
{ {
return $this->email; return $this->email;
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Enum;
/**
* Who asked for a double opt-in mail — the recipient themselves, or the applicant of a
* booking they are travelling on. Only the wording of the confirmation mail depends on
* this: a third-party sign-up has to name who requested it and make clear that nothing
* is subscribed without the recipient's own click. Confirmation itself stays
* context-agnostic, so the value never has to be persisted.
*/
enum NewsletterOptInContext: string
{
case Own = 'own';
case Companion = 'companion';
public function subject(): string
{
return match ($this) {
self::Own => 'Deine Anmeldung zum E&P Newsletter',
self::Companion => 'Möchtest du den E&P Newsletter erhalten?',
};
}
public function template(): string
{
return match ($this) {
self::Own => 'email/newsletter_opt_in.html.twig',
self::Companion => 'email/newsletter_opt_in_companion.html.twig',
};
}
}
+89 -11
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\EventListener; namespace App\EventListener;
use App\Entity\OAuth2Client;
use App\Entity\User; use App\Entity\User;
use App\Security\Role;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent; use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\OAuth2Events; use League\Bundle\OAuth2ServerBundle\OAuth2Events;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -14,14 +16,21 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
/** /**
* Handles OAuth2 authorization code grant flow. * Handles OAuth2 authorization code grant flow.
* *
* Automatically approves authorization requests for authenticated users without * Redirects anonymous users to login, then decides whether the account may authorize the
* consent prompts. Redirects anonymous users to login. For external OAuth2 * client that asked. Consent is deliberately not part of that decision: every client is
* requests (flagged via session), logs out the user after authorization to * one of our own applications, so there is nothing for the user to weigh up. What the user
* prevent session hijacking. * cannot be asked, though, is whether they are entitled to the application at all, and
* that is what this listener answers — a client declares the roles it requires in
* OAuth2Client::$requiredRoles and an account holding none of them is refused here rather
* than handed a token the client will reject for reasons it cannot explain.
*
* For external OAuth2 requests (flagged via session), logs out the user after a successful
* authorization to prevent session hijacking.
*/ */
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')] #[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
class AuthorizationCodeListener class AuthorizationCodeListener
@@ -31,6 +40,7 @@ class AuthorizationCodeListener
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly Security $security, private readonly Security $security,
private readonly LoggerInterface $authenticationLogger, private readonly LoggerInterface $authenticationLogger,
private readonly Environment $twig,
) { ) {
} }
@@ -53,13 +63,81 @@ class AuthorizationCodeListener
); );
$event->setResponse($response); $event->setResponse($response);
$this->authenticationLogger->info('Authorization request without session'); $this->authenticationLogger->info('Authorization request without session');
} else {
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); return;
// in case this authorization request has been flagged as external in login controller, }
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
if (true === $request->getSession()->get('_oauth2', false)) { $client = $event->getClient();
$this->security->logout(false);
} // the event is typed to the bundle's ClientInterface, which knows nothing about the
// column below: anything that is not our entity is a client we cannot have configured
if (false === $client instanceof OAuth2Client) {
$this->deny($event, $user, $client->getIdentifier(), [], 'unexpected client class');
return;
}
$required = $client->getRequiredRoles();
// An empty requirement is not "no requirement": it is the state a client row starts
// in, so it means nobody has said who may use this client yet.
if ([] === $required) {
$this->deny($event, $user, $client->getName(), $required, 'client has no roles configured');
return;
}
// effectiveOnly(), never getRoles(): a *_PENDING marker is a CRM nomination that
// grants nothing until an administrator approves it, and matching the raw role set
// would let a nomination open a staff application on its own.
if ([] === array_intersect($required, Role::effectiveOnly($user->getRoles()))) {
$this->deny($event, $user, $client->getName(), $required, 'insufficient roles');
return;
}
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
// in case this authorization request has been flagged as external in login controller,
// immediately logout the current user (see App\Controller\SecurityController).
if (true === $request->getSession()->get('_oauth2', false)) {
$this->security->logout(false);
} }
} }
/**
* Refuses the request and explains it on our own page.
*
* The resolution is left at its AUTHORIZATION_DENIED default; setting a response is what
* keeps the user here instead of bouncing them back to a client that would only be able
* to report that no authorization code arrived. The session is deliberately left intact
* — the user has to still be signed in to read the page and navigate away from it.
*
* Logged with the client's requirement but never with the user's roles: the requirement
* is configuration, and it is what makes a misspelled entry diagnosable from the log,
* while the account's roles are personal data and the auth channel is database-backed.
*
* @param list<string> $required
*/
private function deny(
AuthorizationRequestResolveEvent $event,
User $user,
string $client,
array $required,
string $reason,
): void {
$this->authenticationLogger->info('Authorization request denied', [
'client' => $client,
'user_id' => $user->getId(),
'required_roles' => $required,
'reason' => $reason,
]);
$event->setResponse(new Response(
// oauth2: true drops hx-boost in base.html.twig -- this page is rendered
// mid-redirect-chain, where boosting breaks the navigation
$this->twig->render('security/oauth2_denied.html.twig', ['client' => $client, 'oauth2' => true]),
Response::HTTP_FORBIDDEN
));
}
} }
@@ -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,
);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The contact data of a group booking — shared by step 3 of the self-service flow and by
* the page that completes an office-made offer before it can be accepted, so both ask
* for the same fields under the same constraints.
*
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
// An offer's access link and every notification already go to this address.
'disabled' => $options['email_readonly'],
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'validation_groups' => ['contact'],
'email_readonly' => false,
]);
$resolver->setAllowedTypes('email_readonly', 'bool');
}
}
+5 -38
View File
@@ -6,10 +6,7 @@ namespace App\Form;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('remarks', TextareaType::class, [ ->add('remarks', TextareaType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
'validation_groups' => ['step_3'], 'validation_groups' => ['step_3'],
]); ]);
} }
public function getParent(): string
{
return AccommodationContactType::class;
}
} }
+13
View File
@@ -44,6 +44,17 @@ class BookingCreateStep4Type extends AbstractType
]); ]);
} }
if (true === $options['show_companion_newsletter_opt_in']) {
// Deliberately no companion addresses in the label: the applicant already knows them,
// and rendering them would put other people's addresses on a shared screen.
$builder->add('companionNewsletterOptIn', CheckboxType::class, [
'label' => 'Ich möchte auch meine Mitfahrer:innen für den E&P Newsletter anmelden.',
'help' => 'Alle erhalten eine eigene Bestätigungs-E-Mail und werden erst nach ihrer eigenen Bestätigung angemeldet.',
'mapped' => false,
'required' => false,
]);
}
$builder $builder
->add('confirmationAccepted', CheckboxType::class, [ ->add('confirmationAccepted', CheckboxType::class, [
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.', 'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
@@ -72,10 +83,12 @@ class BookingCreateStep4Type extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => BookingDto::class, 'data_class' => BookingDto::class,
'show_newsletter_opt_in' => false, 'show_newsletter_opt_in' => false,
'show_companion_newsletter_opt_in' => false,
'newsletter_target_email' => null, 'newsletter_target_email' => null,
]); ]);
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool'); $resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('show_companion_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']); $resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
} }
} }
+20 -14
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model; namespace App\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
class AccommodationBookingDto class AccommodationBookingDto
@@ -16,13 +17,16 @@ class AccommodationBookingDto
public ?\DateTimeImmutable $dateTo = null; 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; 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; 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; public int $childrenCount = 0;
#[Assert\NotNull(message: 'required', groups: ['step_2'])] #[Assert\NotNull(message: 'required', groups: ['step_2'])]
@@ -43,33 +47,35 @@ class AccommodationBookingDto
/** @var array<string, mixed> */ /** @var array<string, mixed> */
public array $priceBreakdown = []; public array $priceBreakdown = [];
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] // `contact` is the subset an office-made offer is checked against before the customer
// may accept it — the same constraints the customer meets in step 3 of a self-service booking.
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $groupName = null; public ?string $groupName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])] #[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3', 'contact'])]
public ?string $salutation = null; public ?string $salutation = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $firstName = null; public ?string $firstName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $lastName = null; public ?string $lastName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Email(message: 'invalid', groups: ['step_3'])] #[Assert\Email(message: 'invalid', groups: ['step_3', 'contact'])]
public ?string $email = null; public ?string $email = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $phone = null; public ?string $phone = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $street = null; public ?string $street = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $zip = null; public ?string $zip = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $city = null; public ?string $city = null;
public ?string $remarks = null; public ?string $remarks = null;
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
} }
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes; return $attributes;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$fieldOptions['attr']['data-description'] = $parkingService->description; $fieldOptions['attr']['data-description'] = $parkingService->description;
} }
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) { if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
$fieldOptions['attr']['readonly'] = true; $fieldOptions['attr']['readonly'] = true;
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht'; $fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
} }
return $fieldOptions; return $fieldOptions;
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/ */
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{ {
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// For non-create workflows, don't apply availability restrictions
return false;
}
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
} }
/** /**
* Determines if a service should be rendered as read-only. * Determines if a service should be rendered as read-only.
* *
* This method intelligently handles readonly state for services in both create and edit modes: * Both modes share one rule set. The already-held carve-out inside
* * ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
* - CREATE MODE: Uses existing availability calculator logic * service that is now sold out or on request, so no mode-specific branch is needed here.
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
*
* This prevents fingerprint false positives in edit mode by allowing participants to keep
* services they already have, even if those services are now fully booked.
* *
* @param Service $service The service to check * @param Service $service The service to check
* @param BookingDto $bookingDto The booking DTO containing participant data * @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant currently selecting services * @param int $participantIndex Index of the participant currently selecting services
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
* *
* @return bool True if the service should be read-only * @return bool True if the service should be read-only
*/ */
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{ {
// In CREATE mode, use existing availability logic return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
// In EDIT mode, apply intelligent readonly logic
// If service is available (available > 0), it's never readonly
if (null !== $service->available && $service->available > 0) {
return false;
}
// Service is unavailable - check if participant already has it
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return true; // Readonly if no participant data
}
// Check if participant has this service based on field type
$participantHasService = match ($fieldName) {
'courses' => $this->hasServiceById($participant->courses, $service->id),
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
'board' => $this->hasServiceById($participant->board, $service->id),
'veg' => $participant->veg?->id === $service->id,
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
'skiPass' => $participant->skiPass?->id === $service->id,
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
default => false,
};
// Make readonly only if participant doesn't have it
return false === $participantHasService;
} }
/** /**
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return false; return false;
} }
/**
* Returns the tooltip explaining why a service is rendered read-only.
*
* Only called for a service that is already known to be blocked, so the status alone
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
* never fires there.
*/
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
{
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
&& Constants::STATUS_ON_REQUEST === $service->status) {
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
}
return 'ausgebucht';
}
/** /**
* Filters services based on participant's age constraints. * Filters services based on participant's age constraints.
* *
@@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult
public const string STATUS_TIMEOUT = 'timeout'; public const string STATUS_TIMEOUT = 'timeout';
public const string STATUS_API_CLIENT_ERROR = 'api_client_error'; public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
/**
* @param list<string> $unbookableServicesReverted Labels of services dropped because they are
* no longer bookable, reported separately from
* the mutability revert: the category is still
* mutable, the service is not bookable
*/
public function __construct( public function __construct(
public string $status, public string $status,
public ?string $message = null, public ?string $message = null,
public bool $immutableChangesReverted = false, public bool $immutableChangesReverted = false,
public array $unbookableServicesReverted = [],
) { ) {
} }
} }
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* A room selection the current travel data no longer supports in full.
*
* A room that disappeared altogether is the same case with nothing left to book, so it is reported
* as an adjustment down to zero rather than as a second kind of result.
*/
final readonly class RoomSelectionAdjustment
{
public function __construct(
public int $roomId,
public ?string $label,
public int $requestedQuantity,
public int $bookableQuantity,
) {
}
/**
* Whether the room is gone entirely rather than merely reduced.
*/
public function isDropped(): bool
{
return 0 === $this->bookableQuantity;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
*/
final readonly class TravelExport
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
) {
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
{
public function __construct(
#[Assert\Positive]
public int $page = 1,
#[Assert\Range(min: 1, max: 1000)]
public int $perPage = 500,
) {
}
}
+15 -6
View File
@@ -67,7 +67,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
public function authenticate(Request $request): Passport public function authenticate(Request $request): Passport
{ {
$email = trim($request->request->getString('_username')); // One canonical casing per account. BusPro matches an address case-insensitively and so
// does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not
// become the casing every later BusPro call re-sends.
$email = mb_strtolower(trim($request->request->getString('_username')));
$passwordPlain = trim($request->request->getString('_password')); $passwordPlain = trim($request->request->getString('_password'));
// BPN requires md5, not a real hash // BPN requires md5, not a real hash
@@ -114,6 +117,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email); $user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
$user $user
// Not redundant next to the constructor: an account created before the address was
// normalized still carries the casing of its very first login, and everything
// downstream re-sends what is stored rather than what was just typed.
->setEmail($email)
->setPassword($encryptedPassword) ->setPassword($encryptedPassword)
->setPersonId($personalData->personId) ->setPersonId($personalData->personId)
->setAddressId($personalData->addressId) ->setAddressId($personalData->addressId)
@@ -182,9 +189,11 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes))) ->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
; ;
// Compared on the full role sets rather than on the markers alone: nominatedFrom() needs
// to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts.
$nominated = array_values(array_diff( $nominated = array_values(array_diff(
Role::pendingOnly($user->getRoles()), array_keys(Role::nominatedFrom($user->getRoles())),
Role::pendingOnly($previousRoles), array_keys(Role::nominatedFrom($previousRoles)),
)); ));
if ([] === $nominated) { if ([] === $nominated) {
@@ -198,9 +207,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
'roles' => $nominated, 'roles' => $nominated,
]); ]);
// Only the newly appeared markers reach this point, so a repeat login with a nomination // Only the newly appeared nominations reach this point, so a repeat login with a
// still standing announces nothing. That difference is the whole de-duplication. // nomination still standing announces nothing. That difference is the whole de-duplication.
return array_keys(Role::nominatedFrom($nominated)); return $nominated;
} }
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+49 -8
View File
@@ -23,6 +23,10 @@ namespace App\Security;
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account * That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the * itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
* nomination path, and the CRM remains the only source for every role that grants privileges. * nomination path, and the CRM remains the only source for every role that grants privileges.
*
* ROLE_EMPLOYEE does gate a few administrative roles, though (see EMPLOYEE_ONLY): for those, a
* CRM claim only counts when the account is staff. It narrows what the CRM can nominate and
* never widens it — the approval step still applies on top.
*/ */
final class Role final class Role
{ {
@@ -33,6 +37,7 @@ final class Role
public const USER = 'ROLE_USER'; public const USER = 'ROLE_USER';
public const ADMIN = 'ROLE_ADMIN'; public const ADMIN = 'ROLE_ADMIN';
public const TEAM_ADMIN = 'ROLE_TEAM_ADMIN';
public const MANAGER = 'ROLE_MANAGER'; public const MANAGER = 'ROLE_MANAGER';
public const TEAMER = 'ROLE_TEAMER'; public const TEAMER = 'ROLE_TEAMER';
public const CUSTOMER = 'ROLE_CUSTOMER'; public const CUSTOMER = 'ROLE_CUSTOMER';
@@ -58,6 +63,7 @@ final class Role
*/ */
public const ALL = [ public const ALL = [
self::ADMIN, self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER, self::MANAGER,
self::TEAMER, self::TEAMER,
self::CUSTOMER, self::CUSTOMER,
@@ -90,6 +96,7 @@ final class Role
*/ */
public const ADMINISTRATIVE = [ public const ADMINISTRATIVE = [
self::ADMIN, self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER, self::MANAGER,
self::HOUSE_MANAGER, self::HOUSE_MANAGER,
self::GROUPS_ADMIN, self::GROUPS_ADMIN,
@@ -97,6 +104,25 @@ final class Role
self::CUSTOMER_EXPERT, self::CUSTOMER_EXPERT,
]; ];
/**
* Administrative roles reserved for staff accounts. A CRM claim for one of these is ignored
* unless the account is also claimed as ROLE_EMPLOYEE, so it neither nominates nor keeps a
* role approved earlier. BusPro backend users outside the company can edit their own CRM
* selections too, and these roles reach far enough that the email domain has to agree.
* ROLE_HOUSE_MANAGER is the one administrative role left out: a Hausleitung signs in with
* the hotel's own address.
*
* @var string[]
*/
public const EMPLOYEE_ONLY = [
self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
self::CUSTOMER_EXPERT,
];
/** /**
* Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this * Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this
* application, including the approval surface itself, so it always takes a second * application, including the approval surface itself, so it always takes a second
@@ -159,7 +185,9 @@ final class Role
} }
/** /**
* The roles behind those markers, labelled — what an approver acts on. * The roles behind those markers, labelled — what an approver acts on. A marker for an
* EMPLOYEE_ONLY role on an account that is not staff is left out: it predates the rule and
* goes on that account's next login, and until then it must not be approvable.
* *
* @param string[] $roles * @param string[] $roles
* *
@@ -170,9 +198,15 @@ final class Role
$labels = self::labels(); $labels = self::labels();
$nominated = []; $nominated = [];
$isEmployee = \in_array(self::EMPLOYEE, $roles, true);
foreach (self::pendingOnly($roles) as $marker) { foreach (self::pendingOnly($roles) as $marker) {
$role = self::realRole($marker); $role = self::realRole($marker);
if (false === $isEmployee && \in_array($role, self::EMPLOYEE_ONLY, true)) {
continue;
}
if (\in_array($role, self::ADMINISTRATIVE, true)) { if (\in_array($role, self::ADMINISTRATIVE, true)) {
$nominated[$role] = $labels[$role]; $nominated[$role] = $labels[$role];
} }
@@ -184,6 +218,8 @@ final class Role
/** /**
* The whole policy, applied on every login. * The whole policy, applied on every login.
* *
* 0. drop the claims for EMPLOYEE_ONLY roles unless ROLE_EMPLOYEE is claimed as well — they
* then count as not made, so the steps below revoke and never mark them;
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is * 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
* what makes BusPro the source of truth; * what makes BusPro the source of truth;
* 2. grant the unconditional roles it claims; * 2. grant the unconditional roles it claims;
@@ -204,6 +240,10 @@ final class Role
{ {
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles))); $claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
if (false === \in_array(self::EMPLOYEE, $claimed, true)) {
$claimed = array_values(array_diff($claimed, self::EMPLOYEE_ONLY));
}
$roles = array_values(array_filter( $roles = array_values(array_filter(
self::assignedOnly($storedRoles), self::assignedOnly($storedRoles),
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true), static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
@@ -224,8 +264,8 @@ final class Role
/** /**
* Turns a marker into the role it stands for. Refuses anything the account is not nominated * Turns a marker into the role it stands for. Refuses anything the account is not nominated
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog * for (see nominatedFrom()), so neither a hand-crafted request nor a claim revoked while the
* was open can grant a role the CRM never reported. * confirmation dialog was open can grant a role the CRM never reported.
* *
* @param string[] $storedRoles * @param string[] $storedRoles
* *
@@ -237,7 +277,7 @@ final class Role
{ {
$roles = self::assignedOnly($storedRoles); $roles = self::assignedOnly($storedRoles);
if (false === \in_array(self::pending($role), $roles, true)) { if (false === \array_key_exists($role, self::nominatedFrom($roles))) {
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role)); throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
} }
@@ -284,13 +324,14 @@ final class Role
{ {
$labels = [ $labels = [
self::ADMIN => 'Administration', self::ADMIN => 'Administration',
self::MANAGER => 'Manager:in', self::TEAM_ADMIN => 'Team Administration',
self::MANAGER => 'Reisemanager:in',
self::TEAMER => 'Teamer:in', self::TEAMER => 'Teamer:in',
self::CUSTOMER => 'Kund:in', self::CUSTOMER => 'Kund:in',
self::HOUSE_MANAGER => 'Hausleitung', self::HOUSE_MANAGER => 'Hausleitung',
self::GROUPS_ADMIN => 'Preisrechner Admin', self::GROUPS_ADMIN => 'GRO Admin',
self::GROUPS_MANAGER => 'Preisrechner', self::GROUPS_MANAGER => 'GRO-Expert:in',
self::CUSTOMER_EXPERT => 'KO-Experte', self::CUSTOMER_EXPERT => 'KO-Expert:in',
self::EMPLOYEE => 'Mitarbeiter:in', self::EMPLOYEE => 'Mitarbeiter:in',
]; ];
+111 -13
View File
@@ -13,7 +13,9 @@ use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams; use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData; use App\Model\CmsHotelData;
use App\Model\InquiryStatus; use App\Model\InquiryStatus;
@@ -23,6 +25,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AccommodationBookingService class AccommodationBookingService
{ {
@@ -41,6 +44,7 @@ class AccommodationBookingService
private readonly AccommodationBookingLinkSigner $linkSigner, private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator, private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingPdfGenerator $pdfGenerator, private readonly AccommodationBookingPdfGenerator $pdfGenerator,
private readonly ValidatorInterface $validator,
private readonly string $accommodationEmail, private readonly string $accommodationEmail,
) { ) {
} }
@@ -76,7 +80,8 @@ class AccommodationBookingService
/** /**
* Parses and validates raw date strings, then sets them on the DTO. * 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 public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
{ {
@@ -91,6 +96,10 @@ class AccommodationBookingService
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.'); 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->dateFrom = $dateFrom->setTime(0, 0, 0);
$dto->dateTo = $dateTo->setTime(0, 0, 0); $dto->dateTo = $dateTo->setTime(0, 0, 0);
} }
@@ -115,6 +124,19 @@ class AccommodationBookingService
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode()); return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
} }
/**
* What the customer-facing offer pages show around a stored booking: the hotel's CMS
* content and the price breakdown computed from the booking's frozen snapshot.
*/
public function createOfferContext(AccommodationBooking $booking, Accommodation $accommodation): AccommodationBookingContext
{
return new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->loadHotelCmsData($accommodation),
priceBreakdown: $this->breakdownCalculator->compute($booking),
);
}
/** /**
* @return AccommodationPrice[] * @return AccommodationPrice[]
*/ */
@@ -262,7 +284,6 @@ class AccommodationBookingService
): AccommodationBooking { ): AccommodationBooking {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setAccommodation($accommodation); $booking->setAccommodation($accommodation);
$booking->setGroupName($dto->groupName);
$booking->setDateFrom($dto->dateFrom); $booking->setDateFrom($dto->dateFrom);
$booking->setDateTo($dto->dateTo); $booking->setDateTo($dto->dateTo);
$booking->setPaxCount($dto->paxCount); $booking->setPaxCount($dto->paxCount);
@@ -305,15 +326,7 @@ class AccommodationBookingService
); );
} }
// Personal data $this->copyContactData($dto, $booking);
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
$booking->setRemarks($dto->remarks); $booking->setRemarks($dto->remarks);
$this->refreshPriceSnapshot($booking); $this->refreshPriceSnapshot($booking);
@@ -559,7 +572,37 @@ class AccommodationBookingService
* their access link, and swapping the house underneath them would rewrite what they were * their access link, and swapping the house underneath them would rewrite what they were
* offered. * offered.
* Idempotent — a no-op for a booking that is not a draft or already sits at $accommodation. * 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 public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void
{ {
if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) { if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) {
@@ -573,8 +616,18 @@ class AccommodationBookingService
$booking->setBoardServiceOriginalId(null); $booking->setBoardServiceOriginalId(null);
$booking->setAdditionalServices([]); $booking->setAdditionalServices([]);
// Clears the snapshot by itself when the new house has no price for the booked range. try {
$this->refreshPriceSnapshot($booking); // 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(); $this->entityManager->flush();
} }
@@ -627,6 +680,51 @@ class AccommodationBookingService
$this->sendOfferAcceptedNotificationEmail($booking); $this->sendOfferAcceptedNotificationEmail($booking);
} }
/**
* Offers are put together by the office, which only has to supply name and email, so
* the rest of the contact data is often missing. A customer may accept an offer once it
* meets the constraints a self-service booking meets in step 3.
*/
public function hasCompleteContactData(AccommodationBooking $booking): bool
{
return 0 === $this->validator->validate($this->contactDataFromBooking($booking), null, ['contact'])->count();
}
public function contactDataFromBooking(AccommodationBooking $booking): AccommodationBookingDto
{
$dto = new AccommodationBookingDto();
$dto->groupName = $booking->getGroupName();
$dto->salutation = $booking->getSalutation();
$dto->firstName = $booking->getFirstName();
$dto->lastName = $booking->getLastName();
$dto->email = $booking->getEmail();
$dto->phone = $booking->getPhone();
$dto->street = $booking->getStreet();
$dto->zip = $booking->getZip();
$dto->city = $booking->getCity();
return $dto;
}
public function applyContactData(AccommodationBooking $booking, AccommodationBookingDto $dto): void
{
$this->copyContactData($dto, $booking);
$this->entityManager->flush();
}
private function copyContactData(AccommodationBookingDto $dto, AccommodationBooking $booking): void
{
$booking->setGroupName($dto->groupName);
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
}
/** /**
* Explicit office action after the requested services and capacities have been validated: * Explicit office action after the requested services and capacities have been validated:
* this is the moment the booking becomes binding for the customer, and the only place the * this is the moment the booking becomes binding for the customer, and the only place the
+49
View File
@@ -15,6 +15,7 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Form\Service\ServiceAgeEvaluator; use App\Form\Service\ServiceAgeEvaluator;
use App\Model\RoomSelectionAdjustment;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -91,6 +92,54 @@ class BookingConfigurator
return $bookingCreateDto; 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. * Creates a room selection DTO from room data and quantities.
* *
+19
View File
@@ -29,6 +29,9 @@ class BookingEditDataLoader
private bool $draftRestored = false; private bool $draftRestored = false;
/** @var list<string> */
private array $droppedDraftServiceLabels = [];
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
@@ -53,6 +56,17 @@ class BookingEditDataLoader
return $this->draftRestored; return $this->draftRestored;
} }
/**
* Returns the drafted services dropped during the last load because they are no longer
* bookable. Reset on each call to loadFormData(), like isDraftRestored().
*
* @return list<string>
*/
public function getDroppedDraftServiceLabels(): array
{
return $this->droppedDraftServiceLabels;
}
/** /**
* Loads booking data from session or initializes from API. * Loads booking data from session or initializes from API.
* *
@@ -72,6 +86,7 @@ class BookingEditDataLoader
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
{ {
$this->draftRestored = false; $this->draftRestored = false;
$this->droppedDraftServiceLabels = [];
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT); $formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
@@ -177,6 +192,10 @@ class BookingEditDataLoader
// it would tell the user their draft was restored for edits they never made. // it would tell the user their draft was restored for edits they never made.
$this->draftRestored = true === $applied $this->draftRestored = true === $applied
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData); && $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
if (true === $applied) {
$this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels();
}
} }
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); $this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
+30 -2
View File
@@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface;
*/ */
class BookingEditDraftManager class BookingEditDraftManager
{ {
/** @var list<string> */
private array $droppedServiceLabels = [];
public function __construct( public function __construct(
private readonly BookingEditDraftRepository $draftRepository, private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
@@ -32,6 +35,19 @@ class BookingEditDraftManager
) { ) {
} }
/**
* Returns the services dropped during the last applyDraftToDto() call.
*
* Reset on each call, so the caller can report them to the user. Mirrors
* BookingEditDataLoader::isDraftRestored().
*
* @return list<string>
*/
public function getDroppedServiceLabels(): array
{
return $this->droppedServiceLabels;
}
/** /**
* Finds an existing draft for a user and booking combination. * Finds an existing draft for a user and booking combination.
* *
@@ -129,6 +145,9 @@ class BookingEditDraftManager
*/ */
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
{ {
$this->droppedServiceLabels = [];
$droppedServiceLabels = [];
try { try {
$formData = $draft->getFormData(); $formData = $draft->getFormData();
@@ -149,16 +168,25 @@ class BookingEditDraftManager
continue; continue;
} }
$this->participantApplier->apply( $droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply(
$dto, $dto,
$index, $index,
$dto->participants[$index], $dto->participants[$index],
$participantData, $participantData,
$travel, $travel,
); )];
} }
} }
$this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels));
if ([] !== $this->droppedServiceLabels) {
$this->logger->info('Dropped drafted services that are no longer bookable', [
'booking_id' => $draft->getBookingId(),
'services' => $this->droppedServiceLabels,
]);
}
$this->logger->info('Applied draft to booking DTO', [ $this->logger->info('Applied draft to booking DTO', [
'booking_id' => $draft->getBookingId(), 'booking_id' => $draft->getBookingId(),
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'), 'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
+98 -23
View File
@@ -24,14 +24,24 @@ use App\Form\Model\ParticipantDto;
*/ */
class BookingEditDraftMerger class BookingEditDraftMerger
{ {
/** @param array<string, mixed> $data */ public function __construct(
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
) {
}
/**
* @param array<string, mixed> $data
*
* @return list<string> Labels of drafted services dropped because they are no longer bookable
*/
public function apply( public function apply(
BookingDto $bookingDto, BookingDto $bookingDto,
int $participantIndex, int $participantIndex,
ParticipantDto $participant, ParticipantDto $participant,
array $data, array $data,
Travel $travel, Travel $travel,
): void { ): array {
$droppedServiceLabels = [];
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant); $canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
// Personal data // Personal data
@@ -68,10 +78,19 @@ class BookingEditDraftMerger
// Service selections (gated per-category by travel mutability flags) // Service selections (gated per-category by travel mutability flags)
if (isset($data['services']) && true === is_array($data['services'])) { if (isset($data['services']) && true === is_array($data['services'])) {
$this->applyServiceSelections($participant, $data['services'], $travel); $this->applyServiceSelections(
$participant,
$data['services'],
$travel,
$bookingDto,
$participantIndex,
$droppedServiceLabels
);
} }
$participant->normalizeLoadedData(); $participant->normalizeLoadedData();
return array_values(array_unique($droppedServiceLabels));
} }
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
@@ -205,16 +224,25 @@ class BookingEditDraftMerger
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values * Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
* always replace API data, since users can intentionally clear these selections. * always replace API data, since users can intentionally clear these selections.
*/ */
/** @param array<string, mixed> $data */ /**
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void * @param array<string, mixed> $data
{ * @param list<string> $droppedServiceLabels Collects what was dropped, by reference
*/
private function applyServiceSelections(
ParticipantDto $participant,
array $data,
Travel $travel,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): void {
// Additional services category — only apply draft data when services are mutable. // Additional services category — only apply draft data when services are mutable.
// When immutable, the booking's current services must be preserved as-is to avoid // When immutable, the booking's current services must be preserved as-is to avoid
// API rejection (stale draft data could differ from the locked booking state). // API rejection (stale draft data could differ from the locked booking state).
if (true === $travel->additionalServicesMutable) { if (true === $travel->additionalServicesMutable) {
// Ski pass (single service) - merge strategy: only apply if resolves to valid service // Ski pass (single service) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) { if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices); $resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->skiPass = $resolved; $participant->skiPass = $resolved;
} }
@@ -222,17 +250,17 @@ class BookingEditDraftMerger
// Courses (array) - overwrite strategy: user can deselect all // Courses (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) { if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices); $participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Board (array) - overwrite strategy: user can deselect all // Board (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) { if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices); $participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Veg (single service) - merge strategy: only apply if resolves to valid service // Veg (single service) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('veg', $data) && null !== $data['veg']) { if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
$resolved = $this->resolveService($data['veg'], $travel->additionalServices); $resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->veg = $resolved; $participant->veg = $resolved;
} }
@@ -240,12 +268,12 @@ class BookingEditDraftMerger
// Rentals (array) - overwrite strategy: user can deselect all // Rentals (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) { if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices); $participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Rental insurance (single) - merge strategy: only apply if resolves to valid service // Rental insurance (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) { if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices); $resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->rentalInsurance = $resolved; $participant->rentalInsurance = $resolved;
$participant->rentalInsuranceSelected = true; $participant->rentalInsuranceSelected = true;
@@ -255,7 +283,7 @@ class BookingEditDraftMerger
// Additional services (array) - overwrite strategy with mandatory service preservation // Additional services (array) - overwrite strategy with mandatory service preservation
// User can deselect optional services, but mandatory services from API must be preserved // User can deselect optional services, but mandatory services from API must be preserved
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) { if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); $resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
$participant->additionalServices = $this->preserveMandatoryServices( $participant->additionalServices = $this->preserveMandatoryServices(
$resolvedFromDraft, $resolvedFromDraft,
$participant->additionalServices, $participant->additionalServices,
@@ -268,7 +296,7 @@ class BookingEditDraftMerger
if (true === $travel->transportationServicesMutable) { if (true === $travel->transportationServicesMutable) {
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service // Transportation outbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) { if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices); $resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->transportationOutbound = $resolved; $participant->transportationOutbound = $resolved;
} }
@@ -276,7 +304,7 @@ class BookingEditDraftMerger
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service // Transportation inbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) { if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices); $resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->transportationInbound = $resolved; $participant->transportationInbound = $resolved;
} }
@@ -368,37 +396,84 @@ class BookingEditDraftMerger
/** /**
* @param array<int, object> $services * @param array<int, object> $services
* @param list<string> $droppedServiceLabels
*/ */
private function resolveService(?int $serviceId, array $services): ?object private function resolveService(
{ ?int $serviceId,
array $services,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): ?object {
if (null === $serviceId) { if (null === $serviceId) {
return null; return null;
} }
return $services[$serviceId] ?? null; $service = $services[$serviceId] ?? null;
if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
$droppedServiceLabels[] = (string) $service->label;
return null;
}
return $service;
} }
/** /**
* @param array<int, int> $serviceIds * @param array<int, int> $serviceIds
* @param array<int, object> $services * @param array<int, object> $services
* @param list<string> $droppedServiceLabels
* *
* @return array<int, object> * @return array<int, object>
*/ */
private function resolveServiceArray(array $serviceIds, array $services): array private function resolveServiceArray(
{ array $serviceIds,
array $services,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): array {
$resolved = []; $resolved = [];
$addedIds = []; $addedIds = [];
foreach ($serviceIds as $serviceId) { foreach ($serviceIds as $serviceId) {
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) { if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) {
$resolved[] = $services[$serviceId]; continue;
$addedIds[$serviceId] = true;
} }
$service = $services[$serviceId];
if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
$droppedServiceLabels[] = (string) $service->label;
$addedIds[$serviceId] = true;
continue;
}
$resolved[] = $service;
$addedIds[$serviceId] = true;
} }
return $resolved; return $resolved;
} }
/**
* Checks whether a drafted selection must not be restored.
*
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on
* every re-entry and BusPro rejects the whole update again. Services the participant already
* holds are carved out by the availability rule itself, so they still restore.
*/
private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (false === $service instanceof Service || null === $service->id) {
return false;
}
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
}
private function resolvePickup(?int $pickupId, Travel $travel): ?object private function resolvePickup(?int $pickupId, Travel $travel): ?object
{ {
if (null === $pickupId) { if (null === $pickupId) {
+63
View File
@@ -24,9 +24,72 @@ class BookingEditSubmitGuard
{ {
public function __construct( public function __construct(
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
) { ) {
} }
/**
* Reverts selections of services that are no longer bookable.
*
* The read-only state in the participant form is presentation only, so a stale session or
* a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'.
* Sending one makes BusPro reject the entire update - every participant's changes with it -
* so the selection is reverted to what the live booking already holds.
*
* Services the participant already holds are never touched: the availability rule carves
* them out, and withdrawing one would break the Leistung/Teilnehmer counts.
*
* Expects $workingDto->booking to be the fresh booking, as the availability rule reads its
* already-held carve-out from there.
*
* @return list<string> Labels of the services that were reverted, for user feedback
*/
public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array
{
$baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking());
$revertedLabels = [];
foreach ($workingDto->participants as $index => $participant) {
$baseline = $baselineDto->participants[$index] ?? null;
if (null === $baseline) {
continue;
}
$isBlocked = fn (?Service $service): bool => null !== $service
&& null !== $service->id
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index);
// Multi-selection fields: drop the blocked additions, keep everything else
foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) {
$kept = [];
foreach ($participant->{$field} as $service) {
if (true === $isBlocked($service)) {
$revertedLabels[] = (string) $service->label;
continue;
}
$kept[] = $service;
}
if (count($kept) !== count($participant->{$field})) {
$participant->{$field} = $kept;
}
}
// Single-selection fields: fall back to what the booking already holds rather than
// clearing, so a required field does not end up empty
foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) {
if (false === $isBlocked($participant->{$field})) {
continue;
}
$revertedLabels[] = (string) $participant->{$field}->label;
$participant->{$field} = $baseline->{$field};
}
}
return array_values(array_unique($revertedLabels));
}
/** /**
* Reverts immutable category changes to fresh booking values. * Reverts immutable category changes to fresh booking values.
* *
+27 -1
View File
@@ -63,11 +63,32 @@ class BookingEditSubmitter
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData); $this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
} }
// The guard checks service status and contingent, so it needs the live figures rather
// than whatever was cached when the edit session started.
$availabilities = $this->travelDataService->getAvailabilityData(
$freshBookingData->dateId,
cached: true,
forceRefresh: true
);
if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
}
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData); $immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted) { $unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
} }
if ([] !== $unbookableServicesReverted) {
$this->logger->warning('Reverted selections of services that are no longer bookable', [
'email' => $email,
'booking_id' => $bookingId,
'services' => $unbookableServicesReverted,
]);
}
try { try {
$response = $this->apiClient->updateBooking($bookingDto, true); $response = $this->apiClient->updateBooking($bookingDto, true);
if ($response instanceof Notification) { if ($response instanceof Notification) {
@@ -83,6 +104,7 @@ class BookingEditSubmitter
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO, : BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
$response->message, $response->message,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
@@ -99,6 +121,7 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_SUCCESS, BookingEditSubmissionResult::STATUS_SUCCESS,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
@@ -113,6 +136,7 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL, BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
$response->status ?? 'Buchung konnte nicht aktualisiert werden', $response->status ?? 'Buchung konnte nicht aktualisiert werden',
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} catch (TimeoutException) { } catch (TimeoutException) {
$this->logger->error('Booking update timeout', [ $this->logger->error('Booking update timeout', [
@@ -124,12 +148,14 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_TIMEOUT, BookingEditSubmissionResult::STATUS_TIMEOUT,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} catch (ApiClientException) { } catch (ApiClientException) {
return new BookingEditSubmissionResult( return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR, BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
} }
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\NewsletterOptInContext;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use Psr\Log\LoggerInterface;
/**
* Decides who a booking may enrol in the newsletter and starts the double opt-in cycles.
*
* The applicant opts in for themselves; everyone else is a third-party sign-up, which is why
* companions are restricted to adults with an address of their own and always get the
* companion wording naming who requested it.
*/
class BookingNewsletterOptIn
{
public function __construct(
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
) {
}
public function resolveApplicantEmail(BookingDto $bookingDto, ?User $currentUser): ?string
{
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/**
* Companions eligible for a third-party opt-in offer.
*
* Children are excluded because they are allowed to share an adult's address, so subscribing
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
*
* @return list<ParticipantDto>
*/
public function resolveCompanions(BookingDto $bookingDto, ?string $applicantEmail): array
{
$companions = [];
$seenEmails = [];
foreach ($bookingDto->participants as $participant) {
if (true === $participant->isApplicant() || true === $participant->isChild()) {
continue;
}
if (null === $participant->email) {
continue;
}
$normalizedEmail = mb_strtolower(trim($participant->email));
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
continue;
}
if ($normalizedEmail === $applicantEmail || true === isset($seenEmails[$normalizedEmail])) {
continue;
}
$seenEmails[$normalizedEmail] = true;
$companions[] = $participant;
}
return $companions;
}
public function subscribeApplicant(string $email, ?ParticipantDto $applicant): void
{
// Everything here runs after the booking API has accepted the booking, so nothing may
// escape: an exception would reach the controller's generic handler, show a technical
// error and leave the booking session intact, inviting a duplicate booking.
try {
$this->newsletterManager->requestConfirmation(
$email,
$applicant?->firstName,
$applicant?->lastName,
);
} catch (\Throwable $exception) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
/**
* @param list<ParticipantDto> $companions
*/
public function subscribeCompanions(array $companions, ?string $requestedByName): void
{
foreach ($companions as $companion) {
$email = mb_strtolower(trim((string) $companion->email));
// The booking already succeeded, so one unusable address must not stop the others and
// must never surface as a booking failure. The consent lookups are inside the block for
// the same reason: a repository failure there would otherwise reach the controller's
// generic handler and show a technical error for a booking that already exists.
try {
// Skipping a pending request avoids replacing a token the companion asked for
// themselves, which would silently break the link they already hold.
if (true === $this->newsletterManager->hasConfirmedOptIn($email)
|| true === $this->newsletterManager->hasPendingConfirmation($email)) {
continue;
}
$this->newsletterManager->requestConfirmation(
$email,
$companion->firstName,
$companion->lastName,
NewsletterOptInContext::Companion,
$requestedByName,
);
} catch (\Throwable $exception) {
$this->logger->warning('Companion newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
}
public function resolveRequestedByName(BookingDto $bookingDto): ?string
{
$applicant = $this->resolveApplicantParticipant($bookingDto);
if (null === $applicant) {
return null;
}
$name = trim(sprintf('%s %s', $applicant->firstName ?? '', $applicant->lastName ?? ''));
return '' !== $name ? $name : null;
}
}
+5 -2
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Constants;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -161,7 +162,9 @@ class BookingPricingAssembler
} }
$hasOutboundBus = null !== $participant->transportationOutbound $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 ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
if ($participant->pickup->price < 0) { 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) { if ($participant->dropOff->price < 0) {
$participantTransportationDiscountCost += $participant->dropOff->price; $participantTransportationDiscountCost += $participant->dropOff->price;
} else { } else {
+17
View File
@@ -97,6 +97,15 @@ class DatabaseAnonymizer
return $report; return $report;
} }
/**
* Looked up by email alone, never by name: resolveIdentity() would register a name alias,
* and two accounts of the same person — an intended state here — would then collapse onto
* one synthetic identity and so onto one synthetic email, which User::$email is unique on.
*
* The names are only replaced where one is already set. Most accounts carry none until the
* BusPro import fills them in, and minting a name for those would turn an incomplete profile
* into one that looks complete while profileComplete still says otherwise.
*/
public function anonymizeUser(User $user): void public function anonymizeUser(User $user): void
{ {
$identity = $this->resolveIdentity( $identity = $this->resolveIdentity(
@@ -104,6 +113,14 @@ class DatabaseAnonymizer
); );
$user->setEmail($identity['email']); $user->setEmail($identity['email']);
if ($this->hasReplaceableValue($user->getFirstName())) {
$user->setFirstName($identity['firstName']);
}
if ($this->hasReplaceableValue($user->getLastName())) {
$user->setLastName($identity['lastName']);
}
} }
public function anonymizeNewsletterConsent(NewsletterConsent $consent): void public function anonymizeNewsletterConsent(NewsletterConsent $consent): void
+19 -5
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Email\Mailer; use App\Email\Mailer;
use App\Entity\NewsletterConsent; use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest; use App\Entity\NewsletterOptInRequest;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterListNotAllowedException;
use App\Exception\NewsletterProviderException; use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient; use App\Mailjet\ApiClient;
@@ -37,8 +38,13 @@ class NewsletterManager
) { ) {
} }
public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void public function requestConfirmation(
{ string $email,
?string $firstName = null,
?string $lastName = null,
NewsletterOptInContext $optInContext = NewsletterOptInContext::Own,
?string $requestedByName = null,
): void {
$normalizedEmail = $this->normalizeEmail($email); $normalizedEmail = $this->normalizeEmail($email);
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
@@ -51,6 +57,8 @@ class NewsletterManager
mailjetListIds: [$this->defaultMailjetListId()], mailjetListIds: [$this->defaultMailjetListId()],
firstName: $this->normalizeName($firstName), firstName: $this->normalizeName($firstName),
lastName: $this->normalizeName($lastName), lastName: $this->normalizeName($lastName),
optInContext: $optInContext,
requestedByName: $this->normalizeName($requestedByName),
); );
} }
@@ -210,7 +218,7 @@ class NewsletterManager
/** /**
* @param list<int|string> $mailjetListIds * @param list<int|string> $mailjetListIds
*/ */
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null, NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, ?string $requestedByName = null): void
{ {
$token = $this->generateToken(); $token = $this->generateToken();
$tokenHash = $this->hashToken($token); $tokenHash = $this->hashToken($token);
@@ -255,11 +263,13 @@ class NewsletterManager
$context = [ $context = [
'token' => $token, 'token' => $token,
'newsletterLists' => $this->createListIdMapping($mailjetListIds), 'newsletterLists' => $this->createListIdMapping($mailjetListIds),
'recipientFirstName' => $firstName,
'requestedByName' => $requestedByName,
]; ];
$options = [ $options = [
'to' => $normalizedEmail, 'to' => $normalizedEmail,
'subject' => 'Deine Anmeldung zum E&P Newsletter', 'subject' => $optInContext->subject(),
'template' => 'email/newsletter_opt_in.html.twig', 'template' => $optInContext->template(),
]; ];
$this->mailer->createAndSendEmail($context, $options); $this->mailer->createAndSendEmail($context, $options);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
@@ -275,9 +285,13 @@ class NewsletterManager
throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception); throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception);
} }
// The log line is the provenance record for third-party sign-ups: there is no column
// saying who requested a companion's opt-in, so it has to be recoverable from here.
$this->logger->info('Newsletter confirmation requested', [ $this->logger->info('Newsletter confirmation requested', [
'email' => $normalizedEmail, 'email' => $normalizedEmail,
'expires_at' => $expiresAt->format(DATE_ATOM), 'expires_at' => $expiresAt->format(DATE_ATOM),
'context' => $optInContext->value,
'requested_by' => $requestedByName,
]); ]);
} }
+6 -6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -193,18 +194,17 @@ class ParticipantPricingCalculator
} }
} }
// Pickup pricing - only charged when outbound transportation is BUS // Boarding surcharges are charged per leg, at the same rate in both directions
// 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
$hasOutboundBus = null !== $participant->transportationOutbound $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 ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
$serviceTotal += $participant->pickup->price; $serviceTotal += $participant->pickup->price;
} }
// Drop-off pricing (charged when inbound transportation is BUS and a drop-off is selected) if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
$serviceTotal += $participant->dropOff->price; $serviceTotal += $participant->dropOff->price;
} }
+94 -2
View File
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
* *
* Delegates to isServiceUnavailable() so that both entry points share one rule set. * Delegates to isServiceUnavailable() so that both entry points share one rule set.
* *
* Never empties a non-empty choice group on the on-request rule alone: on some termine
* every outbound transport option or every ski pass is 'Anfrage', and an empty required
* group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but
* none selectable" as the participant being unable to travel. Sold-out and Buchungsstop
* still empty a group, preserving the pre-existing semantics. Inert in create mode, where
* the on-request rule cannot fire.
*
* @param array<int, Service> $services Array of Service objects to filter * @param array<int, Service> $services Array of Service objects to filter
* @param BookingDto $bookingDto The booking data with participant selections * @param BookingDto $bookingDto The booking data with participant selections
* @param int $participantIndex The index of the participant currently filling the form * @param int $participantIndex The index of the participant currently filling the form
@@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator
*/ */
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
{ {
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) { $filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
// Services without an ID cannot be resolved against travel data - treat as available // Services without an ID cannot be resolved against travel data - treat as available
if (null === $service->id) { if (null === $service->id) {
return true; return true;
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex); return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
}); });
if ([] !== $filtered || [] === $services) {
return $filtered;
}
return array_filter(
$services,
fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto)
&& Constants::STATUS_BLOCKED !== $service->status
&& false === $this->isContingentExhausted($service, $bookingDto, $participantIndex)
);
} }
/** /**
@@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator
* contingent is exhausted, either at the API level or through selections made by the * contingent is exhausted, either at the API level or through selections made by the
* other participants of the current booking. * other participants of the current booking.
* *
* In edit mode one further rule applies: a service that is only available on request
* (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at
* status 'F', and BusPro derives a Leistung's status from the travel data and refuses to
* attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status
* des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem,
* because the booking status can still move to 'A' there - hence the mode precondition.
*
* The rule is asymmetric: a service the participant already holds in the live booking
* stays available, so it can be kept and re-sent.
*
* @param int $serviceId The ID of the service to check * @param int $serviceId The ID of the service to check
* @param BookingDto $bookingDto The booking data with participant selections * @param BookingDto $bookingDto The booking data with participant selections
* @param int $participantIndex The index of the participant currently filling the form * @param int $participantIndex The index of the participant currently filling the form
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
return false; return false;
} }
// A service the participant already holds is always keepable, whatever its state now
if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) {
return false;
}
// A booking stop blocks the service regardless of its contingent // A booking stop blocks the service regardless of its contingent
if (Constants::STATUS_BLOCKED === $service->status) { if (Constants::STATUS_BLOCKED === $service->status) {
return true; return true;
} }
// On request cannot be added to a participant whose status is already fixed
if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) {
return true;
}
return $this->isContingentExhausted($service, $bookingDto, $participantIndex);
}
/**
* Checks whether a service has no contingent left.
*
* Covers both the API-level figure and the seats taken by the other participants of the
* current booking.
*/
private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
// If no availability tracking (null), service is unlimited and available // If no availability tracking (null), service is unlimited and available
if (null === $service->available) { if (null === $service->available) {
return false; return false;
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
// For services with positive availability, calculate remaining based on booking selections // For services with positive availability, calculate remaining based on booking selections
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex); $remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0; return ($remainingAvailability[$service->id] ?? $service->available) <= 0;
}
/**
* Checks whether the on-request rule blocks a service.
*
* Only applies in edit mode, and never to services the form would then be unable to
* satisfy: auto-booked services and mandatory services outside the transport categories
* are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the
* form unsatisfiable. Mandatory transport legs are deliberately not exempt - there
* pflicht="True" means "pick one of this group" rather than "compulsory", and exempting
* them would exempt the bus legs this rule exists for.
*/
private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool
{
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
return false;
}
if (Constants::STATUS_ON_REQUEST !== $service->status) {
return false;
}
if (true === $service->autoBook) {
return false;
}
return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category);
}
/**
* Checks whether the participant already holds the service in the live booking.
*
* Reads the BusPro-side baseline (BookingDto::$booking), never the working selection,
* which already carries whatever the customer just picked. Always false in create mode,
* where there is no booking yet.
*/
private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (null === $bookingDto->booking || null === $service->id) {
return false;
}
return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id);
} }
/** /**
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
*
* Built from the XML file map rather than the travel_snapshot table. The table sits at
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
*
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
*/
class TravelExportBuilder
{
use TypeConversionTrait;
private const CACHE_KEY = 'bpn_travels_export';
public function __construct(
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly TravelParser $travelParser,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): TravelExport
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
return $this->assemble();
});
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
}
}
private function assemble(): TravelExport
{
$hotels = $this->hotelLoader->loadAll();
$pickupMasterData = $this->pickupLoader->loadAll();
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
$rows = [];
$skipped = 0;
/** @var array<int, true> $pickupsWithoutMasterData */
$pickupsWithoutMasterData = [];
$files = $this
->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
foreach ($files as $file) {
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
$crawler->filterXPath('//reisen/reise/termin')->each(
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
}
);
}
return new TravelExport(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
);
}
/**
* @param array<string, array<string, mixed>> $rows
* @param array<int, true> $pickupsWithoutMasterData
* @param array<int, Hotel> $hotels
* @param array<int|string, Pickup> $pickupMasterData
*/
private function collectTravel(
Crawler $node,
array &$rows,
int &$skipped,
array &$pickupsWithoutMasterData,
array $hotels,
array $pickupMasterData,
): void {
$hotelNodes = $node->filterXPath('.//hotel');
// Dates first: getPickups() needs a non-null default date for the time-only zeit
// shape, and parsing them up front is also what turns an unusable date into a
// dropped row rather than a row with a null date the consumer cannot key on.
$dateFrom = $this->stringToDate($node->attr('termin'));
$dateTo = $this->stringToDate($node->attr('bis'));
if (null === $dateFrom || null === $dateTo) {
$this->logger->warning('Travel export: unparseable travel date', [
'travel_id' => $node->attr('idbuspro'),
'termin' => $node->attr('termin'),
'bis' => $node->attr('bis'),
]);
$skipped += $hotelNodes->count();
return;
}
$travelId = (int) $node->attr('idbuspro');
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
// calendar day. Resolved once per travel date, then shared by its hotel rows.
$pickups = $this->buildPickups(
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
$pickupMasterData,
$pickupsWithoutMasterData,
);
$travel = [
'busProId' => $travelId,
'code' => $node->attr('code'),
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
];
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// Defensive: no travel in the current snapshot references a hotel that
// hotel.xml does not carry. A row without master data would reach the consumer
// with no name and no country, so it is dropped and counted instead.
if (false === isset($hotels[$hotelId])) {
$this->logger->warning('Travel export: hotel missing from master data', [
'travel_id' => $travelId,
'hotel_id' => $hotelId,
]);
++$skipped;
return;
}
$hotel = $hotels[$hotelId];
// Keyed by the pair the consumer upserts on, so a date repeated across files
// collapses last-wins instead of reaching it as a duplicate.
$rows[$travelId.'-'.$hotelId] = $travel + [
'hotel' => [
'busProId' => $hotelId,
'code' => $hotel->code,
'name' => $hotel->name,
'costUnit' => $hotel->costUnit,
'country' => $hotel->country,
'countryCode' => $hotel->countryCode,
],
'pickups' => $pickups,
];
});
}
/**
* Joins the travel XML's pickups against zustiege.xml.
*
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
* contract is source order, which is the order the disposition lists them in. Nor
* enrichPickupDetails(), which is private and passes over a miss silently.
*
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
* @return list<array<string, mixed>>
*/
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
foreach ($pickups as $pickup) {
$pickupId = $pickup->id;
// Defensive: none exist today. An entry with no id is not addressable by the
// consumer, so drop the entry -- never the row it belongs to.
if (null === $pickupId || 0 === $pickupId) {
continue;
}
$details = $masterData[$pickupId] ?? null;
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
// entry is emitted anyway: dropping it would hide a real departure point from
// the disposition UI, where a nameless stop is still better than a missing one.
if (null === $details) {
$pickupsWithoutMasterData[$pickupId] = true;
}
$resolved[] = [
'busProId' => $pickupId,
'code' => $details?->code,
'city' => $details?->city,
'street' => $details?->street,
'time' => $pickup->time?->format('H:i'),
// Emitted for time-only sources too: adjustOvernightTimes() has already
// worked out the real day, and a null here would throw that away.
'datetime' => $pickup->time?->format('c'),
];
}
return $resolved;
}
private function getStringOrNullValue(Crawler $node): ?string
{
return 0 < $node->count() ? $node->text() : null;
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
+1 -13
View File
@@ -1,18 +1,6 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a href="{{ path('app_account') }}" title="zu meinem Account"> <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 #} {% include '_partials/_logo.html.twig' %}
{% 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 %}
</a> </a>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{% if is_granted('ROLE_USER') %} {% if is_granted('ROLE_USER') %}
+47
View File
@@ -0,0 +1,47 @@
{# The login form itself, shared by security/login.html.twig and security/login_oauth2.html.twig so
the field names, the CSRF token id and the error rendering cannot drift between the two pages.
Expects `error` and `last_username` from the including template's context. #}
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post">
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<div class="relative">
<input type="password"
id="password"
name="_password"
class="form-field"
{{ stimulus_target('password-reveal', 'field') }}
required
autocomplete="current-password">
<button type="button"
{{ stimulus_action('password-reveal', 'toggle') }}
class="absolute top-0 right-0 mt-2 mr-2">
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
<button type="submit" class="button button--primary">
Login
</button>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
+15
View File
@@ -0,0 +1,15 @@
{# The brand logo, extracted from _header.html.twig so the OAuth2 login chrome can show the same
logo without inheriting the account links that go with it. Takes an optional `accommodation`. #}
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #}
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
{% if country_logo %}
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
{% elseif booking_theme() == 'sbw' %}
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'ser' %}
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'snz' %}
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
{% else %}
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
{% endif %}
@@ -97,6 +97,11 @@
<twig:dropdown:link url="{{ edit_url }}"> <twig:dropdown:link url="{{ edit_url }}">
bearbeiten bearbeiten
</twig:dropdown:link> </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 %} {% if booking.confirmed %}
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}"> <twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
PDF herunterladen 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') %} {% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %} {% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %} {% 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"> <a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
bearbeiten bearbeiten
</a> </a>
@@ -486,6 +486,11 @@
'label_attr': {'class': 'text-base font-semibold'} 'label_attr': {'class': 'text-base font-semibold'}
}) }} }) }}
{% endif %} {% endif %}
{% if form.companionNewsletterOptIn is defined and companionNewsletterOptInVisible %}
{{ form_row(form.companionNewsletterOptIn, {
'label_attr': {'class': 'text-base font-semibold'}
}) }}
{% endif %}
{{ form_row(form.confirmationAccepted, { {{ form_row(form.confirmationAccepted, {
'label_attr': {'class': 'text-base font-semibold'} 'label_attr': {'class': 'text-base font-semibold'}
}) }} }) }}
@@ -0,0 +1,33 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo{% if recipientFirstName %} {{ recipientFirstName }}{% endif %}!
</h1>
<p>
{% if requestedByName %}{{ requestedByName }} hat{% else %}Die anmeldende Person hat{% endif %}
bei der Buchung eurer gemeinsamen Reise angegeben, dass du unseren Newsletter erhalten möchtest.
</p>
{% if newsletterLists is defined and newsletterLists is not empty %}
<p>
Bitte bestätige selbst, dass du die folgende Newsletter erhalten möchtest:
{% for newsletterList in newsletterLists %}
<br>
- {{ newsletterList.label }}
{% endfor %}
</p>
{% endif %}
<p>
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
Newsletter-Anmeldung bestätigen
</a>
</p>
<p>
Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen.
</p>
<p>
<a href="{{ url('app_login') }}" class="button">
Zu MyE&P
</a>
</p>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends 'layout_booking.html.twig' %}
{% block title %}Kontaktdaten vervollständigen{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
{% form_theme form 'booking/_form_theme.html.twig' %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/offer/_summary.html.twig' with {
booking: booking,
ctx: ctx,
currency: currency,
} %}
</div>
{# Contact data form #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include '_partials/_flashes.html.twig' %}
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
<hgroup class="pb-4">
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
Kontaktdaten vervollständigen
</h1>
<p>
Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir
noch deine vollständigen Kontaktdaten.
</p>
</hgroup>
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
{{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.groupName) }}
{{ form_row(form.salutation) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
</div>
<h3 class="pt-4 pb-2">Adresse</h3>
{{ form_row(form.street) }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex items-center justify-between">
<a href="{{ path('app_groups_offer_view', { uuid: booking.uuid }) }}" class="button button--secondary">
Zurück zum Angebot
</a>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{# Chrome for a login that is serving an external OAuth2 authorization.
Only the logo survives from the normal header: the account and logout links are dead ends in the
middle of an authorization, and the marketing column of the standard login page is noise when the
user did not come here to read about MyE&P. The footer's legal links stay -- this page collects
credentials, so dropping them would be the wrong direction.
The background is inherited unchanged, which is why the shared form partial's `text-white`
styling still works here. #}
{% extends 'layout.html.twig' %}
{% block header %}
{% include '_partials/_logo.html.twig' %}
{% endblock %}
+1 -44
View File
@@ -7,50 +7,7 @@
Login<br>MyE&amp;P Login<br>MyE&amp;P
</h1> </h1>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
{% if error %} {% include '_partials/_login_form.html.twig' %}
{% 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>
<div class="flex space-x-2"> <div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}" <a href="{{ path('app_reset_password') }}"
class="text-white underline"> class="text-white underline">
+37
View File
@@ -0,0 +1,37 @@
{# The login page as seen by someone who was sent here by another E&P application (ephub, myep-team)
to authorize it. Its job is to explain why they are suddenly on a different website and that they
will be sent back, which the standard login page never said.
The grid is the one from security/login.html.twig, minus its marketing column: that keeps the
form column at exactly the width it has on the standard login page. #}
{% extends 'layout_oauth2.html.twig' %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
{% if client_name %}
Anmelden für<br>{{ client_name }}
{% else %}
Login<br>MyE&amp;P
{% endif %}
</h1>
<p class="mb-6 text-white">
Melde dich mit deinem MyE&amp;P-Konto an. Anschließend wirst du automatisch
{% if client_name %}zu {{ client_name }} {% endif %}zurückgeleitet.
</p>
{% include '_partials/_login_form.html.twig' %}
<div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}" class="text-white underline">
Passwort vergessen?
</a>
<a href="{{ path('app_registration') }}" class="text-white underline">
Jetzt registrieren
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends 'layout.html.twig' %}
{% block title %}Kein Zugriff{% endblock %}
{% block content %}
<div class="px-4 lg:px-8 py-8 lg:py-16 max-w-2xl">
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Kein Zugriff
</h1>
<p class="mb-4 text-white">
Du bist bei MyE&amp;P angemeldet, aber dein Konto ist nicht für
<strong>{{ client }}</strong> freigeschaltet.
</p>
<p class="mb-8 text-white">
Das liegt an den Berechtigungen deines Kontos, nicht an deinem Passwort &ndash;
ein erneuter Login ändert daran nichts. Wenn du hier Zugriff brauchst, wende
dich bitte an dein E&amp;P-Team.
</p>
<a href="{{ path('app_account') }}" class="button button--primary">
Zurück zu MyE&amp;P
</a>
</div>
{% endblock %}
+52 -2
View File
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException; use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\XmlParser\ApiResponseParser; use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\RequestIdGenerator; use App\Service\RequestIdGenerator;
@@ -70,7 +71,7 @@ class ApiClientReceiveTest extends TestCase
$this->invokeReceive($stream); $this->invokeReceive($stream);
} }
public function testNonEmptyResponseReturnsData(): void public function testNonEmptyResponseReturnsPayloadWithoutTheLengthHeader(): void
{ {
$stream = fopen('php://memory', 'r+'); $stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000005Hello'); fwrite($stream, '0000000005Hello');
@@ -80,7 +81,56 @@ class ApiClientReceiveTest extends TestCase
$result = $this->invokeReceive($stream); $result = $this->invokeReceive($stream);
self::assertSame('0000000005Hello', $result); self::assertSame('Hello', $result);
}
/**
* A peer closing mid-stream used to yield a silently shortened payload, which only
* surfaced later as an unexplained parse failure. The announced length catches it here.
*/
public function testTruncatedResponseThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000050Hello');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Truncated response, got 5 of 50 announced bytes');
$this->invokeReceive($stream);
}
public function testResponseShorterThanTheLengthHeaderThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '00000');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Incomplete response header, got 5 of 10 bytes');
$this->invokeReceive($stream);
}
public function testHeaderOnlyResponseThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000000');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Response announced an empty body (header "0000000000")');
$this->invokeReceive($stream);
} }
private function invokeReceive($socket): string private function invokeReceive($socket): string
@@ -3,9 +3,11 @@
namespace App\Tests\BusProNet\DataLoader; namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapter;
@@ -34,7 +36,7 @@ class HotelDataLoaderTest extends TestCase
->willReturn($xmlContent); ->willReturn($xmlContent);
$cache = new TagAwareAdapter(new ArrayAdapter()); $cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new HotelLoader($cache, $filesystem); $loader = new HotelLoader(new CountryCodeMapper(new NullLogger()), $cache, $filesystem);
$hotels = $loader->loadAll(); $hotels = $loader->loadAll();
@@ -50,5 +52,7 @@ class HotelDataLoaderTest extends TestCase
$this->assertEquals('F', $hotel->country); $this->assertEquals('F', $hotel->country);
$this->assertEquals('Rue des Lacs', $hotel->street); $this->assertEquals('Rue des Lacs', $hotel->street);
$this->assertEquals('', $hotel->phone); $this->assertEquals('', $hotel->phone);
$this->assertEquals('fr', $hotel->countryCode);
$this->assertNull($hotel->costUnit);
} }
} }
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\Utility;
use App\BusProNet\Utility\CountryCodeMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
class CountryCodeMapperTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function tokenProvider(): array
{
return [
'iso-2 passes through' => ['CH', 'ch'],
'vehicle code D' => ['D', 'de'],
'iso-3 DEU' => ['DEU', 'de'],
'vehicle code A' => ['A', 'at'],
'iso-2 AT' => ['AT', 'at'],
'iso-3 AUT' => ['AUT', 'at'],
'vehicle code F' => ['F', 'fr'],
'vehicle code I' => ['I', 'it'],
'iso-2 IT' => ['IT', 'it'],
'vehicle code E' => ['E', 'es'],
'vehicle code B' => ['B', 'be'],
'vehicle code S is Sweden' => ['S', 'se'],
'vehicle code N is Norway' => ['N', 'no'],
'vehicle code FL is Liechtenstein' => ['FL', 'li'],
'IR is Ireland, not Iran' => ['IR', 'ie'],
'UK maps to the iso GB' => ['UK', 'gb'],
'iso-3 PRT' => ['PRT', 'pt'],
'iso-3 GRE' => ['GRE', 'gr'],
'iso-3 HRV' => ['HRV', 'hr'],
'iso-3 RUS' => ['RUS', 'ru'],
'iso-3 USA' => ['USA', 'us'],
'SI is Slovenia' => ['SI', 'si'],
'SLO is Slovakia' => ['SLO', 'sk'],
'ZA is South Africa' => ['ZA', 'za'],
];
}
#[DataProvider('tokenProvider')]
public function testMapsKnownToken(string $token, string $expected): void
{
$this->assertSame($expected, (new CountryCodeMapper(new NullLogger()))->map($token));
}
public function testEveryMappedCodeIsLowercaseIsoTwo(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
foreach (array_keys(self::tokenProvider()) as $name) {
[$token] = self::tokenProvider()[$name];
$code = $mapper->map($token);
$this->assertNotNull($code);
$this->assertMatchesRegularExpression('/^[a-z]{2}$/', $code, $token.' must map to lowercase ISO-2');
}
}
public function testIsCaseAndWhitespaceInsensitive(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
$this->assertSame('ch', $mapper->map('ch'));
$this->assertSame('at', $mapper->map(' aut '));
}
public function testUnknownTokenMapsToNullAndWarnsOnce(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->once())
->method('warning')
->with('Unmapped BusPro country token', ['token' => 'XX']);
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map('XX'));
$this->assertNull($mapper->map('XX'));
}
public function testAbsentTokenMapsToNullWithoutWarning(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->never())
->method('warning');
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map(null));
$this->assertNull($mapper->map(''));
$this->assertNull($mapper->map(' '));
}
}
+65 -1
View File
@@ -5,10 +5,14 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader; namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\DomCrawler\Crawler;
class HotelLoaderTest extends TestCase class HotelLoaderTest extends TestCase
{ {
@@ -19,7 +23,7 @@ class HotelLoaderTest extends TestCase
->expects($this->never()) ->expects($this->never())
->method('read'); ->method('read');
$loader = new HotelLoader(new ArrayAdapter(), $filesystem); $loader = $this->createLoader($filesystem);
$travel = new Travel(); $travel = new Travel();
$travel->hotelId = null; $travel->hotelId = null;
$travel->hotel = null; $travel->hotel = null;
@@ -28,4 +32,64 @@ class HotelLoaderTest extends TestCase
$this->assertNull($travel->hotel); $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,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Constants;
use App\BusProNet\Model\Availability;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
*
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
* availability rule read the status from the nightly XML export instead.
*/
class TravelLoaderAvailabilityTest extends TestCase
{
private TravelLoader $loader;
protected function setUp(): void
{
$this->loader = new TravelLoader(
$this->createStub(HotelLoader::class),
$this->createStub(TravelParser::class),
'https://example.test',
$this->createStub(CacheInterface::class),
$this->createStub(FilesystemOperator::class),
);
}
public function testPatchesStatusAlongsideAvailability(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
);
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
$this->assertSame(3, $service->available);
}
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, null, 3)])
);
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
$this->assertSame(3, $service->available);
}
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, ' ', 3)])
);
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
}
public function testPatchesTransportationServicesToo(): void
{
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
$travel = new Travel();
$travel->transportationServices = [80 => $service];
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
);
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
$this->assertSame(0, $service->available);
}
/** @param array<int, Service> $services */
private function createTravel(array $services): Travel
{
$travel = new Travel();
$indexed = [];
foreach ($services as $service) {
$indexed[$service->id] = $service;
}
$travel->additionalServices = $indexed;
return $travel;
}
private function createService(int $id, string $status, ?int $available): Service
{
$service = new Service();
$service->id = $id;
$service->status = $status;
$service->available = $available;
return $service;
}
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
{
$availability = new Availability();
$availability->serviceId = $serviceId;
$availability->status = $status;
$availability->available = $available;
return $availability;
}
/** @param array<int, Availability> $availabilities */
private function createResponse(array $availabilities): ServiceAvailabilityResponse
{
$indexed = [];
foreach ($availabilities as $availability) {
$indexed[$availability->serviceId] = $availability;
}
return new ServiceAvailabilityResponse($indexed);
}
}
@@ -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']);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
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;
/**
* A broken response used to collapse into either "Empty response received from server" —
* which is wrong for a malformed body — or an opaque "Unable to parse XML response".
* Each shape must now name itself, otherwise a production parse failure is not
* diagnosable after the fact.
*/
class ApiResponseParserFailureTest extends TestCase
{
public function testEmptyResponse(): void
{
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response (0 bytes)');
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
}
public function testTruncatedResponseNamesTheLibxmlReason(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
try {
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) {
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
self::assertStringContainsString('line 1', $e->getMessage());
}
}
public function testWellFormedResponseOfAnUnknownType(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="IRGENDWAS"></satz></ergebnis>';
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
}
public function testUnknownCustomerDataSubtype(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Irgendwas</art></ergebnis>';
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
$this->createParser()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
}
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
{
$previous = libxml_use_internal_errors(false);
try {
try {
$this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
} catch (ResponseParserException) {
}
self::assertFalse(libxml_use_internal_errors(false));
self::assertSame([], libxml_get_errors());
} finally {
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));
}
}
@@ -47,6 +47,26 @@ class CrmAttributesResponseParserTest extends TestCase
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles); self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
} }
/**
* ROLE_TEAM_ADMIN is not consumed here at all — it is exported through /api/userinfo and is
* myep-team's administrative role — so the claim is the only thing standing between the CRM
* selection and that application.
*/
public function testParseAssignsTeamAdminRoleWhenSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1484, true));
self::assertContains('ROLE_TEAM_ADMIN', $roles);
self::assertNotContains('ROLE_ADMIN', $roles, 'the "Admin" selection 1292 is a different one');
}
public function testParseAssignsNoTeamAdminRoleWhenNotSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1484, false));
self::assertNotContains('ROLE_TEAM_ADMIN', $roles);
}
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
{ {
$roles = $this->parseRoles($this->selectionXml(1483, false)); $roles = $this->parseRoles($this->selectionXml(1483, false));
+96 -1
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser; namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser; use App\BusProNet\XmlParser\TravelParser;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -15,7 +17,7 @@ class TravelParserTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->parser = new TravelParser(); $this->parser = $this->createParser();
} }
public function testParseServiceDescriptions(): void public function testParseServiceDescriptions(): void
@@ -355,6 +357,99 @@ class TravelParserTest extends TestCase
$this->assertNull($travel->status); // No status_hin node should result in null $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 public function testGetPickupsAdjustsOvernightTimes(): void
{ {
$xml = '<?xml version="1.0" encoding="utf-8"?> $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\Accommodation;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -87,6 +88,36 @@ class ChangeAccommodationControllerTest extends TestCase
self::assertSame('success', $controller->flashes[0]['type']); 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 public function testPickingTheSameHouseIsNotReportedAsAChange(): void
{ {
$booking = $this->draft(); $booking = $this->draft();
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\CreateController; use App\Controller\Admin\AccommodationBooking\CreateController;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -32,6 +33,37 @@ class CreateControllerTest extends TestCase
self::assertSame($user, $controller->capturedBooking?->getManagedBy()); 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 private function buildController(?UserInterface $user): TestableCreateController
{ {
$form = $this->createStub(FormInterface::class); $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\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
@@ -46,6 +47,39 @@ class EditControllerTest extends TestCase
self::assertSame($status, $booking->getStatus(), 'editing leaves the status where it was'); 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}> * @return iterable<string, array{AccommodationBookingStatus}>
*/ */
@@ -33,11 +33,11 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN')); $response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView); self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], Role::assignedOnly($user->getRoles()));
} }
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
@@ -49,9 +49,9 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
self::assertTrue($response->headers->has('HX-Redirect')); self::assertTrue($response->headers->has('HX-Redirect'));
self::assertSame(['success'], array_column($controller->flashes, 'type')); self::assertSame(['success'], array_column($controller->flashes, 'type'));
} }
@@ -94,9 +94,9 @@ class ApproveRoleControllerTest extends TestCase
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the // Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
// rest grant less than they could grant themselves anyway. // rest grant less than they could grant themselves anyway.
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
} }
public function testPostWithAnInvalidTokenIsDenied(): void public function testPostWithAnInvalidTokenIsDenied(): void
@@ -108,7 +108,7 @@ class ApproveRoleControllerTest extends TestCase
$this->expectException(AccessDeniedException::class); $this->expectException(AccessDeniedException::class);
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($this->nominatedUser(), Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
} }
public function testApprovingForYourselfReissuesTheSecurityToken(): void public function testApprovingForYourselfReissuesTheSecurityToken(): void
@@ -125,12 +125,12 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage, tokenStorage: $tokenStorage,
); );
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
// Without this the next request would find the stored roles out of step with the token // Without this the next request would find the stored roles out of step with the token
// and end the session, logging the approver out mid-action. // and end the session, logging the approver out mid-action.
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []); self::assertContains(Role::HOUSE_MANAGER, $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []); self::assertNotContains(Role::pending(Role::HOUSE_MANAGER), $tokenStorage->getToken()?->getRoleNames() ?? []);
} }
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
@@ -148,14 +148,14 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage, tokenStorage: $tokenStorage,
); );
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($other, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame($originalToken, $tokenStorage->getToken()); self::assertSame($originalToken, $tokenStorage->getToken());
} }
private function nominatedUser(): User private function nominatedUser(): User
{ {
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]); return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)]);
} }
} }
@@ -21,7 +21,7 @@ class ShowControllerTest extends TestCase
$controller->index($this->nominatedUser(), $this->permissionsRequest()); $controller->index($this->nominatedUser(), $this->permissionsRequest());
self::assertSame( self::assertSame(
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'], [Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'GRO Admin'],
$controller->parameters['approvableRoles'], $controller->parameters['approvableRoles'],
); );
self::assertSame([], $controller->parameters['selfRefusedRoles']); self::assertSame([], $controller->parameters['selfRefusedRoles']);
@@ -29,14 +29,14 @@ class ShowControllerTest extends TestCase
public function testWhatCannotBeSelfApprovedIsNotOffered(): void public function testWhatCannotBeSelfApprovedIsNotOffered(): void
{ {
$user = (new User('admin@example.org'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]); $user = (new User('admin@ep-reisen.de'))->setRoles([Role::EMPLOYEE, Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
$controller = new TestableShowController(currentUser: $user); $controller = new TestableShowController(currentUser: $user);
$controller->index($user, $this->permissionsRequest()); $controller->index($user, $this->permissionsRequest());
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page. // ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']); self::assertSame([Role::MANAGER => 'Reisemanager:in'], $controller->parameters['approvableRoles']);
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']); self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
} }
@@ -62,8 +62,8 @@ class ShowControllerTest extends TestCase
private function nominatedUser(): User private function nominatedUser(): User
{ {
return (new User('teamer@example.org')) return (new User('teamer@ep-reisen.de'))
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]); ->setRoles([Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
} }
/** /**
@@ -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,
));
}
}
}
}
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more