Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221008f90 | ||
|
|
b27639c7fa | ||
|
|
ca1ab17595 | ||
|
|
c5685e6d7c | ||
|
|
0a5602fd41 | ||
|
|
f3745f918e | ||
|
|
65c1a7bd12 | ||
|
|
d0e7ba017c | ||
|
|
be0e15b3d4 | ||
|
|
f5e6d2105d | ||
|
|
0c6126010f | ||
|
|
29f295abf6 | ||
|
|
2aa98b81fe | ||
|
|
56adf72d12 | ||
|
|
773cd4f847 | ||
|
|
876e69e62c | ||
|
|
96b7b3f451 | ||
|
|
e751eed4a0 | ||
|
|
bad916cfb7 | ||
|
|
cc3d32fcbb | ||
|
|
79709e3a3e | ||
|
|
ab7d00b35d | ||
|
|
679a864906 | ||
|
|
f080d05dd6 | ||
|
|
00637042e2 | ||
|
|
fbb3ebb8c1 | ||
|
|
ec83ad598f | ||
|
|
2cb4871268 | ||
|
|
c1415dee41 | ||
|
|
540cd8eeb7 | ||
|
|
725e32daab | ||
|
|
160ebef39e | ||
|
|
672fc30d7e | ||
|
|
ba4f66f117 | ||
|
|
12fe4d8e15 |
No files matched your search
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 | |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A room selection the current travel data no longer supports in full.
|
||||||
|
*
|
||||||
|
* A room that disappeared altogether is the same case with nothing left to book, so it is reported
|
||||||
|
* as an adjustment down to zero rather than as a second kind of result.
|
||||||
|
*/
|
||||||
|
final readonly class RoomSelectionAdjustment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $roomId,
|
||||||
|
public ?string $label,
|
||||||
|
public int $requestedQuantity,
|
||||||
|
public int $bookableQuantity,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the room is gone entirely rather than merely reduced.
|
||||||
|
*/
|
||||||
|
public function isDropped(): bool
|
||||||
|
{
|
||||||
|
return 0 === $this->bookableQuantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole travel-date x hotel export plus the counters that describe how it was built.
|
||||||
|
*
|
||||||
|
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
|
||||||
|
* makes the counters identical on every page: they describe the export, not the page, so a
|
||||||
|
* consumer can read them once and abort the page walk on the first response.
|
||||||
|
*
|
||||||
|
* @see \App\Service\TravelExportBuilder
|
||||||
|
*/
|
||||||
|
final readonly class TravelExport
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||||
|
* @param int $skipped (travel, hotel) pairs dropped as unusable
|
||||||
|
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $rows,
|
||||||
|
public int $skipped,
|
||||||
|
public int $pickupsWithoutMasterData,
|
||||||
|
public \DateTimeImmutable $generatedAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
final readonly class TravelExportQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\Positive]
|
||||||
|
public int $page = 1,
|
||||||
|
|
||||||
|
#[Assert\Range(min: 1, max: 1000)]
|
||||||
|
public int $perPage = 500,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Hotel;
|
||||||
|
use App\BusProNet\Model\Pickup;
|
||||||
|
use App\BusProNet\Traits\TypeConversionTrait;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\PickupLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use App\Model\TravelExport;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use League\Flysystem\StorageAttributes;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
|
||||||
|
*
|
||||||
|
* Built from the XML file map rather than the travel_snapshot table. The table sits at
|
||||||
|
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
|
||||||
|
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
|
||||||
|
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
|
||||||
|
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
|
||||||
|
*
|
||||||
|
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
|
||||||
|
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
|
||||||
|
*/
|
||||||
|
class TravelExportBuilder
|
||||||
|
{
|
||||||
|
use TypeConversionTrait;
|
||||||
|
|
||||||
|
private const CACHE_KEY = 'bpn_travels_export';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HotelLoader $hotelLoader,
|
||||||
|
private readonly PickupLoader $pickupLoader,
|
||||||
|
private readonly TravelParser $travelParser,
|
||||||
|
private readonly FilesystemOperator $xmlExport,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the whole export, cached as one unit alongside the sibling loaders.
|
||||||
|
*/
|
||||||
|
public function build(): TravelExport
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||||
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
|
return $this->assemble();
|
||||||
|
});
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assemble(): TravelExport
|
||||||
|
{
|
||||||
|
$hotels = $this->hotelLoader->loadAll();
|
||||||
|
$pickupMasterData = $this->pickupLoader->loadAll();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
|
||||||
|
$rows = [];
|
||||||
|
$skipped = 0;
|
||||||
|
/** @var array<int, true> $pickupsWithoutMasterData */
|
||||||
|
$pickupsWithoutMasterData = [];
|
||||||
|
|
||||||
|
$files = $this
|
||||||
|
->xmlExport
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
|
||||||
|
|
||||||
|
$crawler->filterXPath('//reisen/reise/termin')->each(
|
||||||
|
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
|
||||||
|
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TravelExport(
|
||||||
|
array_values($rows),
|
||||||
|
$skipped,
|
||||||
|
count($pickupsWithoutMasterData),
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $rows
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
* @param array<int, Hotel> $hotels
|
||||||
|
* @param array<int|string, Pickup> $pickupMasterData
|
||||||
|
*/
|
||||||
|
private function collectTravel(
|
||||||
|
Crawler $node,
|
||||||
|
array &$rows,
|
||||||
|
int &$skipped,
|
||||||
|
array &$pickupsWithoutMasterData,
|
||||||
|
array $hotels,
|
||||||
|
array $pickupMasterData,
|
||||||
|
): void {
|
||||||
|
$hotelNodes = $node->filterXPath('.//hotel');
|
||||||
|
|
||||||
|
// Dates first: getPickups() needs a non-null default date for the time-only zeit
|
||||||
|
// shape, and parsing them up front is also what turns an unusable date into a
|
||||||
|
// dropped row rather than a row with a null date the consumer cannot key on.
|
||||||
|
$dateFrom = $this->stringToDate($node->attr('termin'));
|
||||||
|
$dateTo = $this->stringToDate($node->attr('bis'));
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
$this->logger->warning('Travel export: unparseable travel date', [
|
||||||
|
'travel_id' => $node->attr('idbuspro'),
|
||||||
|
'termin' => $node->attr('termin'),
|
||||||
|
'bis' => $node->attr('bis'),
|
||||||
|
]);
|
||||||
|
$skipped += $hotelNodes->count();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travelId = (int) $node->attr('idbuspro');
|
||||||
|
|
||||||
|
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
||||||
|
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
|
||||||
|
// calendar day. Resolved once per travel date, then shared by its hotel rows.
|
||||||
|
$pickups = $this->buildPickups(
|
||||||
|
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
|
||||||
|
$pickupMasterData,
|
||||||
|
$pickupsWithoutMasterData,
|
||||||
|
);
|
||||||
|
|
||||||
|
$travel = [
|
||||||
|
'busProId' => $travelId,
|
||||||
|
'code' => $node->attr('code'),
|
||||||
|
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
|
||||||
|
'dateFrom' => $dateFrom->format('Y-m-d'),
|
||||||
|
'dateTo' => $dateTo->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
|
||||||
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// Defensive: no travel in the current snapshot references a hotel that
|
||||||
|
// hotel.xml does not carry. A row without master data would reach the consumer
|
||||||
|
// with no name and no country, so it is dropped and counted instead.
|
||||||
|
if (false === isset($hotels[$hotelId])) {
|
||||||
|
$this->logger->warning('Travel export: hotel missing from master data', [
|
||||||
|
'travel_id' => $travelId,
|
||||||
|
'hotel_id' => $hotelId,
|
||||||
|
]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hotel = $hotels[$hotelId];
|
||||||
|
|
||||||
|
// Keyed by the pair the consumer upserts on, so a date repeated across files
|
||||||
|
// collapses last-wins instead of reaching it as a duplicate.
|
||||||
|
$rows[$travelId.'-'.$hotelId] = $travel + [
|
||||||
|
'hotel' => [
|
||||||
|
'busProId' => $hotelId,
|
||||||
|
'code' => $hotel->code,
|
||||||
|
'name' => $hotel->name,
|
||||||
|
'costUnit' => $hotel->costUnit,
|
||||||
|
'country' => $hotel->country,
|
||||||
|
'countryCode' => $hotel->countryCode,
|
||||||
|
],
|
||||||
|
'pickups' => $pickups,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the travel XML's pickups against zustiege.xml.
|
||||||
|
*
|
||||||
|
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
|
||||||
|
* contract is source order, which is the order the disposition lists them in. Nor
|
||||||
|
* enrichPickupDetails(), which is private and passes over a miss silently.
|
||||||
|
*
|
||||||
|
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
|
||||||
|
* @param array<int|string, Pickup> $masterData
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
|
||||||
|
{
|
||||||
|
$resolved = [];
|
||||||
|
|
||||||
|
foreach ($pickups as $pickup) {
|
||||||
|
$pickupId = $pickup->id;
|
||||||
|
|
||||||
|
// Defensive: none exist today. An entry with no id is not addressable by the
|
||||||
|
// consumer, so drop the entry -- never the row it belongs to.
|
||||||
|
if (null === $pickupId || 0 === $pickupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = $masterData[$pickupId] ?? null;
|
||||||
|
|
||||||
|
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
|
||||||
|
// entry is emitted anyway: dropping it would hide a real departure point from
|
||||||
|
// the disposition UI, where a nameless stop is still better than a missing one.
|
||||||
|
if (null === $details) {
|
||||||
|
$pickupsWithoutMasterData[$pickupId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = [
|
||||||
|
'busProId' => $pickupId,
|
||||||
|
'code' => $details?->code,
|
||||||
|
'city' => $details?->city,
|
||||||
|
'street' => $details?->street,
|
||||||
|
'time' => $pickup->time?->format('H:i'),
|
||||||
|
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
||||||
|
// worked out the real day, and a null here would throw that away.
|
||||||
|
'datetime' => $pickup->time?->format('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStringOrNullValue(Crawler $node): ?string
|
||||||
|
{
|
||||||
|
return 0 < $node->count() ? $node->text() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trimOrNull(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,6 @@
|
|||||||
<div class="flex items-center justify-between">
|
<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') %}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{# The login form itself, shared by security/login.html.twig and security/login_oauth2.html.twig so
|
||||||
|
the field names, the CSRF token id and the error rendering cannot drift between the two pages.
|
||||||
|
Expects `error` and `last_username` from the including template's context. #}
|
||||||
|
{% if error %}
|
||||||
|
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pb-4">
|
||||||
|
<form action="{{ path('app_login') }}" method="post">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="username" class="mb-1 font-semibold text-white">
|
||||||
|
E-Mail:
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
id="username"
|
||||||
|
name="_username"
|
||||||
|
value="{{ last_username }}"
|
||||||
|
class="form-field"
|
||||||
|
required
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
|
||||||
|
<label for="password" class="mb-1 font-semibold text-white">
|
||||||
|
Passwort:
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="_password"
|
||||||
|
class="form-field"
|
||||||
|
{{ stimulus_target('password-reveal', 'field') }}
|
||||||
|
required
|
||||||
|
autocomplete="current-password">
|
||||||
|
<button type="button"
|
||||||
|
{{ stimulus_action('password-reveal', 'toggle') }}
|
||||||
|
class="absolute top-0 right-0 mt-2 mr-2">
|
||||||
|
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
|
||||||
|
<button type="submit" class="button button--primary">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{# The brand logo, extracted from _header.html.twig so the OAuth2 login chrome can show the same
|
||||||
|
logo without inheriting the account links that go with it. Takes an optional `accommodation`. #}
|
||||||
|
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #}
|
||||||
|
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
|
||||||
|
{% if country_logo %}
|
||||||
|
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
|
||||||
|
{% elseif booking_theme() == 'sbw' %}
|
||||||
|
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
|
||||||
|
{% elseif booking_theme() == 'ser' %}
|
||||||
|
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
|
||||||
|
{% elseif booking_theme() == 'snz' %}
|
||||||
|
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
|
||||||
|
{% else %}
|
||||||
|
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
|
||||||
|
{% endif %}
|
||||||
@@ -97,6 +97,11 @@
|
|||||||
<twig:dropdown:link url="{{ edit_url }}">
|
<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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{# Chrome for a login that is serving an external OAuth2 authorization.
|
||||||
|
|
||||||
|
Only the logo survives from the normal header: the account and logout links are dead ends in the
|
||||||
|
middle of an authorization, and the marketing column of the standard login page is noise when the
|
||||||
|
user did not come here to read about MyE&P. The footer's legal links stay -- this page collects
|
||||||
|
credentials, so dropping them would be the wrong direction.
|
||||||
|
|
||||||
|
The background is inherited unchanged, which is why the shared form partial's `text-white`
|
||||||
|
styling still works here. #}
|
||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% include '_partials/_logo.html.twig' %}
|
||||||
|
{% endblock %}
|
||||||
@@ -7,50 +7,7 @@
|
|||||||
Login<br>MyE&P
|
Login<br>MyE&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">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{# The login page as seen by someone who was sent here by another E&P application (ephub, myep-team)
|
||||||
|
to authorize it. Its job is to explain why they are suddenly on a different website and that they
|
||||||
|
will be sent back, which the standard login page never said.
|
||||||
|
|
||||||
|
The grid is the one from security/login.html.twig, minus its marketing column: that keeps the
|
||||||
|
form column at exactly the width it has on the standard login page. #}
|
||||||
|
{% extends 'layout_oauth2.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
|
||||||
|
{% if client_name %}
|
||||||
|
Anmelden für<br>{{ client_name }}
|
||||||
|
{% else %}
|
||||||
|
Login<br>MyE&P
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mb-6 text-white">
|
||||||
|
Melde dich mit deinem MyE&P-Konto an. Anschließend wirst du automatisch
|
||||||
|
{% if client_name %}zu {{ client_name }} {% endif %}zurückgeleitet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% include '_partials/_login_form.html.twig' %}
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a href="{{ path('app_reset_password') }}" class="text-white underline">
|
||||||
|
Passwort vergessen?
|
||||||
|
</a>
|
||||||
|
<a href="{{ path('app_registration') }}" class="text-white underline">
|
||||||
|
Jetzt registrieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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&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 –
|
||||||
|
ein erneuter Login ändert daran nichts. Wenn du hier Zugriff brauchst, wende
|
||||||
|
dich bitte an dein E&P-Team.
|
||||||
|
</p>
|
||||||
|
<a href="{{ path('app_account') }}" class="button button--primary">
|
||||||
|
Zurück zu MyE&P
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user