Compare commits

320 Commits
Author SHA1 Message Date
Björn Fromme 97f8baba06 fix: adopt general styles to error page template 2026-01-06 08:34:24 +01:00
Björn Fromme 3ffa80e2bc chore: update deployer config 2026-01-06 08:30:56 +01:00
Björn Fromme ab1a21dffc chore: update project dependencies 2026-01-06 08:26:22 +01:00
Björn Fromme bccd8c6fc3 chore: merge master into feature branch 2026-01-05 18:25:14 +01:00
Björn Fromme 2ea298854d fix: restore .user.ini from master 2026-01-05 18:17:38 +01:00
Björn Fromme b7e4f29813 chore: update upgrade plan with current state of the project 2026-01-05 17:34:02 +01:00
Björn Fromme 7780646601 chore: replace claude code with opencode inside ddev container 2026-01-05 17:24:41 +01:00
Björn Fromme 0f58f07757 chore: update project dependencies 2026-01-05 17:20:44 +01:00
Björn Fromme ef263e064a feat: add env variable for website base url 2026-01-05 17:20:18 +01:00
Björn Fromme 7127b4d050 chore: install claude code in ddev container for security reasons 2025-12-31 14:02:53 +01:00
Björn Fromme 42744fa30f chore: complete scheduler config for email notifications 2025-12-22 11:07:56 +01:00
Björn Fromme be3f6e6288 feat: remove outdated log entries scheduled 2025-12-22 11:07:33 +01:00
Björn Fromme 9291adfdff fix: fail gracefully when remote XML data cannot be read during sync 2025-12-22 08:29:57 +01:00
Björn Fromme c9426eff3b fix: catch travel data not found errors to show proper flash message 2025-12-19 17:54:25 +01:00
Björn Fromme cd350c212e chore: update project dependencies 2025-12-19 17:29:33 +01:00
Björn Fromme 1f1ac1a0cc docs: add inline documentation where missing 2025-12-19 17:26:59 +01:00
Björn Fromme 2dd5b7ad64 chore: cleanup 2025-12-19 17:26:39 +01:00
Björn Fromme 62ac8038dc feat: cron controlled cli command to sync xml data with remote 2025-12-19 17:06:27 +01:00
Björn Fromme 5f94f0487b chore: cleanup flysystem config 2025-12-19 16:07:12 +01:00
Björn Fromme 64880355e8 chore: update json test-data for pickups planning 2025-12-17 17:16:41 +01:00
Björn Fromme 1dc8489239 fix: avoid duplicated pickup planning entries 2025-12-17 17:05:38 +01:00
Björn Fromme 5831294d33 chore: reduce noise in logs 2025-12-17 16:44:11 +01:00
Björn Fromme 073bbdc0f8 feat: add logging to api endpoint 2025-12-17 16:34:57 +01:00
Björn Fromme ef75383885 fix: don't show bulk insurance labels in summary 2025-12-17 16:04:19 +01:00
Björn Fromme bfb25a4051 feat: accept multiple travel codes per pickup planning 2025-12-17 14:23:05 +01:00
Björn Fromme f55febd545 chore: update project dependencies 2025-12-17 10:23:06 +01:00
Björn Fromme d27b78cec1 chore: rotate auth log 2025-12-17 10:22:37 +01:00
Björn Fromme 1bb145e3be feat: forcibly invalidate availabilities cache on booking start 2025-12-16 13:46:54 +01:00
Björn Fromme 59b9a42009 feat: loading indicator on initial page load 2025-12-16 13:44:25 +01:00
Björn Fromme a576fa2d23 fix: adopt changes in test 2025-12-16 13:31:06 +01:00
Björn Fromme 14fe9bd44f feat: load availability data once per booking session 2025-12-16 13:30:45 +01:00
Björn Fromme cce59b8ed6 chore: disable saving cookies for api requests 2025-12-16 12:23:40 +01:00
Björn Fromme 8904e1c7fe feat: log pickups planning data api calls 2025-12-16 12:17:41 +01:00
Björn Fromme a0f763af63 chore: update project dependencies 2025-12-16 09:59:15 +01:00
Björn Fromme 667c9d4677 chore: reduce logger noise 2025-12-14 17:17:19 +01:00
Björn Fromme 1622f2be6a chore: retain xml dumps for three days to save on webspace 2025-12-14 17:10:09 +01:00
Björn Fromme 28914d6586 feat: rotate auth logfile 2025-12-12 20:15:56 +01:00
Björn Fromme cfc3957fec chore: update project dependencies 2025-12-12 20:06:45 +01:00
Björn Fromme aee2eb848d fix: adopt changed response class 2025-12-12 20:06:19 +01:00
Björn Fromme 42f17db542 feat: extended oauth2 user info and refactored authenticator 2025-12-12 19:57:34 +01:00
Björn Fromme 6315fbd0f7 fix: ensure template variable exists 2025-12-12 19:46:48 +01:00
Björn Fromme 79e5a3c12d feat: adjust oauth2 implementation for TYPO3 backend login 2025-12-12 16:31:32 +01:00
Björn Fromme e9c1dd8b9d feat: basic themes for event sites 2025-12-12 13:13:36 +01:00
Björn Fromme b1ff6989ec feat: api endpoints for pickups planning 2025-12-12 09:44:38 +01:00
Björn Fromme 1d97a19982 feat: always show all available ski passes but readonly if age doesn't match 2025-12-12 08:47:25 +01:00
Björn Fromme c3519124b7 feat: update labels of voucher fields
addresses #869bf8tzv
2025-12-12 08:09:34 +01:00
Björn Fromme 05c769bab2 feat: use query parameters for booking urls 2025-12-11 16:14:31 +01:00
Björn Fromme c1539a47f9 fix: properly assign readonly state to form fields for unavailable services 2025-12-11 12:07:19 +01:00
Björn Fromme f54f66cd32 fix: reset room assignments after updated selection if required 2025-12-11 11:27:48 +01:00
Björn Fromme c8a33cdf43 fix: assign correct return types 2025-12-11 11:26:46 +01:00
Björn Fromme b4c03160ca feat: consistent display of included services and prices 2025-12-11 11:17:03 +01:00
Björn Fromme 72cd36a573 feat: enable logging for contact form api endpoint 2025-12-11 09:33:41 +01:00
Björn Fromme 4d3a4b7c04 chore: update project dependencies 2025-12-10 18:01:34 +01:00
Björn Fromme b9dfeb2d3e feat: backport contact form api endpoint 2025-12-10 18:00:51 +01:00
Björn Fromme e42ef2d3c9 feat: show unavailable transportation services as readonly 2025-12-10 17:15:05 +01:00
Björn Fromme bb69a28ec2 fix: don't show incorrect notification about updated rentals 2025-12-10 15:40:17 +01:00
Björn Fromme e8c2261b7e feat: show inquiry status with rooms in step 1 2025-12-10 15:33:13 +01:00
Björn Fromme 7665385cbf feat: hide parking from participants under 18 2025-12-10 14:37:06 +01:00
Björn Fromme 346dee23e4 fix: don't auto assign discounted transport to babies 2025-12-10 14:09:52 +01:00
Björn Fromme 109f36e7f5 chore: simplify parameter handling 2025-12-10 13:57:48 +01:00
Björn Fromme 0e9c5e6e1a feat: configurable choices for body dimensions
closes #8697phk21
2025-12-10 13:48:47 +01:00
Björn Fromme 145a08e511 chore: update deployer config for prod 2025-12-10 13:10:17 +01:00
Björn Fromme 2b8046b416 feat: re-implement contact form submissions api endpoint for website 2025-12-10 09:29:19 +01:00
Björn Fromme e27b5da3c8 feat: adjust participant number size depending on value 2025-12-09 20:52:12 +01:00
Björn Fromme 613e0b9a5c feat: update deployer config to build assets upfront 2025-12-09 20:48:36 +01:00
Björn Fromme 0564e3c424 feat: align rendering of pickups field with other services 2025-12-09 20:43:58 +01:00
Björn Fromme 32c73ab7b1 fix: avoid loading indicator issues when using browser history for navigation 2025-12-09 15:13:04 +01:00
Björn Fromme 420efb25ad chore: update project dependencies 2025-12-09 12:56:31 +01:00
Björn Fromme 150998e120 fix: ensure flash message is displayed after redirect 2025-12-09 12:55:23 +01:00
Björn Fromme 31f414b4d8 feat: show room prices in room assignment form field 2025-12-09 12:55:00 +01:00
Björn Fromme 9c6860ecc6 fix: account for htmx issues in mobile safari with select fields 2025-12-09 11:42:31 +01:00
Björn Fromme 6b32b6baa4 fix: disable autocomplete in chrome for good 2025-12-09 11:33:19 +01:00
Björn Fromme 3527be9c29 feat: various improvements in styles and layout 2025-12-09 11:20:43 +01:00
Björn Fromme 0239aee89a fix: avoid stale caches 2025-12-09 10:25:11 +01:00
Björn Fromme f21f4a0385 feat: streamlined rendering of fields as static text in forms 2025-12-09 10:20:55 +01:00
Björn Fromme fa2b5e0d7f feat: render room assignment as radio buttons 2025-12-09 10:05:14 +01:00
Björn Fromme 1ac7720765 feat: replace overly complex and unnecessary form choice loader for rooms 2025-12-09 09:32:01 +01:00
Björn Fromme 4182c30b72 fix: ensure footer bar is visible on mobile safari 2025-12-09 09:21:58 +01:00
Björn Fromme e4aad8a456 feat: load cms data early to display image on login page 2025-12-08 17:39:29 +01:00
Björn Fromme d78638e6e5 feat: improved layout for participant form 2025-12-08 17:08:26 +01:00
Björn Fromme 1726523eaf fix: disable autocompletion on personal data fields 2025-12-08 16:20:20 +01:00
Björn Fromme 1aa564960d feat: body dimensions optional 2025-12-08 16:18:31 +01:00
Björn Fromme cd2765bab5 feat: improved error handling and redirect on invalid session 2025-12-08 16:00:02 +01:00
Björn Fromme 390ee81675 feat: indicate incomplete participant data in cards 2025-12-08 15:59:35 +01:00
Björn Fromme 6dbbfd87f8 feat: special treatment of services for participants of age 0-2 2025-12-08 15:26:30 +01:00
Björn Fromme 118f86b2a0 feat: increase availability cache ttl and ensure fresh data when entering form 2025-12-08 14:57:12 +01:00
Björn Fromme 27d77624da docs: upgrade plan to symfony 7.4 2025-12-07 18:48:40 +01:00
Björn Fromme 20a67e9b96 feat: optional return url 2025-12-07 18:47:18 +01:00
Björn Fromme 233748a6c5 chore: cleanup 2025-12-07 15:03:54 +01:00
Björn Fromme 67491a7f68 fix: remove obsolete htmx attributes from forms 2025-12-07 15:03:44 +01:00
Björn Fromme c7129d5791 chore: cleanup 2025-12-07 14:47:51 +01:00
Björn Fromme 4abee677f6 fix: enforce https 2025-12-07 14:24:08 +01:00
Björn Fromme 21600dc2d6 feat: implement hx-boost for reliable loading indication 2025-12-07 14:11:50 +01:00
Björn Fromme f0b84c9943 fix: ensure loading indicator is triggered 2025-12-06 18:13:38 +01:00
Björn Fromme 59418adc02 chore: update project dependencies 2025-12-06 15:05:06 +01:00
Björn Fromme de52907eec fix: remove form options cache causing issues 2025-12-06 15:02:34 +01:00
Björn Fromme bfb1a04ea0 feat: implement screendesign 2025-12-06 15:02:33 +01:00
Björn Fromme b206c3990b chore: update project dependencies 2025-12-06 15:02:13 +01:00
Björn Fromme 54a37c5bf7 feat: adjust loading indicator styles 2025-12-06 15:02:13 +01:00
Björn Fromme fa98ceac29 fix: add loading indicator to all 'back' buttons 2025-12-06 15:02:13 +01:00
Björn Fromme 1bab230f10 fix: consider mode in summary 2025-12-06 15:02:13 +01:00
Björn Fromme 5b55d0984a fix: correctly handle email uniqueness by excluding the applicant from validation 2025-12-06 15:02:13 +01:00
Björn Fromme 2b3a8f1c69 feat: request replay command for bpn api 2025-12-06 15:02:13 +01:00
Björn Fromme 696800aafd feat: refactoring and cleanup 2025-12-06 15:02:13 +01:00
Björn Fromme e7997f84eb fix: include user ini for adjusted php settings on prod host 2025-12-05 10:03:51 +01:00
Björn Fromme 8d3a7b99b4 fix: prevent type errors 2025-12-01 10:41:04 +01:00
Björn Fromme 61a8afdde7 fix: prevent null reference errors 2025-12-01 10:38:55 +01:00
Björn Fromme 1f3e79d57c fix: prevent null reference errors 2025-12-01 10:37:39 +01:00
Björn Fromme f4691ec67e feat: finalize voucher handling and discount display in summary and overview 2025-11-26 17:29:15 +01:00
Björn Fromme 32bef9a4e9 feat: special treatment of participants with age 0-2 (babies) 2025-11-26 15:38:49 +01:00
Björn Fromme 30704f936a fix: don't disable room selection field with single option 2025-11-25 18:14:33 +01:00
Björn Fromme 9167c84b4c fix: clear target path when canceling booking flow 2025-11-25 17:34:10 +01:00
Björn Fromme f5f5d43ab5 feat: conditionally render room assignment field in participant edit form 2025-11-25 17:30:23 +01:00
Björn Fromme 9b2ad3845d feat: prevent selection of baby rooms without regular rooms 2025-11-25 16:43:41 +01:00
Björn Fromme e2513d6245 chore: update claude settings 2025-11-25 16:43:05 +01:00
Björn Fromme 126fce9687 feat: step input for room type selection 2025-11-25 16:42:39 +01:00
Björn Fromme c1364a458b fix: correctly determine number of participants 2025-11-25 16:42:13 +01:00
Björn Fromme ecc4741f56 feat: correct determination of inquiry status and conditional voucher field display 2025-11-25 16:41:25 +01:00
Björn Fromme f33e55f201 feat: correct handling of vouchers 2025-11-24 10:20:21 +01:00
Björn Fromme ce1b9ece09 feat: layout improvements 2025-11-21 12:44:06 +01:00
Björn Fromme 9d28d2f102 feat: improved promotional and purchase vouchers 2025-11-21 12:43:51 +01:00
Björn Fromme aaa7e9f4c3 feat: promotional and purchase vouchers 2025-11-20 18:41:46 +01:00
Björn Fromme 26681e5c62 feat: fetch additional content from cms via api 2025-11-20 15:42:41 +01:00
Björn Fromme 33c3dc6022 feat: sort pickups by their times 2025-11-17 10:00:03 +01:00
Björn Fromme a6b0051f1b chore: update project dependencies 2025-11-17 09:51:13 +01:00
Björn Fromme d0d1edb3ae feat: sort pickups by their times 2025-11-17 09:50:00 +01:00
Björn Fromme 6e96f6fb44 fix: never validate insurance selection in edit mode 2025-11-13 12:44:19 +01:00
Björn Fromme c6b28e7f4e fix: don't validate insurance selection of participants when in bulk assignment 2025-11-13 12:29:27 +01:00
Björn Fromme 3ce4d81a4c chore: update project dependencies 2025-11-13 11:36:52 +01:00
Björn Fromme d5c572c25b feat: loading indicators for regular get requests 2025-11-13 11:34:45 +01:00
Björn Fromme bcded403be feat: improved timeout handling for api client 2025-11-13 11:15:31 +01:00
Björn Fromme aeb25f3b7e feat: remove 'remember me' functionality from login form in booking process 2025-11-13 10:54:40 +01:00
Björn Fromme 421851cc4c feat: surcharges and discounts per participant and in summary 2025-11-13 09:30:49 +01:00
Björn Fromme 66eb392151 fix: check field existence before rendering 2025-11-11 15:45:14 +01:00
Björn Fromme beaccd93e0 fix: show rental insurance field only when applicable 2025-11-11 15:39:56 +01:00
Björn Fromme 111cda95f3 chore: update project dependencies 2025-11-11 15:13:15 +01:00
Björn Fromme 84ed41f325 fix: tooltips for form fields not showing 2025-11-11 15:12:28 +01:00
Björn Fromme e7cf172b9f feat: add subtypes PAR and LVS as bookable additional services 2025-11-11 14:14:44 +01:00
Björn Fromme e52cb98163 chore: update project dependencies 2025-11-07 10:39:39 +01:00
Björn Fromme d85e81e8b9 fix: ensure valid applicant data 2025-11-07 10:39:16 +01:00
Björn Fromme 1e5fe23159 fix: don't rely on individual prices being available for all participants 2025-11-06 15:32:39 +01:00
Björn Fromme 2f78ddb8ad fix: don't rely on individual prices being available for all participants 2025-11-06 15:24:51 +01:00
Björn Fromme f19762d686 chore: update project dependencies 2025-11-06 14:55:23 +01:00
Björn Fromme eba2f8e503 fix: skip availability check for services that are not included in API response 2025-11-06 14:55:00 +01:00
Björn Fromme 0a0bef9e82 feat: ensure inquiry info is always visible 2025-10-31 16:40:14 +01:00
Björn Fromme 29f415a91f feat: improved dynamic validation 2025-10-31 16:23:06 +01:00
Björn Fromme a619add4e3 fix: ensure validation of participant data 2025-10-31 15:07:22 +01:00
Björn Fromme 34a8d42fc3 fix: keep browser history intact when navigating between cards view and participant form 2025-10-31 12:46:37 +01:00
Björn Fromme cfd346d5df wip: integrate with typo3 to fetch product and hotel data 2025-10-28 16:13:07 +01:00
Björn Fromme 3d694ed7eb fix: streamlined single room selection handling 2025-10-28 16:12:27 +01:00
Björn Fromme 5021f414d1 feat: improved mutability checks for personal data in edit or create mode 2025-10-28 15:34:09 +01:00
Björn Fromme 51dbf20118 feat: improved handling of fields to be rendered as static text 2025-10-27 13:38:40 +01:00
Björn Fromme a50bd205cd chore: update project dependencies 2025-10-27 08:33:59 +01:00
Björn Fromme da7b4dfb48 fix: temporarily disable all participant validation 2025-10-27 08:31:39 +01:00
Björn Fromme eee7be0c1e feat: gravatar integration 2025-10-25 14:09:19 +02:00
Björn Fromme d55a201681 feat: integrate with scheduler bundle 2025-10-24 17:57:13 +02:00
Björn Fromme ba0eab9e96 fix: adapt changes in create mode for edit mode, cleanup 2025-10-24 17:30:41 +02:00
Björn Fromme b63804df88 feat: authenticated booking 2025-10-24 17:07:08 +02:00
Björn Fromme 647a25a78f feat: performance improvements 2025-10-24 15:21:15 +02:00
Björn Fromme 6b0dfb3426 chore: fix deprecations in preparation uf upgrading to symfony 7.3 2025-10-24 13:59:54 +02:00
Björn Fromme c8bb051ae6 fix: incorrect and unnecessary template inheritance 2025-10-24 12:32:16 +02:00
Björn Fromme b326510b04 chore: consolidate documentation 2025-10-24 12:17:42 +02:00
Björn Fromme 293574187f feat: price info tooltip for bulk insurance booking
addresses #869axbjzz
2025-10-24 11:23:19 +02:00
Björn Fromme b74f84e8ad feat: rearrange field order
addresses #869axbhz3
2025-10-24 09:32:16 +02:00
Björn Fromme 19a272db96 feat: group rentals and rental insurance fields visually
addresses #869ay16cp
2025-10-24 09:28:41 +02:00
Björn Fromme 67843c2f2e feat: dedicated section for discounts in summary
addresses #869ay1724
2025-10-24 09:19:34 +02:00
Björn Fromme a01e966d6d feat: automatic unselection of discounted transportation 2025-10-24 09:04:20 +02:00
Björn Fromme 9140b97a71 fix: improved position of info icon 2025-10-23 11:54:44 +02:00
Björn Fromme 405f03ee7a fix: adopt wrapper dto to dynamic form field 2025-10-23 11:38:34 +02:00
Björn Fromme b88ad24c3d feat: display service descriptions as tooltip with info icon
addresses #869axbnj0
2025-10-23 11:07:47 +02:00
Björn Fromme bb630da5ed feat: remove room selection field entirely for single room type
addresses #869axcukn
2025-10-23 10:49:14 +02:00
Björn Fromme 39d944351c chore: cleanup, update documentation 2025-10-23 10:29:37 +02:00
Björn Fromme 2b272ef789 fix: validation error indication on cards 2025-10-23 10:19:18 +02:00
Björn Fromme 3e54a58b9b wip: email uniqueness validation 2025-10-23 09:29:27 +02:00
Björn Fromme 60176d19f4 wip: improved validation and feedback 2025-10-23 08:50:45 +02:00
Björn Fromme 9c716477aa feat: unique email addresses of participants unless child, cleanup
addresses #869axbn21
2025-10-22 17:42:17 +02:00
Björn Fromme be2fffad23 feat: additional conformation checkbox for terms and conditions
addresses #869axbm44
2025-10-22 16:19:05 +02:00
Björn Fromme 34230e5520 feat: improved layout for pickup selection to make price visible
addresses #869axbjmd
2025-10-22 16:01:17 +02:00
Björn Fromme 1949bf9b97 feat: prefer discounted transportation services
addresses #869axbjbz
2025-10-22 15:53:54 +02:00
Björn Fromme 7ab60b1048 feat: improved form field layout for date of birth 2025-10-22 10:37:20 +02:00
Björn Fromme 0e6c31b8ce feat: show placeholders for individual date parts in date of birth field 2025-10-22 10:10:02 +02:00
Björn Fromme a2cfb693e0 feat: replace date field for participant's birthday 2025-10-22 10:02:30 +02:00
Björn Fromme 10df3ebb35 wip: implement e2e tests with cypress 2025-10-21 20:25:18 +02:00
Björn Fromme ab91c138d5 feat: unassign rooms from other participants when unavailable 2025-10-21 17:02:26 +02:00
Björn Fromme cc1d1e5746 feat: automatically assign rooms only for unique room type selection 2025-10-20 15:29:33 +02:00
Björn Fromme 8c75d0070d chore: update project dependencies 2025-10-20 10:23:34 +02:00
Björn Fromme 4df04d0504 chore: cleanup, updated docs 2025-10-20 09:32:54 +02:00
Björn Fromme 38bc594ca4 feat: consolidated insurance service 2025-10-20 09:22:03 +02:00
Björn Fromme fb9c9d91ee feat: integrate with spatie/ray for debugging and performance optimization 2025-10-20 08:08:17 +02:00
Björn Fromme ee69346eb4 feat: extended pricing breakdown on confirmation page 2025-10-18 17:59:42 +02:00
Björn Fromme e6c47019d5 fix: fix floating point precision error 2025-10-18 17:44:24 +02:00
Björn Fromme b894bbf5e6 chore: update documentation 2025-10-18 17:17:49 +02:00
Björn Fromme 0f8fed605c chore: adjust and cleanup tests 2025-10-18 17:15:31 +02:00
Björn Fromme 479b177e90 wip: bulk insurance refactoring 2025-10-18 16:39:57 +02:00
Björn Fromme 08210b8e52 wip: bulk insurance booking fix 2025-10-18 13:07:27 +02:00
Björn Fromme b5dfe17b73 wip: finalize implementation 2025-10-18 12:11:24 +02:00
Björn Fromme 49b5ccf308 wip: finalize implementation 2025-10-18 11:46:12 +02:00
Björn Fromme 434c4d0a11 wip: finalize implementation 2025-10-17 18:46:25 +02:00
Björn Fromme dc7ed0728a feat: readonly room selection for single room types 2025-10-16 09:36:04 +02:00
Björn Fromme b1dbb15ad7 feat: display booked insurance in edit mode 2025-10-16 09:35:30 +02:00
Björn Fromme e76170b6dd chore: remove debug statements 2025-10-16 09:35:12 +02:00
Björn Fromme 8225d4e92d fix: show feedback modal 2025-10-16 09:12:43 +02:00
Björn Fromme a5addf8d33 fix: adopt changed template name 2025-10-15 21:29:12 +02:00
Björn Fromme 0542a669bd fix: adjust deployer config 2025-10-15 18:32:49 +02:00
Björn Fromme 49b7a3fe34 feat: refactor to cards 2025-10-15 18:31:10 +02:00
Björn Fromme ead2c092da fix: ensure skipass is selected 2025-10-15 11:40:51 +02:00
Björn Fromme 6e818c3480 wip: refactor to cards with individual participant forms 2025-10-14 21:27:32 +02:00
Björn Fromme a02490ea36 wip: add documentation for plan 2025-10-14 20:30:51 +02:00
Björn Fromme 9eda12981a feat: htmx powered requests, improved loading indicator
feat: loading indicator and htmx form submit
2025-10-14 18:07:59 +02:00
Björn Fromme 4939a1a9ad feat: inquiry bookings 2025-10-09 18:52:23 +02:00
Björn Fromme 989f599b54 chore: remove outdated documentation 2025-10-09 18:51:58 +02:00
Björn Fromme 67c642bc2f wip: modernized edit flow, fix insurance tier calculation 2025-10-09 17:42:39 +02:00
Björn Fromme cba0f747cd wip: modernized edit flow 2025-10-08 21:09:53 +02:00
Björn Fromme c3008d7f7a wip: insurance booking in edit flow 2025-10-07 18:09:16 +02:00
Björn Fromme 7304fb50c6 fix: set correct default agency 2025-10-07 17:27:26 +02:00
Björn Fromme ec1bba7c46 wip: backport booking create flow form handling to edit flow 2025-10-07 16:38:55 +02:00
Björn Fromme b66d453881 feat: unify pickup selection 2025-10-06 18:07:07 +02:00
Björn Fromme b7b80296e9 feat: comprehensive booking overview for confirmation 2025-10-06 16:43:55 +02:00
Björn Fromme 2f582f0548 fix: use correct country tokens 2025-10-06 15:58:44 +02:00
Björn Fromme fad28c2442 chore: update project dependencies 2025-10-06 11:22:42 +02:00
Björn Fromme 7f1ef4059d wip: submit booking to api 2025-10-06 11:22:02 +02:00
Björn Fromme 139ec3c1db wip: confirmation step 2025-10-05 13:46:19 +02:00
Björn Fromme 798beae597 feat: implement payment step 2025-10-05 13:29:19 +02:00
Björn Fromme 3edf5a80aa fix: don't submit data of removed fields 2025-10-04 18:08:11 +02:00
Björn Fromme fc93b28af0 wip: backport form handing system from create flow to edit flow 2025-10-04 12:12:16 +02:00
Björn Fromme cd82b70139 fix: ignore past travels in list view when patching data 2025-10-04 11:49:36 +02:00
Björn Fromme fba9edc931 feat: inform user about automatic reassignments or reset selections 2025-10-02 17:57:56 +02:00
Björn Fromme 8e43edcbaf feat: insurance info urls in tooltips 2025-10-02 17:28:53 +02:00
Björn Fromme 8984b2edd5 feat: customizable label for unavailable services 2025-10-02 16:56:37 +02:00
Björn Fromme 32ec2c1deb feat: make room selection fields read-only for single room type 2025-10-02 16:40:18 +02:00
Björn Fromme 25029dff5d feat: extended insurance parsing, pass info urls to view 2025-10-02 16:32:50 +02:00
Björn Fromme 875b181aff fix: filter rentals by skipass duration 2025-10-02 14:42:04 +02:00
Björn Fromme 0f3d9b87fa feat: auto-assign rooms 2025-10-02 12:25:42 +02:00
Björn Fromme d7929dd855 wip: improved insurance selection 2025-10-02 12:11:23 +02:00
Björn Fromme 8a495346d6 wip: check eligibility based on ski pass availability 2025-10-01 18:52:50 +02:00
Björn Fromme fef4997132 chore: update docs 2025-10-01 17:57:39 +02:00
Björn Fromme b4eaf695c7 feat: bulk insurance booking by applicant 2025-10-01 17:52:21 +02:00
Björn Fromme 372490ed5a feat: improved form layout 2025-09-30 17:11:30 +02:00
Björn Fromme e37b6234e8 chore: remove debugging statements 2025-09-30 16:47:29 +02:00
Björn Fromme 71b42786f7 feat: show room pricing details 2025-09-30 16:47:17 +02:00
Björn Fromme 009eabd09c fix: layout improvements 2025-09-30 16:08:59 +02:00
Björn Fromme 49a4393980 fix: consider participant's age at travel date 2025-09-30 16:08:20 +02:00
Björn Fromme c0e20a15fe fix: ensure mandatory services are selected 2025-09-30 16:08:02 +02:00
Björn Fromme 061317ed4c wip: insurance booking 2025-09-30 15:13:20 +02:00
Björn Fromme a5dce776de wip: insurance booking 2025-09-30 14:37:49 +02:00
Björn Fromme 90ae78262a wip: insurance booking 2025-09-29 19:54:20 +02:00
Björn Fromme 0809f07d46 wip: insurance booking phase 5 2025-09-26 16:54:48 +02:00
Björn Fromme 199ef08e35 wip: insurance booking phase 4 2025-09-26 16:48:48 +02:00
Björn Fromme 4930a2ba42 wip: insurance booking phase 3 2025-09-26 16:46:04 +02:00
Björn Fromme 0ceabe465b wip: insurance booking phases 1 and 2 2025-09-26 16:34:32 +02:00
Björn Fromme bd7b70e465 feat: correct calculation and display of individual pricing 2025-09-26 15:32:22 +02:00
Björn Fromme 55a24b99f0 chore: update project dependencies 2025-09-26 09:01:32 +02:00
Björn Fromme 122df1a3f5 feat: improved error handling in controllers, cleanup 2025-09-26 09:00:40 +02:00
Björn Fromme 391dd63ebf feat: improved error handling of xml parsers and loaders 2025-09-26 08:21:57 +02:00
Björn Fromme 9d44197246 feat: parser and loader for insurance data 2025-09-24 15:28:43 +02:00
Björn Fromme 582fb9b52d feat: functionality to determine family status of a booking 2025-09-24 12:58:42 +02:00
Björn Fromme eed7ba4d7b feat: prevent booking flow without available rooms 2025-09-24 11:14:45 +02:00
Björn Fromme 5642193610 feat: validate room selection 2025-09-24 10:59:23 +02:00
Björn Fromme 1a1e120cf6 feat: render tooltips 2025-09-24 10:44:01 +02:00
Björn Fromme cf66b85993 feat: streamlined tooltip data attributes 2025-09-24 10:30:14 +02:00
Björn Fromme 8677e9a8a5 chore: update project dependencies 2025-09-22 17:37:11 +02:00
Björn Fromme 906f591979 feat: mark unavailable services as read-only and add tooltip 2025-09-22 17:36:49 +02:00
Björn Fromme 97cc0ac422 feat: simplify validation 2025-09-19 16:37:48 +02:00
Björn Fromme bdc7100f74 feat: simplify handling of ski pass service 2025-09-19 16:19:01 +02:00
Björn Fromme 29a51ed66d feat: cached api results for availabilities 2025-09-19 16:09:41 +02:00
Björn Fromme c9a258a350 chore: streamline property naming 2025-09-19 16:09:27 +02:00
Björn Fromme 096dd96db0 feat: enrich travel data with live availabilities 2025-09-19 12:40:20 +02:00
Björn Fromme ddd5971c06 feat: replace xss extension with symfony component 2025-09-19 12:40:02 +02:00
Björn Fromme b7638f5e9e fix: use correct ids for user validation 2025-09-10 16:26:58 +02:00
Björn Fromme b197152f12 feat: restrict access to booking edit and invoice to applicant 2025-09-10 15:30:36 +02:00
Björn Fromme 297becf7a0 fix: filter selectable services by participant's age 2025-09-10 15:25:30 +02:00
Björn Fromme b6f991b291 wip: skipass-duration based rentals filtering 2025-09-10 14:26:18 +02:00
Björn Fromme 5469c834a6 wip: transportation services with discount 2025-09-10 14:08:44 +02:00
Björn Fromme a3ea02a136 wip: service descriptions and car license plate field 2025-09-10 12:53:33 +02:00
Björn Fromme 195246af89 wip: rentals insurance field 2025-09-03 17:54:19 +02:00
Björn Fromme 2953ed0b2d wip: improved refresh logic 2025-09-03 16:48:23 +02:00
Björn Fromme ccb2fa75ac wip: transportation services in summary 2025-09-03 14:58:24 +02:00
Björn Fromme a33e6a0dd4 wip: update documentation 2025-09-02 20:09:29 +02:00
Björn Fromme 435e3a79e8 wip: dynamic availability of services 2025-09-02 20:03:22 +02:00
Björn Fromme 21054f0c45 wip: implement transportation, pickups and parking 2025-09-02 19:32:02 +02:00
Björn Fromme 605b6ca64c wip: transportation services 2025-09-02 18:28:51 +02:00
Björn Fromme bc9616e7de wip: prepare transportation services 2025-09-02 17:52:42 +02:00
Björn Fromme 491169b9d7 wip: update docs 2025-09-02 17:21:50 +02:00
Björn Fromme 0e0ce254a9 wip: show pricing 2025-09-02 16:59:55 +02:00
Björn Fromme 7b25fbf28b chore: adjust schedule for XML cleanup to run more often 2025-09-02 09:29:36 +02:00
Björn Fromme 2a5100e76d feat: update scheduler config 2025-09-02 08:51:12 +02:00
Björn Fromme e98695ea06 feat: integrate with zenstruck schedule bundle 2025-09-02 08:47:57 +02:00
Björn Fromme 1c2421fb7e fix: respect environment variable to toggle BPN debug mode 2025-09-02 08:40:17 +02:00
Björn Fromme 76aeb101bd chore: update project dependencies 2025-09-02 08:29:22 +02:00
Björn Fromme 60b655a195 wip: dynamic services fields 2025-08-27 17:49:49 +02:00
Björn Fromme 369d660e24 wip: age based filtering of options 2025-08-27 15:02:45 +02:00
Björn Fromme 47703dc205 wip: age based filtering of options 2025-08-27 13:46:00 +02:00
Björn Fromme 25bbf3d44f wip: age based filtering of options 2025-08-27 12:42:02 +02:00
Björn Fromme 803b10f981 chore: update project dependencies 2025-08-27 10:00:01 +02:00
Björn Fromme b992e4a5ea wip: conditional mandatory body dimensions 2025-07-25 21:47:00 +02:00
Björn Fromme 9c5597eb23 wip: additional services with constraints 2025-07-25 18:12:55 +02:00
Björn Fromme 2ec61d5086 wip: courses 2025-07-25 16:53:48 +02:00
Björn Fromme 8bae958e24 wip: booking process 2025-07-25 15:47:11 +02:00
Björn Fromme e7f0c09b36 wip: major refactoring 2025-07-25 11:54:07 +02:00
Björn Fromme ae2ed306b9 wip: form refactoring 2025-07-25 09:28:13 +02:00
Björn Fromme 2b122a7cc4 wip: improved booking summary 2025-07-24 12:44:38 +02:00
Björn Fromme 787e76c21c wip: refactor 2025-07-24 11:59:40 +02:00
Björn Fromme 7b1f2e7468 wip: booking summary for step 1 2025-07-24 11:59:27 +02:00
Björn Fromme 3f94c51bc8 wip: refactor 2025-07-24 11:12:06 +02:00
Björn Fromme 64d51c01b2 wip: refactor 2025-07-24 10:03:02 +02:00
Björn Fromme 955dbd2f05 fix: correct handling of readonly checkboxes with tooltips 2025-07-24 09:33:27 +02:00
Björn Fromme 318a326fdb chore: update browserslist 2025-07-24 09:33:05 +02:00
Björn Fromme 86223dc8a6 wip: field states and dependencies 2025-07-23 18:38:31 +02:00
Björn Fromme 4bb62830a4 wip: refactor forms 2025-07-23 18:10:27 +02:00
Björn Fromme 4ce731bfcb wip: refactor forms 2025-07-23 17:15:39 +02:00
Björn Fromme f225dac5fe wip: collapsible forms 2025-07-23 15:48:59 +02:00
Björn Fromme 5e1f2a8711 wip: refactor forms 2025-07-23 15:34:54 +02:00
Björn Fromme 8746397d83 wip: feedback about booked and assigned rooms 2025-07-23 11:39:03 +02:00
Björn Fromme e8c27cb51b wip: refactoring 2025-07-23 11:08:29 +02:00
Björn Fromme d2a80ea8fd wip: refactoring 2025-07-23 10:28:38 +02:00
Björn Fromme c85c72863c wip: refactoring 2025-07-23 10:27:04 +02:00
Björn Fromme 77afc0fe7b Merge branch 'master' into booking 2025-07-22 09:03:38 +02:00
Björn Fromme 7a53908b80 chore: update deployer config with extended rsync excludes 2025-07-22 09:02:47 +02:00
Björn Fromme ca4495169f Merge branch 'master' into booking 2025-07-22 08:59:19 +02:00
Björn Fromme d1344bbfb4 chore: remove project name from ddev config to enable git worktrees 2025-07-22 08:58:44 +02:00
419 changed files with 9546 additions and 58792 deletions

No files matched your search

@@ -0,0 +1,11 @@
name: opencode
repository: /Users/fromme/Development/Misc/ddev-opencode
version: ""
install_date: "2026-01-05T17:21:57+01:00"
project_files:
- web-build/Dockerfile.opencode
- commands/web/opencode
- config.opencode.yaml
- opencode/.gitignore
global_files: []
removal_actions: []
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
#ddev-generated
## Description: OpenCode AI coding assistant
## Usage: opencode
## Example: ddev opencode [...]
set -e
opencode $@
+6
View File
@@ -0,0 +1,6 @@
#ddev-generated
webimage_extra_packages:
- xdg-utils
hooks:
post-start:
- exec: "mkdir -p /var/www/html/.ddev/opencode/.opencode && ln -sf /var/www/html/.ddev/opencode/.opencode ~/.opencode"
+24 -40
View File
@@ -1,51 +1,44 @@
type: php
docroot: public
php_version: "8.3"
php_version: "8.2"
webserver_type: apache-fpm
xdebug_enabled: false
additional_hostnames:
- mysnz
- mysbw
- myser
additional_hostnames: []
additional_fqdns: []
database:
type: mysql
version: "8.4"
type: mariadb
version: "10.11"
use_dns_when_possible: true
composer_version: "2"
web_environment: []
corepack_enable: false
nodejs_version: "22"
# Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# type: <projecttype> # backdrop, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress
# See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more
# information on the different project types
# "drupal" covers recent Drupal 8+
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
# php_version: "8.2" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades.
# webimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
# webimage: <docker_image> # nginx/php docker image.
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# PostgreSQL versions can be 9-18
# MariaDB versions can be 5.5-10.8, 10.11, and 11.4.
# MySQL versions can be 5.5-8.0.
# PostgreSQL versions can be 9-17.
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -55,21 +48,12 @@ nodejs_version: "22"
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit.
# xhgui_http_port: "8143"
# xhgui_https_port: "8142"
# The XHGui ports can be changed from the default 8143 and 8142
# Very rarely used
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better,
# as leaving Xhprof enabled all the time is a big performance hit.
# host_xhgui_port: "8142"
# Can be used to change the host binding port of the XHGui
# application. Rarely used; only when port conflict and
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
# timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
@@ -95,9 +79,9 @@ nodejs_version: "22"
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild".
# nodejs_version: "22"
# nodejs_version: "20"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# See https://ddev.readthedocs.io/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
@@ -133,7 +117,7 @@ nodejs_version: "22"
# ddev_version_constraint: ""
# Example:
# ddev_version_constraint: ">= 1.24.8"
# ddev_version_constraint: ">= 1.22.4"
# This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats
@@ -162,8 +146,8 @@ nodejs_version: "22"
# - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook
@@ -212,7 +196,7 @@ nodejs_version: "22"
# ngrok_args: --basic-auth username:pass1234
# Provide extra flags to the "ngrok http" command, see
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
@@ -290,7 +274,7 @@ nodejs_version: "22"
# Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer"
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:
+5
View File
@@ -0,0 +1,5 @@
#ddev-generated
# OpenCode internal config
/.opencode/
+4
View File
@@ -0,0 +1,4 @@
#ddev-generated
RUN npm install -g opencode-ai
+2 -25
View File
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.11-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
@@ -40,45 +40,22 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
MAILER_DSN=null://null
###< symfony/mailer ###
MAILJET_API_KEY=
MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_NEWSLETTER_LIST_ID=
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
APP_BPN_USER=
APP_BPN_PASSWORD=
APP_BPN_IP=
APP_BPN_PORTS=
APP_BPN_PORT=
APP_BPN_DEBUG=false
APP_BPN_CONNECTION_TIMEOUT=5
APP_BPN_STREAM_TIMEOUT=30
APP_BPN_TOTAL_TIMEOUT=45
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/"
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
# Travel Data Service Configuration
APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Feature Flags
FEATURE_TRAVEL_SNAPSHOT=true
# Emails
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
# Booking Configuration
# Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation)
# Use 'O' during beta phase, switch to 'F' for production
DEFAULT_BOOKING_STATUS=F
API_KEYS=
-6
View File
@@ -31,9 +31,3 @@ yarn-error.log
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
/ray.php
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
+10 -10
View File
@@ -60,7 +60,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
### API travel
# @no-cookie-jar
GET {{base_url}}/api/travels/DPWMP140326
GET {{base_url}}/api/travels/SBW131225
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
@@ -72,7 +72,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
### API hotel availability remote
# @no-cookie-jar
GET {{base_url}}/api/travels/11606/152546/2026-01-24/availability
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
@@ -90,14 +90,14 @@ Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
{
"lastName": "{{$random.name.lastName}}",
"firstName": "{{$random.name.firstName}}",
"gender": "d",
"street": "{{$random.address.streetAddress}}",
"zipCode": "{{$random.address.zipCode}}",
"city": "{{$random.address.city}}",
"email": "{{$random.email}}",
"phone": "{{$random.phoneNumber.cellPhone}}"
"lastName": "Duschen",
"firstName": "Isolde",
"gender": "f",
"street": "Fooroad 1",
"zipCode": "12345",
"city": "Bartown",
"email": "[email protected]",
"phone": "12345"
}
### API pickups planning webhook
-59
View File
@@ -1,59 +0,0 @@
import { Controller } from '@hotwired/stimulus'
/**
* Monitors birthday input fields (day, month, year) and dispatches a custom event
* only when all three fields are filled with valid values.
*
* This prevents premature HTMX refreshes when users are still entering the date,
* improving UX by waiting until the complete date is entered.
*/
export default class extends Controller {
static targets = ['day', 'month', 'year']
/**
* Checks if all birthday fields are filled and dispatches a completion event.
*
* Called on input/change events from the day, month, and year fields.
* Only dispatches the 'birthday:complete' event when all three fields
* contain valid values that could form a complete date.
*/
check() {
const day = this.dayTarget.value.trim()
const month = this.monthTarget.value.trim()
const year = this.yearTarget.value.trim()
if (false === this.isComplete(day, month, year)) {
return
}
this.element.dispatchEvent(new CustomEvent('birthday:complete', { bubbles: true }))
}
/**
* Validates that all date components are filled with plausible values.
*
* @param {string} day The day value
* @param {string} month The month value
* @param {string} year The year value
*
* @returns {boolean} True if all fields contain complete values
*/
isComplete(day, month, year) {
// Day: 1-2 digits
if (0 === day.length || day.length > 2) {
return false
}
// Month: 1-2 digits
if (0 === month.length || month.length > 2) {
return false
}
// Year: exactly 4 digits for a complete year
if (4 !== year.length) {
return false
}
return true
}
}
@@ -1,13 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
payload: Object
};
connect() {
if (typeof window.dataLayer !== 'undefined') {
window.dataLayer.push(this.payloadValue);
}
}
}
+10 -1
View File
@@ -24,15 +24,24 @@ export default class extends Controller {
}
handleNotifications(event) {
console.log('[VOUCHER DEBUG] showNotifications event received:', event)
console.log('[VOUCHER DEBUG] event.detail:', event.detail)
// HTMX wraps the trigger value in an object with 'value' and 'elt' properties
// event.detail.value contains the actual array from the HX-Trigger header
const notifications = event.detail?.value || event.detail || []
console.log('[VOUCHER DEBUG] notifications array:', notifications)
console.log('[VOUCHER DEBUG] notifications count:', Array.isArray(notifications) ? notifications.length : 'not an array')
if (Array.isArray(notifications)) {
notifications.forEach((notification, index) => {
console.log(`[VOUCHER DEBUG] Processing notification ${index}:`, notification)
const className = this.getClassForType(notification.type)
this.showToast(notification.message, className)
})
} else {
console.error('[VOUCHER DEBUG] Notifications is not an array:', notifications)
}
}
@@ -57,4 +66,4 @@ export default class extends Controller {
return typeMap[type] || 'toastify--info'
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 130 130" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(3, 28)">
<path d="M107,0.3c-36.4,5.5 -81.6,19.1 -81.6,38.2c0,17.7 41.8,13.2 41.8,13.2c0,0 -67,22.3 -67,-7c0,-33.2 84.5,-44.4 106.8,-44.4Z" fill="#0084d4"/>
<path d="M17,73.5c36.4,-5.4 81.6,-19.1 81.6,-38.2c0,-17.7 -41.8,-13.2 -41.8,-13.2c0,0 67,-22.3 67,7c0,33.3 -84.5,44.4 -106.8,44.4Z" fill="#f8c62f"/>
<path d="M60.7,34.3l-12.8,4l12.2,0.8l-0.4,-2.4l1,-2.4Z" fill="#0084d4"/>
<path d="M75.9,35.2l-9.2,2.9l-3.5,9.7l-1.7,-11.3l4,-10.7l1.7,7.9l8.7,1.5Z" fill="#f8c62f"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because it is too large. Load diff
+7 -45
View File
@@ -5,8 +5,6 @@ const DEBOUNCE_MS = 200
let debounceTimeout = null
let historyRestoreGracePeriod = false
let savedScrollPosition = null
let activeRequests = 0
function getIndicator() {
return document.getElementById(INDICATOR_ID)
@@ -30,20 +28,12 @@ function hide() {
}
}
function handleBeforeRequest(event) {
function handleBeforeRequest() {
// Ignore requests triggered during history restore grace period
if (true === historyRestoreGracePeriod) {
return
}
activeRequests++
// Save scroll position of the target container before HTMX request
const target = event.detail?.target || document.getElementById('main-content')
if (target && typeof target.scrollTop === 'number') {
savedScrollPosition = { element: target, top: target.scrollTop }
}
// Don't start a new debounce if already pending
if (debounceTimeout) {
return
@@ -55,29 +45,7 @@ function handleBeforeRequest(event) {
}, DEBOUNCE_MS)
}
function handleRequestEnd() {
// Counter may already be 0 if a force-reset (history save, page show) fired
// before this afterRequest — ignore the stale completion in that case
if (0 === activeRequests) {
return
}
activeRequests--
if (0 === activeRequests) {
hide()
}
}
function handleAfterSwap() {
// Restore scroll position after HTMX swaps content
if (savedScrollPosition !== null && savedScrollPosition.element) {
savedScrollPosition.element.scrollTop = savedScrollPosition.top
savedScrollPosition = null
}
}
function handleHistoryRestore() {
activeRequests = 0
hide()
// Set grace period to ignore change events triggered by history restore
@@ -88,29 +56,23 @@ function handleHistoryRestore() {
}
function handleTimeout() {
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
// and handles the counter. This handler only shows the alert.
hide()
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
}
function handleBeforeHistorySave() {
// Hide indicator before HTMX saves the page to history cache
activeRequests = 0
hide()
}
function handlePageShow() {
activeRequests = 0
hide()
}
// Initialize event listeners
document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', handleRequestEnd)
document.body.addEventListener('htmx:afterSwap', handleAfterSwap)
document.body.addEventListener('htmx:afterRequest', hide)
document.body.addEventListener('htmx:timeout', handleTimeout)
document.body.addEventListener('htmx:historyRestore', handleHistoryRestore)
document.body.addEventListener('htmx:sendError', hide)
document.body.addEventListener('htmx:responseError', hide)
document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave)
window.addEventListener('pageshow', handlePageShow)
window.addEventListener('popstate', handleHistoryRestore)
window.addEventListener('pageshow', hide)
window.addEventListener('popstate', handleHistoryRestore)
+11 -16
View File
@@ -1,7 +1,7 @@
.button {
@apply inline-flex items-center justify-center space-x-2 h-8 leading-8 md:h-10 md:leading-10 px-4 md:px-8 cursor-pointer;
@apply uppercase leading-none text-center text-sm md:text-base;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply inline-flex items-center justify-center space-x-2 h-10 leading-10 px-4 md:px-8 cursor-pointer;
@apply uppercase leading-none text-center;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light;
@apply rounded-md;
}
@@ -20,47 +20,42 @@
}
.button--secondary {
@apply bg-none bg-primary-light text-white;
@apply hover:bg-primary-light/50;
}
.button--neutral {
@apply bg-none bg-gray-200;
@apply hover:bg-gray-200/50;
@apply bg-none bg-primary-dark text-white;
@apply hover:bg-primary-light/80;
}
.sbw {
.button--primary {
@apply bg-none bg-sbw-secondary text-white;
@apply hover:bg-none hover:bg-sbw-primary/50;
@apply hover:bg-none hover:bg-sbw-primary/80;
}
.button--secondary {
@apply bg-none bg-sbw-primary-dark text-white;
@apply hover:bg-none hover:bg-sbw-primary/50;
@apply hover:bg-none hover:bg-sbw-primary/80;
}
}
.snz {
.button--primary {
@apply bg-none bg-snz-secondary text-white;
@apply hover:bg-none hover:bg-snz-primary/50;
@apply hover:bg-none hover:bg-snz-primary/80;
}
.button--secondary {
@apply bg-none bg-snz-primary-dark text-white;
@apply hover:bg-none hover:bg-snz-primary/50;
@apply hover:bg-none hover:bg-snz-primary/80;
}
}
.ser {
.button--primary {
@apply bg-none bg-ser-secondary text-white;
@apply hover:bg-none hover:bg-ser-primary/50;
@apply hover:bg-none hover:bg-ser-primary/80;
}
.button--secondary {
@apply bg-none bg-ser-primary text-white;
@apply hover:bg-none hover:bg-ser-primary/50;
@apply hover:bg-none hover:bg-ser-primary/80;
}
}
+1 -8
View File
@@ -14,16 +14,12 @@
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.27",
"fakerphp/faker": "^1.24",
"flagception/flagception-bundle": "^6.1",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4",
"phpstan/phpdoc-parser": "^2.0",
"spatie/crypto": "^2.1",
"symfony/apache-pack": "^1.0",
@@ -73,7 +69,7 @@
},
"sort-packages": true,
"platform": {
"php": "8.3.30"
"php": "8.2.28"
}
},
"autoload": {
@@ -120,9 +116,6 @@
"require-dev": {
"deployer/deployer": "^7.5",
"friendsofphp/php-cs-fixer": "^3.75",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5",
"spatie/ray": "^1.43",
"spatie/x-ray": "^1.2",
Generated
+1673 -1115
View File
File diff suppressed because it is too large. Load diff
-3
View File
@@ -16,7 +16,4 @@ return [
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true],
];
+2 -4
View File
@@ -15,7 +15,5 @@ framework:
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
pools:
bpn.cache:
adapter: cache.adapter.filesystem
tags: true
#pools:
#my.dedicated.cache: null
-2
View File
@@ -1,2 +0,0 @@
flagception:
features: {}
+2 -2
View File
@@ -13,7 +13,7 @@ framework:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
# sync: 'sync://'
default_bus: messenger.bus.default
@@ -21,7 +21,7 @@ framework:
messenger.bus.default: []
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
+1 -3
View File
@@ -59,7 +59,7 @@ when@test:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod: &prod_config
when@prod:
monolog:
handlers:
database:
@@ -105,5 +105,3 @@ when@prod: &prod_config
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json
when@staging: *prod_config
+1 -2
View File
@@ -33,8 +33,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
when@test:
security:
-6
View File
@@ -1,15 +1,9 @@
twig:
file_name_pattern: '*.twig'
form_themes: ['forms.html.twig']
paths:
'%kernel.project_dir%/assets/images': images
globals:
htmx_change_trigger: 'change delay:100ms'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
privacy_policy_url: '%env(APP_PRIVACY_POLICY_URL)%'
when@test:
twig:
-5
View File
@@ -1,5 +0,0 @@
twig_component:
anonymous_template_directory: 'components/'
defaults:
# Namespace & directory for components
App\Twig\Components\: 'components/'
+10 -41
View File
@@ -9,52 +9,21 @@ zenstruck_schedule:
schedule_extensions:
email_on_failure:
enabled: true
to: [email protected]
tasks:
- task: app:cleanup:xml-dumps
frequency: "0 1 * * *"
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
- task: app:bpn:xml-sync
frequency: "*/15 8-19 * * *"
description: "Sync BusPro XML data every 15 min. during peak hours"
- task: app:bpn:xml-sync
frequency: "0 20-23,0-7 * * *"
description: "Sync BusPro XML data hourly outside peak hours"
- task: app:cleanup:log-entries
frequency: "15 1 * * *"
description: "Removes outdated log entries with a retention period of 6 months"
- task: app:draft:cleanup
frequency: "30 1 * * *"
description: "Removes expired drafts"
- task: app:cleanup:newsletter-opt-in-requests
frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests"
- task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly"
- task: app:bpn:refresh-travel-snapshot --refresh-after 60
frequency: "10 8-19 * * *"
description: "Syncs travel data snapshots with BusPro XML data hourly during peak hours"
- task: app:bpn:refresh-travel-snapshot --refresh-after 180
frequency: "10 20-23,0-7 * * *"
description: "Syncs travel data snapshots with BusPro XML data every 3 hours outside peak hours"
when@staging:
zenstruck_schedule:
mailer:
subject_prefix: "[MyE&P STAGING]"
when@prod:
zenstruck_schedule:
mailer:
subject_prefix: "[MyE&P PROD]"
tasks:
- task: app:bpn:xml-sync
frequency: "*/15 8-19 * * *"
description: "Syncs BusPro XML data every 15 min. during peak hours"
- task: app:bpn:xml-sync
frequency: "0 20-23,0-7 * * *"
description: "Syncs BusPro XML data hourly outside peak hours"
description: "Removes outdated log entries with a retention period of 6 months"
-3
View File
@@ -1,3 +0,0 @@
easyadmin:
resource: .
type: easyadmin.routes
+6 -75
View File
@@ -5,14 +5,8 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%'
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
path_to_keys: '%kernel.project_dir%/config/secret'
bpn_debug: '%env(APP_BPN_DEBUG)%'
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
newsletter_confirmation_ttl_hours: '%env(int:APP_NEWSLETTER_CONFIRMATION_TTL_HOURS)%'
travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%'
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
# Body dimensions choices for BodyDimensionsType
body_dimensions.height_choices:
@@ -22,7 +16,7 @@ parameters:
'167 - 178cm': '167-178'
'179 - 185cm': '179-185'
'186 - 194cm': '186-194'
'195cm oder mehr': '195+'
'195cm oder mehr': '195cm+'
body_dimensions.shoe_size_min: 36
body_dimensions.shoe_size_max: 48
body_dimensions.weight_choices:
@@ -32,26 +26,7 @@ parameters:
'67 - 78kg': '67-78'
'79 - 85kg': '79-85'
'86 - 94kg': '86-94'
'95kg oder mehr': '95+'
# domain mapping for theme, gtm id and cmp url
domain_config:
ep-reisen.de:
theme: base
gtm_id: GTM-56LWRF
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
ski-boarderweek.de:
theme: sbw
gtm_id: GTM-MS2C8CH
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
semesterende-skireisen.de:
theme: ser
gtm_id: GTM-MNKDKL9
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
snowzone.net:
theme: snz
gtm_id: GTM-M96CKL
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
'95kg oder mehr': '95k+'
services:
# default configuration for services in *this* file
@@ -64,7 +39,6 @@ services:
$xmlDump: '@xml_dump.storage'
$jsonExport: '@json_export.storage'
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
$termsAndConditionsUrl: '%terms_and_conditions_url%'
$logger: '@monolog.logger.core'
$environment: '%kernel.environment%'
@@ -85,7 +59,7 @@ services:
bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
bpn_api_port: '%env(APP_BPN_PORT)%'
debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
@@ -103,21 +77,13 @@ services:
arguments:
$path: '%path_to_keys%'
App\Service\TravelSnapshotManager:
arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\Service\TravelDataProvider:
App\Service\TravelDataService:
arguments:
$logger: '@monolog.logger.core'
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
App\Service\BookingEditDataLoader:
arguments:
$bpnCache: '@bpn.cache'
App\Service\VoucherValidator:
App\Service\VoucherValidationService:
arguments:
$cache: '@cache.app'
$logger: '@monolog.logger.bpn'
@@ -145,7 +111,6 @@ services:
- 'App\Form\Service\ParticipantTransportationOutboundFieldHandler'
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
- 'App\Form\Service\ParticipantPickupFieldHandler'
- 'App\Form\Service\ParticipantDropOffFieldHandler'
- 'App\Form\Service\ParticipantParkingFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
@@ -156,41 +121,7 @@ services:
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
# Booking Status Rules
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
# Booking Status Rule Registry
App\BusProNet\Service\BookingStatusRuleRegistry:
arguments:
$rules:
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
App\Service\CmsDataProvider:
App\Service\CmsDataService:
arguments:
$httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
App\Logger\RequestIdProcessor:
tags:
- { name: monolog.processor }
App\Service\NewsletterManager:
arguments:
$newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%'
App\Service\DomainConfigProvider:
arguments:
$domainConfig: '%domain_config%'
App\Email\Mailer:
arguments:
$defaults:
from: '%default_email_from%'
to: '%default_email_to%'
App\Service\MailjetApiClient:
arguments:
$mailjetApiKey: '%env(default::MAILJET_API_KEY)%'
$mailjetApiSecret: '%env(default::MAILJET_API_SECRET)%'
$mailjetApiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$mailjetNewsletterListId: '%env(default::MAILJET_NEWSLETTER_LIST_ID)%'
-19
View File
@@ -1,19 +0,0 @@
parameters:
# domain mapping for theme, gtm id and cmp url
domain_config:
myep.ddev.site:
theme: base
gtm_id: GTM-56LWRF
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
myswb.ddev.site:
theme: sbw
gtm_id: GTM-MS2C8CH
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
myser.ddev.site:
theme: ser
gtm_id: GTM-MNKDKL9
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
mysnz.ddev.site:
theme: snz
gtm_id: GTM-M96CKL
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
+15 -19
View File
@@ -17,6 +17,7 @@ add('shared_dirs', [
add('shared_files', [
'public/.htaccess',
'.env.local.php',
]);
set('keep_releases', 3);
@@ -32,7 +33,6 @@ $rsyncOptions = [
'.ddev',
'.git',
'.github',
'.jj',
'node_modules',
'.editorconfig',
'.env.dev.local',
@@ -46,17 +46,14 @@ $rsyncOptions = [
'.phpunit.result.cache',
'config/secret/',
'CLAUDE.md',
'cypress*',
'deploy.php',
'docs',
'package.json',
'package-lock.json',
'*.http',
'http-client*.json',
'http-client.private.env.json',
'postcss.config.js',
'phpunit.xml.dist',
'public/.htaccess',
'ray.php',
'tailwind.config.js',
'webpack.config.js',
'bin/.phpunit',
@@ -77,31 +74,31 @@ $rsyncOptions = [
];
host('prod')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setHostname('92.205.212.39')
->setRemoteUser('ssh-53857-root')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep')
->setDeployPath('/kunden/tiefschnee.de/myep')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('http_user', 'ssh-53857-root')
->set('bin/php', '/usr/bin/php82')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://my.ep-reisen.de')
;
host('staging')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setHostname('185.243.132.170')
->setRemoteUser('p546473')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-staging')
->setDeployPath('/home/www/p546473/html/myep')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net')
->set('keep_releases', 5)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep.dreipunktnull.net')
;
task('deploy', [
@@ -113,15 +110,14 @@ task('deploy', [
'rsync',
'deploy:shared',
'deploy:writable',
'deploy:cache:clear',
'database:migrate',
'cachetool:clear:opcache',
'deploy:publish',
'cachetool:clear:opcache',
'deploy:stop-workers',
]);
task('deploy:assets', function () {
runLocally('npm ci && npm run build');
runLocally('npm i && npm run build');
});
task('deploy:stop-workers', function () {
+20 -29
View File
@@ -1,9 +1,9 @@
# Symfony 7.4 Upgrade Plan
**Project:** MyEP Next Booking
**Current Version:** Symfony 6.4.x (6.4.33)
**Current Version:** Symfony 6.4.31
**Target Version:** Symfony 7.4 (LTS)
**Date:** 2026-02-23
**Date:** 2026-01-05
**Status:** Ready for Upgrade
---
@@ -18,16 +18,17 @@ The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade.
**Key Facts:**
- Symfony 7.4 was released on November 27, 2025
- Requires PHP 8.2.0+ (current: PHP 8.3.30)
- Requires PHP 8.2.0+ (current: PHP 8.2.29)
- LTS version with bug fixes until November 2028, security fixes until November 2029
- Symfony 8.0 = Symfony 7.4 minus deprecations (same features, PHP 8.4 required)
**Pre-Upgrade Status:**
- PHP version: 8.3.30 (compatible; Symfony 7.4 requires 8.2+)
- PHP version: 8.2.29 (compatible)
- OAuth2 Server Bundle: 1.1.0 (compatible)
- Stimulus Bundle: 2.32.0 (compatible)
- KnpMenuBundle: Removed (no longer in use)
- Doctrine ORM: 3.6.0 (compatible)
- All 433 tests passing
- All 312 tests passing
- Email validation mode: Configured (html5)
- 18 test-only deprecation warnings (cosmetic, do not affect runtime)
@@ -48,14 +49,19 @@ The previous upgrade plan (dated 2025-12-06) has been updated. All pre-upgrade t
| Email validator "loose" mode deprecation | Needs fix | **Completed** (validator.yaml configured) |
| Failing test in BookingDataProcessorTest | Unrelated to upgrade | **Resolved** (test now passes) |
### Items No Longer Relevant
| Item | Reason |
|------|--------|
| KnpMenuBundle upgrade | **Bundle removed from project** |
---
## Current State Analysis
### PHP Version Compatibility
- **Current Runtime:** PHP 8.3.30
- **Composer Platform:** PHP 8.3.30
- **Current Runtime:** PHP 8.2.29
- **Composer Platform:** PHP 8.2.28
- **Symfony 7.4 Required:** PHP 8.2.0+
- **Status:** Compatible
@@ -75,11 +81,6 @@ All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
| `symfony/monolog-bundle` | 3.11.0 | Yes |
| `zenstruck/schedule-bundle` | 1.9.0 | Yes |
| `league/flysystem-bundle` | 3.6.1 | Yes |
| `league/flysystem-sftp-v3` | 3.30.0 | Yes |
| `nelexa/zip` | 4.0.2 | Yes (verify post-upgrade) |
| `spatie/crypto` | 2.1.1 | Yes |
| `easycorp/easyadmin-bundle` | 4.29.1 (^4.27) | Yes |
### Code Quality Assessment
@@ -123,7 +124,7 @@ All Email constraints in the codebase also explicitly use `mode: 'strict'`:
**Impact:** These warnings are cosmetic and do not affect:
- Runtime behavior (all Email constraints use explicit `mode: 'strict'`)
- Symfony 7.4 compatibility (no code changes needed)
- Test results (all 433 tests pass)
- Test results (all 312 tests pass)
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
@@ -181,16 +182,12 @@ Update all packages from `6.4.*` to `7.4.*`:
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "7.4.*",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
}
```
Keep `symfony/maker-bundle` at `^1.0` (compatible with Symfony 7.4).
### 2. Update Symfony Flex Configuration
**File:** `composer.json`
@@ -255,7 +252,6 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
1. **Update composer.json**
- Update all Symfony package constraints to `7.4.*`
- Update `extra.symfony.require` to `"7.4.*"`
- Consider tightening `php` to `>=8.2` (Symfony 7.4 requires 8.2+; optional if already on 8.3)
2. **Run Composer Update**
```bash
@@ -309,7 +305,6 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
- [ ] User registration
- [ ] Password reset
- [ ] Personal data management
- [ ] EasyAdmin CRUD / admin panels
4. **Check Symfony Profiler**
- Review deprecation warnings (should be minimal)
@@ -325,10 +320,6 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
- Verify Toastify notifications
- Check TailwindCSS compilation
6. **Verify ZIP File Functionality**
- Test any features using `nelexa/zip` (package last updated 2022)
- Verify document generation and downloads work correctly
### Phase 4: Bug Fixes (0-2 hours)
Address any issues discovered during testing:
@@ -395,7 +386,7 @@ ddev exec vendor/bin/dep rollback production
## Testing Checklist
### Unit Tests
- [ ] All PHPUnit tests pass (433 tests)
- [ ] All PHPUnit tests pass (312 tests)
- [ ] No new deprecation warnings
- [ ] Code coverage maintained
@@ -451,7 +442,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
## Success Criteria
- [ ] All PHPUnit tests pass (433 tests)
- [ ] All PHPUnit tests pass (312 tests)
- [ ] No deprecation warnings in Symfony Profiler
- [ ] All critical user flows functional
- [ ] OAuth2 authentication working
@@ -478,7 +469,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
---
**Document Version:** 7.0
**Previous Version:** 6.0 (dated 2026-01-09)
**Last Updated:** 2026-02-23
**Status:** Ready for Symfony 7.4 Upgrade (updated for current project state)
**Document Version:** 5.0
**Previous Version:** 4.0 (dated 2025-12-06)
**Last Updated:** 2026-01-05
**Status:** Ready for Symfony 7.4 Upgrade
-196
View File
@@ -1,196 +0,0 @@
# Booking Flow Reference
Last updated: 2026-04-16
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
---
## Shared Concepts
### `BookingDto`
The central data object for both flows. Holds room selections, participants, payment method, and travel reference. Lives in the HTTP session between requests. Has two modes: `MODE_CREATE` and `MODE_EDIT`.
### `BookingSessionManager`
Owns all reads and writes to session-stored `BookingDto` instances. Also manages the baseline room snapshot used for change detection in the create flow.
### `BookingCreateContextFactory`
Assembles the view model (`BookingCreateContext`) passed to create-flow templates. Handles room grouping (by-pax vs. by-room) and wires summary sidebar data.
---
## Create Flow
```
Step 1 (room selection)
→ Step 2 (participants)
→ Step 2 sub-form (per-participant, HTMX)
→ Step 3 (payment + API pre-validation)
→ Step 4 (confirmation + final submission)
→ Success
```
### Entry / Bootstrap
`BookingExceptionHandlerTrait::getOrCreateBookingCreateDto()` is called by every step controller. If no DTO exists in session, it calls `BookingConfigurator::startFreshBooking()` which loads travel data and initialises a fresh `BookingDto`.
### Step 1 — Room Selection
**Controller:** `Create/Step1Controller`
**Route:** `GET|POST /bookings/create/rooms`
1. Load or create DTO via `getOrCreateBookingCreateDto()`
2. `BookingSessionManager::getOrCreateBaselineSnapshot()` — saves the current room selection as a comparison point for change detection
3. Render `BookingCreateStep1Type` form
4. On submit:
- If room selection changed from baseline: `BookingDto::resetParticipantAssignments()` — clears stale assignments
- `BookingConfigurator::updateBookingStatusFromRoomSelection()` — may switch booking to inquiry mode if capacity constraints require it
- `BookingSessionManager::saveBookingDto()`, advance `currentStep` to 2
- `BookingSessionManager::clearBaselineSnapshot()`
- Redirect to Step 2
HTMX refresh (`/bookings/create/refresh`): processes the form without validation and returns updated room selection + summary sidebar blocks.
### Step 2 — Participants (Card Overview)
**Controller:** `Create/Step2Controller`
**Route:** `GET|POST /bookings/create/participants`
1. Load DTO from session (or redirect to Step 1 if missing)
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
4. `ParticipantDataPrefiller::shouldPrepopulateApplicant()` + `prepopulateApplicantFromUser()` — prefills participant[0] from user profile on first visit
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
8. `BookingConfigurator::applyCreateBookingStatusRules()` — re-evaluates booking status (book / inquiry / not-bookable)
9. `BookingSessionManager::saveBookingDto()`
10. On form submit (all participants valid): advance to Step 3, redirect
### Step 2 — Individual Participant Form (HTMX)
**Controller:** `Create/Step2ParticipantController`
**Routes:** `GET|POST /bookings/create/participants/{index}` and `/participants/{index}/refresh`
Each participant card lazy-loads its form via HTMX. The controller:
1. Verifies the participant slot exists
2. Refreshes availability
3. Handles dummy data fill (`ParticipantDataPrefiller::isDummyDataFillRequested()` / `fillDummyParticipant()`)
4. On submit: re-applies status rules, saves to session, redirects to card overview
5. `ParticipantFormSupport` is used for form creation options and notification draining
### Step 3 — Payment + API Pre-Validation
**Controller:** `Create/Step3Controller`
**Route:** `GET|POST /bookings/create/payment`
1. Load DTO from session
2. `BookingConfigurator::applyCreateBookingStatusRules()`
3. On submit:
- `ApiClient::createBookingInquiry()` — submits booking to API for validation without committing it
- If inquiry fails with "nicht möglich" + "Anfrage" in message: auto-switch to inquiry mode, advance to Step 4
- If inquiry fails otherwise: show error, stay on step
- Price verification: `BookingPriceCalculator::calculateGrandTotal()` is compared against the API response total (accounting for promotional/goodwill voucher discounts and API-applied group discounts). Mismatch triggers `BookingPriceMismatchAnalyzer` for detailed logging.
- On success: advance `currentStep` to 4, redirect
### Step 4 — Confirmation + Final Submission
**Controller:** `Create/Step4Controller`
**Route:** `GET|POST /bookings/create/confirmation`
1. Load DTO from session
2. Newsletter subscription check for opt-in display
3. On submit:
- `BookingConfigurator::applyCreateBookingStatusRules()`
- `ApiClient::createBooking()` — final booking submission (already validated in Step 3)
- Newsletter double-opt-in if selected
- On success: store booking number + total in flash (for analytics), clear travel data cache, `BookingSessionManager::clearBookingDto()`, redirect to success page
---
## Edit Flow
```
Entry (start)
→ Load (session or API + draft restore)
→ Participant sub-forms (HTMX, per-participant)
→ Submission
```
### Entry
**Controller:** `Edit/IndexController::start()`
**Route:** `GET /bookings/{id}/edit/start`
Clears any existing session DTO for edit mode and invalidates the API booking cache, then redirects to the edit page. This ensures a clean load from API on every intentional re-entry.
### Load
**Controller:** `Edit/IndexController::index()`
**Route:** `GET|POST /bookings/{id}/edit`
Loading is delegated entirely to `BookingEditDataLoader::loadFormData()`:
**If session DTO exists and matches the requested booking ID:**
- `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
- `TravelDataProvider::getMutabilityData()` + `patchMutability()` — applies cached mutability state to DTO
**If no session DTO (first load or after `start`):**
`initializeFromApi()`:
1. `fetchBookingData()` — `ApiClient::getBooking()` with 5-minute cache (tagged by user ID for bulk invalidation)
2. `TravelDataProvider::getTravelData()` — loads travel data (XML or snapshot)
3. `TravelDataProvider::getAvailabilityData(forceRefresh: true)` + `patchAvailabilities()` — force-refreshes availability at edit start, populates cache for subsequent loads within the session
4. `TravelDataProvider::getMutabilityData(cached: true)` + `patchMutability()` — applies which service categories are currently editable
5. `BookingDataProcessor::createBookingDtoFromBooking()` — hydrates `BookingDto` from API booking entity
6. Agency code resolved via `AgencyLoader` (used by field state conditions)
7. `BookingChangeTracker::generateFingerprint()` — sets `originalFingerprint` on DTO **before** draft is applied, so dirty detection compares against original API data
8. `BookingEditDraftManager::findDraft()` + `applyDraftToDto()` — if draft exists, merges user edits on top of fresh API data. Sets `draftWasRestored = true` if successful.
9. `BookingSessionManager::saveBookingDto()`
Back in the controller: shows "draft restored" flash if applicable, fetches booking data for surcharge/status display, renders the card overview with `BookingEditContextFactory::createOverviewContext()`.
### Individual Participant Form (HTMX)
**Controller:** `Edit/ParticipantController`
**Routes:** `GET|POST /bookings/{id}/edit/participants/{index}` and `/{index}/refresh`
Per-participant forms are lazy-loaded, matching the create flow pattern.
1. `BookingEditContextFactory::prepareBookingDto()` — refreshes availabilities on the DTO travel object
2. `ParticipantFormSupport` — form creation, notification draining
3. On submit: `BookingSessionManager::saveBookingDto()` + `BookingEditDraftManager::saveDraft()` — persists participant edits to both session and draft
### Submission
**Service:** `BookingEditSubmitter::handleSubmission()`
Called from `Edit/IndexController` on valid form submit.
1. Invalidate API cache, fetch fresh booking data
2. Force-refresh mutability data (`TravelDataProvider::getMutabilityData(forceRefresh: true)`) and patch DTO travel
3. `BookingEditSubmitGuard::reconcileImmutableCategories()` — reverts any DTO fields that have become immutable since the session was loaded. Shows flash if anything was reverted.
4. `ApiClient::updateBooking()` — submit
5. On success: invalidate cache again, `BookingSessionManager::clearBookingDto()`, `BookingEditDraftManager::deleteDraft()`
### Other Edit Routes
| Route | Action |
|-------|--------|
| `GET /bookings/{id}/edit/reload` | Shows confirmation modal |
| `POST /bookings/{id}/edit/reload` | Clears session + **deletes draft**, reloads from API |
| `GET /bookings/{id}/edit/cancel` | Clears session, **preserves draft**, redirects to booking list |
The distinction between reload and cancel is intentional: cancel keeps the draft so the user can return to their changes later; reload discards everything.
---
## Key Invariants
- `currentStep` on the DTO gates forward navigation in create mode; controllers redirect to the current step if a higher step is requested directly.
- The original fingerprint is set before draft application in edit mode. Dirty detection (`BookingChangeTracker::isDirty()`) always compares the live session DTO against the original API state, not against the draft.
- Mutability is always patched from fresh or cached data before rendering. The DTO travel object is the authoritative source of which fields are editable at render time.
- Drafts survive cancel but not reload, and are deleted on successful submission.
-122
View File
@@ -1,122 +0,0 @@
# Service Simplification Plan
Status: active
Last updated: 2026-04-13
## Purpose
This document tracks the next pass of service simplification work. The goal is to reduce orchestration density, make the booking flow easier to follow for a human reader, and keep responsibilities aligned with the actual boundaries in the code.
The emphasis is not on deleting services for its own sake. The emphasis is on:
- keeping one clear owner for each meaningful boundary
- removing thin wrappers and pass-through helpers
- avoiding services that mostly shuffle data between layers
- keeping presentation concerns out of calculation and orchestration code
## Current Read
The codebase is already in a better place than it was at the start of the refactor, but a few services still carry more than one responsibility:
- `BookingService` now mostly covers booking bootstrap, service preselection, and booking status rules.
- `BookingParticipantCountService` was removed — its logic lives as a private method in the one controller that needed it (`Step2Controller`).
- `BookingRoomSelectionService` was removed — its logic lives as a private method in `BookingCreateContextFactory`.
- `BookingSummaryParticipantCountService` was removed — its logic lives as a private method in `BookingSummaryDataService`.
- `ParticipantPrepopulationService` now owns applicant prefill plus the create-mode dummy-data shortcut.
- `BookingEditParticipantContextFactory` now prepares the edit participant page context directly, replacing the older pass-through participant form service.
- `BookingPriceCalculatorService` is focused on pricing, but it still sits close to display-oriented behavior in adjacent code paths.
- `TravelDataService` remains broad and is likely the next larger boundary after booking orchestration is reduced.
One registry stands out as a real orchestration boundary and should be left alone for now:
- `ParticipantFieldHandlerRegistry`
It is not just a lookup table. It owns execution order, edit-mode mutability gating, and synchronization of submitted form data back into the DTO state.
## Next Pass
### 1. Keep pricing calculation focused
Primary goal: keep pricing code about pricing, not rendering.
Concrete next steps:
- keep `BookingPriceCalculatorService` as the pricing boundary
- continue removing display formatting from pricing code paths
- keep any remaining view-specific formatting in the presentation layer or a dedicated UI helper
- avoid introducing another service that only formats values already known to the view
Decision rule:
- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible
### 2. Leave the field-handler registry in place
Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer.
Concrete next steps:
- do not refactor `ParticipantFieldHandlerRegistry` in this pass
- revisit only if a later change can split ordering, mutability, and synchronization into clear collaborators without making the flow harder to trace
Decision rule:
- if a registry owns actual workflow behavior, treat it as a boundary rather than a smell
## Follow-Up Queue
After the booking service pass, the next likely candidates are:
### `TravelDataService`
This is the largest broad service still in the codebase. It likely needs a later pass if the application should become easier to follow end to end.
Likely directions:
- separate runtime travel loading from cache/snapshot maintenance if the public API still feels too wide
- keep the read path explicit and avoid hiding maintenance work behind one large method surface
### `BookingPriceCalculatorService`
This service should stay focused on pricing logic, but it may still have room for further internal cleanup if more display or transport aggregation concerns surface.
Likely directions:
- keep calculation responsibilities together
- avoid dragging presentation behavior back into the calculator
- split only if a sub-boundary becomes obvious and reusable
### `ParticipantFieldHandlerRegistry`
This stays on the list only as a future optional refactor, not as an immediate target.
Likely directions, only if justified later:
- isolate ordering/sorting logic if it becomes independently meaningful
- split synchronization code if a clearer DTO/form boundary emerges
- otherwise leave it as the central orchestration point for participant field processing
## Current Notes
- `templates/booking/_summary_travel_info.html.twig` already renders the summary participant count directly, so the remaining work here is naming and contract clarity rather than Twig branching.
- The summary count still needs a clear name if the code should distinguish the display-oriented count from the canonical participant total in the DTO/service layer.
- `ParticipantFormSupportService` was reviewed and kept: it is shared between two controllers (`Step2ParticipantController` and `Edit/ParticipantController`) with real shared logic, not just delegation.
## Progress Tracker
| Item | Status | Notes |
|------|--------|-------|
| Participant card DTO cleanup | Done | Card data now uses typed DTOs instead of nested array payloads |
| Room label formatting cleanup | Done | Pricing labels now have a dedicated presentation helper |
| Booking service split | Done | Session lifecycle, hydration, baseline snapshot, return URL handling, room grouping, and participant count shaping moved out of `BookingService` |
| Dummy prefill extraction | Done | Moved into `ParticipantPrepopulationService` |
| Micro-service consolidation | Done | `BookingRoomSelectionService`, `BookingParticipantCountService`, `BookingSummaryParticipantCountService` inlined into their single callers |
| Pricing service review | Pending | Keep focused on calculation, not rendering |
| Travel data service review | Pending | Broad boundary, likely later pass |
| Participant field registry review | Deferred | Real orchestration boundary, intentionally left alone for now |
## Acceptance Criteria
The next booking-service pass is only worth keeping if it:
- reduces the number of unrelated responsibilities in `BookingService`
- makes the booking flow easier to trace from controller to session/DTO state
- preserves existing booking behavior and test coverage
- does not replace one large service with several generic "manager" classes
## Working Agreement
- Update this document as decisions are made.
- Record rejected simplification ideas here with a short reason.
- Keep the plan aligned with actual code, not with an abstract architecture ideal.
- If a future simplification does not clearly reduce cognitive load, do not add it.
File diff suppressed because it is too large. Load diff
+12 -47
View File
@@ -7,8 +7,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep.ddev.site/token",
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email"
},
@@ -17,8 +17,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep.ddev.site/token",
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email profile"
},
@@ -27,43 +27,8 @@
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep.ddev.site/token",
"Scope": "api"
}
}
}
},
"staging": {
"Security": {
"Auth": {
"oauth2_email": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email"
},
"oauth2_profile": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email profile"
},
"oauth2_api": {
"Type": "OAuth2",
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token",
"Scope": "api"
}
}
@@ -77,8 +42,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email"
},
@@ -87,8 +52,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email profile"
},
@@ -97,8 +62,8 @@
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Scope": "api"
}
}
-191
View File
@@ -1,191 +0,0 @@
###
# Mailjet API test requests for IntelliJ HTTP Client
#
# 1) Fill in variables below (or move them to http-client.private.env.json).
# 2) Set `mailjet_api_key` and `mailjet_api_secret` in your env file.
# IntelliJ HTTP Client will inject them into the Authorization header below.
#
# Required variables:
# - mailjet_base_url (example: https://api.mailjet.com/v3/REST)
# - mailjet_api_key
# - mailjet_api_secret
# - mailjet_contact_email
# - mailjet_list_id
#
# @no-cookie-jar
### Mailjet health check (authenticated)
# @no-cookie-jar
GET {{mailjet_base_url}}/apikey
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Find contact by email
# @no-cookie-jar
GET {{mailjet_base_url}}/contact?Email={{mailjet_contact_email}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("contact_id", String(response.body.Data[0].ID));
}
%}
### Create contact (run if not found)
# @no-cookie-jar
POST {{mailjet_base_url}}/contact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Email": "{{mailjet_contact_email}}",
"IsExcludedFromCampaigns": false
}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("contact_id", String(response.body.Data[0].ID));
}
%}
### Get contact by ID (after find/create)
# @no-cookie-jar
GET {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Mark contact as subscribed (opt-in)
# @no-cookie-jar
PUT {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"IsExcludedFromCampaigns": false
}
### Mark contact as unsubscribed (opt-out)
# @no-cookie-jar
PUT {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"IsExcludedFromCampaigns": true
}
### Get newsletter list by ID
# @no-cookie-jar
GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### List all available contact lists
# @no-cookie-jar
GET {{mailjet_base_url}}/contactslist?Limit=1000
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Check list recipient status (before actions)
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("listrecipient_id", String(response.body.Data[0].ID));
}
%}
### Subscribe to list (addnoforce)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addnoforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addnoforce
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Unsubscribe from list (unsub)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "unsub",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after unsub
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Re-subscribe attempt with addnoforce (expected to keep unsubscribed state)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addnoforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addnoforce re-subscribe attempt
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Re-subscribe with addforce (expected to re-subscribe)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addforce
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260106091117 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create booking_edit_draft table for persisting user edits during booking edit flow';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE booking_edit_draft (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, booking_id INT NOT NULL, form_data JSON NOT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_E6548098A76ED395 (user_id), UNIQUE INDEX user_booking_unique (user_id, booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE booking_edit_draft ADD CONSTRAINT FK_E6548098A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE booking_edit_draft DROP FOREIGN KEY FK_E6548098A76ED395');
$this->addSql('DROP TABLE booking_edit_draft');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260109081315 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE booking_edit_draft ADD travel_date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\'');
$this->addSql('CREATE INDEX IDX_DRAFT_TRAVEL_DATE ON booking_edit_draft (travel_date)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX IDX_DRAFT_TRAVEL_DATE ON booking_edit_draft');
$this->addSql('ALTER TABLE booking_edit_draft DROP travel_date');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260110105253 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add booking_number column to booking_edit_draft table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft ADD booking_number INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft DROP booking_number');
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260113140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add date_id and hotel_id columns to booking_edit_draft table for export functionality';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft ADD date_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE booking_edit_draft ADD hotel_id INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft DROP date_id');
$this->addSql('ALTER TABLE booking_edit_draft DROP hotel_id');
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260119174522 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add profile_complete flag to user table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD profile_complete TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user DROP profile_complete');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260318113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create newsletter_opt_in_confirmation table for double opt-in confirmations';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE TABLE newsletter_opt_in_confirmation (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH (token_hash), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE newsletter_opt_in_confirmation');
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260320071724 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add error_code column and index to log_entry table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE log_entry ADD error_code VARCHAR(12) DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry (error_code)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry');
$this->addSql('ALTER TABLE log_entry DROP error_code');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260321120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create travel_snapshot table for DB-primary travel persistence';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE travel_snapshot (id INT AUTO_INCREMENT NOT NULL, date_id INT NOT NULL, hotel_id INT NOT NULL, date_code VARCHAR(64) DEFAULT NULL, label VARCHAR(255) DEFAULT NULL, product_id INT DEFAULT NULL, product_code VARCHAR(64) DEFAULT NULL, hotel_code VARCHAR(64) DEFAULT NULL, hotel_label VARCHAR(255) DEFAULT NULL, date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', payload LONGTEXT NOT NULL, payload_hash VARCHAR(64) NOT NULL, captured_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', extended_refreshed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_snapshot_date_hotel (date_id, hotel_id), INDEX idx_snapshot_date_code (date_code), INDEX idx_snapshot_product_id (product_id), INDEX idx_snapshot_date_to (date_to), INDEX idx_snapshot_extended_refreshed_at (extended_refreshed_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE travel_snapshot');
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260323182524 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE booking_edit_draft CHANGE form_data form_data JSON NOT NULL');
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL, CHANGE extra extra JSON DEFAULT NULL');
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_newsletter_opt_in_token_hash TO UNIQ_70505214B3BC57DA');
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL, CHANGE hotel_codes hotel_codes JSON NOT NULL');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_70505214b3bc57da TO UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH');
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL COLLATE `utf8mb4_bin`, CHANGE hotel_codes hotel_codes JSON NOT NULL COLLATE `utf8mb4_bin`');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL COLLATE `utf8mb4_bin`, CHANGE extra extra JSON DEFAULT NULL COLLATE `utf8mb4_bin`');
$this->addSql('ALTER TABLE booking_edit_draft CHANGE form_data form_data JSON NOT NULL COLLATE `utf8mb4_bin`');
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Changes travel_snapshot.payload from TEXT to LONGTEXT so large
* plain JSON snapshot payloads can be stored safely.
*/
final class Version20260324120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Change travel_snapshot.payload from TEXT to LONGTEXT for plain JSON snapshot payloads';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload LONGTEXT NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload TEXT NOT NULL');
}
}
+1206 -756
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -5,7 +5,7 @@
"@babel/preset-env": "^7.16.0",
"@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0",
"@symfony/webpack-encore": "^6.0.0",
"@symfony/webpack-encore": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.20",
@@ -20,7 +20,7 @@
"tippy.js": "^6.3.7",
"toastify-js": "^1.12.0",
"webpack": "^5.74.0",
"webpack-cli": "^6.0.1",
"webpack-cli": "^5.1.0",
"webpack-notifier": "^1.15.0"
},
"license": "WTFPL",
-13
View File
@@ -1,13 +0,0 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
parameters:
level: 6
paths:
- src
tmpDir: var/phpstan
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/doctrine-object-manager.php
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 124 74" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"><g><g><path d="M107,0.3c-36.4,5.5 -81.6,19.1 -81.6,38.2c0,17.7 41.8,13.2 41.8,13.2c0,0 -67,22.3 -67,-7c0,-33.2 84.5,-44.4 106.8,-44.4Z" style="fill:#0084d4;fill-rule:nonzero;"/><path d="M17,73.5c36.4,-5.4 81.6,-19.1 81.6,-38.2c0,-17.7 -41.8,-13.2 -41.8,-13.2c0,0 67,-22.3 67,7c0,33.3 -84.5,44.4 -106.8,44.4Z" style="fill:#f8c62f;fill-rule:nonzero;"/><g><path d="M60.7,34.3l-12.8,4l12.2,0.8l-0.4,-2.4l1,-2.4Z" style="fill:#0084d4;fill-rule:nonzero;"/><path d="M75.9,35.2l-9.2,2.9l-3.5,9.7l-1.7,-11.3l4,-10.7l1.7,7.9l8.7,1.5Z" style="fill:#f8c62f;fill-rule:nonzero;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 995 B

-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
+6
View File
@@ -0,0 +1,6 @@
{
"icons": [
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
]
}
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'host' => 'host.orb.internal',
'port' => 23517,
'remote_path' => '/var/www/html',
'local_path' => '/Users/fromme/Development/Symfony/myep-next-booking',
];
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Admin\Field;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
class JsonDataField implements FieldInterface
{
use FieldTrait;
public static function new(string $propertyName, ?string $label = null): self
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->setTemplatePath('admin/field/json.html.twig');
}
}
+49 -385
View File
@@ -4,43 +4,39 @@ namespace App\BusProNet;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\ContactFormResponse;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingDto;
use App\Form\Model\RegistrationDto;
use App\Service\RequestIdGenerator;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
class ApiClient
{
use ApiClientTrait;
public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_BOOKING = 'BUCHUNG';
@@ -50,21 +46,14 @@ class ApiClient
public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG';
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
/** @var array<string, mixed> */
private array $config;
private float $operationStartTime;
private int $selectedPort;
private int $requestCounter = 0;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
array $options,
) {
$this->config = $this->resolveOptions($options);
@@ -90,14 +79,21 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
public function register(RegistrationDto $registrationData): Notification
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu',
'adressdaten' => $registrationData->toPayload(),
'adressdaten' => [
'geschlecht' => $registrationData->gender,
'vorname' => $registrationData->firstName,
'name' => $registrationData->name,
'kommunikation' => [
'email' => $registrationData->email,
],
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
@@ -148,13 +144,13 @@ class ApiClient
public function createAddress(
PersonalData $personalData,
bool $debug = false,
): RegistrationResponse|Notification {
): ContactFormResponse|Notification {
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(false),
'adressdaten' => $personalData->toPayload(),
'ohnemailversand' => 'True',
];
@@ -320,21 +316,6 @@ class ApiClient
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, $data);
}
/**
* @throws ApiClientException
*/
@@ -416,30 +397,6 @@ class ApiClient
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
}
/**
* Fetches raw XML travel data from the BusProNet API.
*
* Returns the unprocessed XML response for direct storage in the XML export directory.
* The response format matches the XML export structure from BusPro.
*
* @param int $travelId The travel product ID to fetch
*
* @return string The raw XML response
*
* @throws ApiClientException If the API request fails
*/
public function getTravelDataXml(int $travelId): string
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
'idprodukt' => $travelId,
];
return $this->sendRequestRaw($data);
}
/**
* @throws ApiClientException
*/
@@ -532,11 +489,10 @@ class ApiClient
}
/**
* Sends raw XML to the BPN API with key regeneration and automatic retry.
* Sends raw XML to the BPN API with key regeneration.
*
* Parses the XML to extract the request type, regenerates the authentication key
* with the current date, and sends the request. Returns the raw XML response.
* Automatically retries on immediate connection close (server busy).
*
* @param string $xml The raw XML request body
* @param bool $debug Enable debug mode (XML dumps)
@@ -547,18 +503,7 @@ class ApiClient
*/
public function sendRawXml(string $xml, bool $debug = false): string
{
return $this->executeWithRetry(fn () => $this->doSendRawXml($xml, $debug));
}
/**
* Performs the actual raw XML request to the BPN API.
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doSendRawXml(string $xml, bool $debug = false): string
{
$requestId = $this->getRequestId();
$requestId = date(DATE_ATOM).uniqid();
$doc = new \DOMDocument();
if (false === @$doc->loadXML($xml)) {
@@ -589,19 +534,25 @@ class ApiClient
$body = $doc->saveXML();
$this->logger->info('Sending raw XML request to BPN API', [
'requestId' => $requestId,
'type' => $type,
]);
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending raw XML request to BPN API', [
'requestId' => $requestId,
'type' => $type,
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$socket = $this->connect(
$this->config['bpn_api_ip'],
$this->config['bpn_api_port'],
$this->config['max_retries'],
$this->config['connection_timeout'],
$this->config['stream_timeout'],
$this->config['total_timeout']
);
$this->send($socket, $body, $this->config['total_timeout']);
$response = $this->receive($socket, $this->config['total_timeout']);
$this->disconnect($socket);
$responseXml = substr($response, 10);
@@ -616,81 +567,9 @@ class ApiClient
/**
* @throws ApiClientException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
}
/**
* @throws ApiClientException
*/
/** @param array<string, mixed> $data */
private function sendRequestRaw(array $data): string
{
return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data));
}
/**
* Executes an operation with automatic retry on immediate connection close.
*
* When the BPN server is busy, it may close connections immediately without responding.
* This method detects such conditions and automatically retries after a short delay.
*
* @throws ImmediateConnectionCloseException If all retry attempts fail
*/
private function executeWithRetry(callable $operation, ?string $type = null): mixed
{
$maxAttempts = $this->config['busy_retry_attempts'];
$retryDelay = $this->config['busy_retry_delay'];
$lastException = null;
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
return $operation();
} catch (ImmediateConnectionCloseException $e) {
$lastException = $e;
if ($attempt < $maxAttempts) {
$context = [
'attempt' => $attempt,
'maxAttempts' => $maxAttempts,
'retryDelay' => $retryDelay,
];
if (null !== $type) {
$context['type'] = $type;
}
$this->logger->warning('BPN server busy, retrying request', $context);
sleep($retryDelay);
}
}
}
$context = ['attempts' => $maxAttempts];
if (null !== $type) {
$context['type'] = $type;
}
$this->logger->error('BPN server busy after all retry attempts', $context);
throw $lastException;
}
/**
* Performs the actual request to the BPN API.
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
$requestId = $this->getRequestId();
$requestId = date(DATE_ATOM).uniqid();
$body = $this
->serializer
@@ -700,33 +579,30 @@ class ApiClient
])
;
$this->logger->info('Sending request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
]);
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$socket = $this->connect(
$this->config['bpn_api_ip'],
$this->config['bpn_api_port'],
$this->config['max_retries'],
$this->config['connection_timeout'],
$this->config['stream_timeout'],
$this->config['total_timeout']
);
$this->send($socket, $body, $this->config['total_timeout']);
$response = $this->receive($socket, $this->config['total_timeout']);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
if ('' === $xml) {
$this->logger->error('Empty response body received from API (header-only response)', [
'request_id' => $requestId,
'raw_length' => strlen($response),
]);
throw new ApiClientException('Empty response body received from API');
}
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
@@ -744,46 +620,6 @@ class ApiClient
throw new ApiClientException('Unexpected response received from API');
}
/**
* @param array<string, mixed> $data
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doSendRequestRaw(array $data): string
{
$requestId = $this->getRequestId();
$body = $this->serializer->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
]);
if (true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending raw request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$xml = substr($response, 10);
if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
return $xml;
}
private function dumpXmlToFile(string $type, string $requestId, string $body): void
{
try {
@@ -792,14 +628,6 @@ class ApiClient
}
}
private function getRequestId(): string
{
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
?? $this->requestIdGenerator->generateBaseId();
return $baseId.'_'.++$this->requestCounter;
}
private function createKey(string $username, string $password, string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
@@ -807,167 +635,6 @@ class ApiClient
return md5($username.$password.$date.$type);
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect()
{
// Randomly select a port from the available ports for load balancing
$this->selectedPort = $this->config['bpn_api_ports'][array_rand($this->config['bpn_api_ports'])];
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) {
return @fsockopen(
$this->config['bpn_api_ip'],
$this->selectedPort,
$errNo,
$errStr,
$this->config['connection_timeout']
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->config['max_retries'] > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $this->config['total_timeout'],
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $this->config['stream_timeout']);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
/**
* @param resource $socket
*
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket): string
{
$response = '';
$readAttempts = 0;
$totalTimeout = $this->config['total_timeout'];
while (false === feof($socket)) {
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
// Check if this is an immediate rejection (0 bytes, < 1 second)
// This indicates the server is busy rather than a true timeout
if (0 === strlen($response) && $elapsedTime < 1.0) {
$this->logger->warning('Server closed connection immediately', [
'elapsed_time' => $elapsedTime,
'bytes_received' => 0,
'read_attempts' => $readAttempts,
]);
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
}
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
if (0 === strlen($response)) {
$elapsedTime = microtime(true) - $this->operationStartTime;
$this->logger->warning('Server closed connection without sending data', [
'elapsed_time' => $elapsedTime,
'read_attempts' => $readAttempts,
]);
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
}
return $response;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
@@ -975,17 +642,14 @@ class ApiClient
'bpn_username',
'bpn_password',
'bpn_api_ip',
'bpn_api_ports',
'bpn_api_port',
]);
$optionsResolver->setAllowedTypes('bpn_api_ports', ['array']);
$optionsResolver->setDefaults([
'max_retries' => 25,
'debug' => false,
'connection_timeout' => 5,
'stream_timeout' => 30,
'total_timeout' => 45,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
return $optionsResolver->resolve($options);
+1 -6
View File
@@ -14,7 +14,6 @@ final class Constants
public const TOKEN_SKI_PASS = 'SPA';
public const TOKEN_ADDITIONAL = 'SON';
public const TOKEN_BOARD = 'VPF';
public const TOKEN_VEG = 'VEG';
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public const TOKEN_RENTAL_INSURANCE = 'LVS';
public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG'];
@@ -38,7 +37,6 @@ final class Constants
self::TOKEN_SKI_PASS => 'Skipässe',
self::TOKEN_ADDITIONAL => 'Zusatzleistungen',
self::TOKEN_BOARD => 'Verpflegung',
self::TOKEN_VEG => 'Verpflegungswunsch',
self::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
self::TOKEN_PARKING => 'Parkplatz',
self::GROUP_TRANSPORTATION => 'Beförderung',
@@ -57,7 +55,7 @@ final class Constants
// Booking status codes from buchungstatusmoeglich attribute
public const BOOKING_STATUS_FREE = 'F';
public const BOOKING_STATUS_INQUIRY = 'A';
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung - not relevant for current implementation
// Payment methods
public const PAYMENT_METHOD_TRANSFER = 'transfer';
@@ -70,7 +68,4 @@ final class Constants
// Baby room constraints
public const BABY_MAX_AGE = 2;
public const BABY_ROOM_CODE = 'Baby';
// Pseudo price room - used for website display only, not bookable
public const PSEUDO_PRICE_ROOM_CODE = 'PDGS';
}
@@ -12,8 +12,8 @@ use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
/**
* Processes booking form data and converts it into BusProNet API payload format.
@@ -25,8 +25,8 @@ use App\Service\InsuranceManager;
class BookingDataProcessor
{
public function __construct(
private readonly InsuranceManager $insuranceService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly ServiceMappingCollector $mappingCollector,
private readonly ParticipantServiceProcessor $serviceProcessor,
private readonly BookingPayloadBuilder $payloadBuilder,
@@ -73,12 +73,14 @@ class BookingDataProcessor
if (0 === $index) {
// Copy address if first participant has no street (indicating empty/incomplete address)
// This allows applicant and first participant to be different people with different addresses
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
if (null !== $booking->applicant->address) {
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
}
// Copy body dimensions from applicant if not present in participant
@@ -122,19 +124,9 @@ class BookingDataProcessor
// Pickup location
$participantData->pickup = $booking->getPickupForParticipant($index);
// Drop-off location
$participantData->dropOff = $booking->getDropOffForParticipant($index);
$participantData->differentDropOff = null !== $participantData->dropOff;
// Parking service (for self-organized PKW transportation)
$parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING);
if (false === empty($parkingServices)) {
$participantData->parking = true;
$participantData->parkingService = reset($parkingServices);
} else {
$participantData->parking = false;
$participantData->parkingService = null;
}
// Parking (stored in form data, not in booking entity - needs special handling)
// For now, leave as false - may need to extract from transportation services
$participantData->parking = false;
// License plate (not stored in booking entity)
$participantData->licensePlate = null;
@@ -164,8 +156,6 @@ class BookingDataProcessor
$roomSelection->label = $room->label;
$roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection;
}
}
@@ -223,11 +213,6 @@ class BookingDataProcessor
$participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id];
}
// Enrich parking service
if (null !== $participant->parkingService && isset($travel->additionalServices[$participant->parkingService->id])) {
$participant->parkingService = $travel->additionalServices[$participant->parkingService->id];
}
// Enrich transportation services
if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) {
$participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id];
@@ -237,33 +222,9 @@ class BookingDataProcessor
$participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id];
}
// Enrich pickup with data from travel (includes both outbound and inbound prices)
// The pickups already have inbound prices merged in from TravelParser
if (null !== $participant->pickup && isset($travel->pickups[$participant->pickup->id])) {
$enrichedPickup = $travel->pickups[$participant->pickup->id];
// Copy all properties from enriched pickup to participant's pickup
$participant->pickup->price = $enrichedPickup->price;
$participant->pickup->priceOutbound = $enrichedPickup->priceOutbound;
$participant->pickup->priceInbound = $enrichedPickup->priceInbound;
$participant->pickup->time = $enrichedPickup->time;
$participant->pickup->city = $enrichedPickup->city;
$participant->pickup->street = $enrichedPickup->street;
$participant->pickup->postalCode = $enrichedPickup->postalCode;
$participant->pickup->code = $enrichedPickup->code;
}
// Enrich drop-off with data from travel
if (null !== $participant->dropOff && isset($travel->dropOffs[$participant->dropOff->id])) {
$enrichedDropOff = $travel->dropOffs[$participant->dropOff->id];
$participant->dropOff->price = $enrichedDropOff->price;
$participant->dropOff->priceOutbound = $enrichedDropOff->priceOutbound;
$participant->dropOff->priceInbound = $enrichedDropOff->priceInbound;
$participant->dropOff->time = $enrichedDropOff->time;
$participant->dropOff->city = $enrichedDropOff->city;
$participant->dropOff->street = $enrichedDropOff->street;
$participant->dropOff->postalCode = $enrichedDropOff->postalCode;
$participant->dropOff->code = $enrichedDropOff->code;
// Enrich pickup
if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) {
$participant->pickup = $travel->pickupsOutbound[$participant->pickup->id];
}
// Enrich insurance
@@ -282,7 +243,7 @@ class BookingDataProcessor
*
* @param BookingDto|null $formData The booking edit form data containing updated participant and service selections
*
* @return array<string, mixed> The structured payload array ready for BusProNet API submission
* @return array The structured payload array ready for BusProNet API submission
*/
public function createUpdateRequestPayload(?BookingDto $formData): array
{
@@ -305,18 +266,13 @@ class BookingDataProcessor
}
$this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants,
$bookingData,
$formData->isInternalAgencyBooking()
);
$this->personalDataSynchronizer->updateParticipantPersonalData($formData->participants, $bookingData);
$payload = $this->payloadBuilder->buildBasePayload($bookingData);
$this->payloadBuilder->addBankAccountToPayload($payload, $bookingData);
$this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants);
$this->payloadBuilder->buildServicesPayload($payload, $bookingData);
$this->payloadBuilder->buildPickupPayload($payload, $bookingData);
$this->payloadBuilder->buildDropOffPayload($payload, $bookingData);
// Add purchase vouchers if any exist
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
@@ -343,7 +299,7 @@ class BookingDataProcessor
* @param BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array<string, mixed> The structured payload array for BusProNet API submission
* @return array The structured payload array for BusProNet API submission
*/
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
{
@@ -386,7 +342,7 @@ class BookingDataProcessor
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
}
// Use InsuranceManager for proper type-based assignment with price tier matching
// Use InsuranceService for proper type-based assignment with price tier matching
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
$selectableInsurances,
$applicant->insurance,
@@ -7,7 +7,6 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Builds API payload structures for BusProNet booking requests.
@@ -17,8 +16,6 @@ use App\Form\Model\ParticipantDto;
*/
class BookingPayloadBuilder
{
private const DEFAULT_PARTICIPANT_STATUS = 'F';
public function __construct(
private readonly ServiceMappingCollector $mappingCollector,
) {
@@ -31,7 +28,7 @@ class BookingPayloadBuilder
*
* @param Booking $bookingData The booking data object
*
* @return array<string, mixed> The base payload structure
* @return array The base payload structure
*/
public function buildBasePayload(Booking $bookingData): array
{
@@ -67,8 +64,8 @@ class BookingPayloadBuilder
*
* Bank account information is required for direct debit payments.
*
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function addBankAccountToPayload(array &$payload, Booking $bookingData): void
{
@@ -87,9 +84,9 @@ class BookingPayloadBuilder
*
* Includes status, personal data, and wishes (room remarks, license plate) for each participant.
*
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<int, ParticipantDto> $participantDtos The participant DTOs from the form (for wishes data)
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array $participantDtos The participant DTOs from the form (for wishes data)
*/
public function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void
{
@@ -142,31 +139,28 @@ class BookingPayloadBuilder
* Includes additional services, transportation services, and accommodation details
* with participant mappings and quantities.
*
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildServicesPayload(array &$payload, Booking $bookingData): void
{
foreach ($bookingData->additionalServices as $service) {
$uniqueMapping = array_unique($service->mapping);
$payload['zusatzleistungen']['zusatzleistung'][] = [
'@idleistung' => $service->id,
'@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
'@anzahl' => count($service->mapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
];
}
foreach ($bookingData->transportationServices as $service) {
$uniqueMapping = array_unique($service->mapping);
$payload['beförderungen']['beförderung'][] = [
'@idleistung' => $service->id,
'@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
'@anzahl' => count($service->mapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
];
}
foreach ($bookingData->rooms as $room) {
$uniqueMapping = array_unique($room->mapping);
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
'@idzimmer' => $room->id,
'@kategorie' => $room->category,
@@ -174,7 +168,7 @@ class BookingPayloadBuilder
'@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null,
'@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null,
'@anzahl' => $room->totalCount,
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)),
];
}
@@ -182,12 +176,11 @@ class BookingPayloadBuilder
if (false === empty($bookingData->insurances)) {
$payload['versicherungen']['versicherung'] = [];
foreach ($bookingData->insurances as $insurance) {
$uniqueMapping = array_unique($insurance->mapping);
if (count($uniqueMapping) > 0) {
if (count($insurance->mapping) > 0) {
$payload['versicherungen']['versicherung'][] = [
'@idversicherung' => $insurance->id,
'@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
'@anzahl' => count($insurance->mapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)),
];
}
}
@@ -199,44 +192,18 @@ class BookingPayloadBuilder
*
* Only included in payload if there are actual pickup assignments for bus transportation.
*
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildPickupPayload(array &$payload, Booking $bookingData): void
{
$payload['zustiege'] = [];
if (0 < count($bookingData->pickups)) {
if (0 < count($bookingData->pickupsOutbound)) {
$payload['zustiege']['zustieg'] = [];
foreach ($bookingData->pickups as $pickup) {
$uniqueMapping = array_unique($pickup->mapping);
foreach ($bookingData->pickupsOutbound as $pickup) {
$payload['zustiege']['zustieg'][] = [
'@idzustieg' => $pickup->id,
'@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
];
}
}
}
/**
* Builds the drop-off locations section of the payload.
*
* Only included in payload if there are actual drop-off assignments for bus transportation.
*
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildDropOffPayload(array &$payload, Booking $bookingData): void
{
if (0 < count($bookingData->dropOffs)) {
$payload['ausstiege']['ausstieg'] = [];
foreach ($bookingData->dropOffs as $dropOff) {
$uniqueMapping = array_unique($dropOff->mapping);
$payload['ausstiege']['ausstieg'][] = [
'@idzustieg' => $dropOff->id,
'@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
'@anzahl' => count($pickup->mapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)),
];
}
}
@@ -250,7 +217,7 @@ class BookingPayloadBuilder
* @param BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array<string, mixed> The structured payload array for BusProNet API submission
* @return array The structured payload array for BusProNet API submission
*/
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
{
@@ -303,7 +270,6 @@ class BookingPayloadBuilder
foreach ($bookingDto->participants as $index => $participant) {
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
'name' => $participant->lastName,
'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '',
@@ -393,13 +359,6 @@ class BookingPayloadBuilder
$this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto);
$this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap);
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap);
if (false === array_key_exists('zustiege', $payload)) {
$payload['zustiege'] = [];
}
$dropOffMap = $this->mappingCollector->collectDropOffMappings($bookingDto);
$this->addServicesFromMap($payload, 'ausstiege', 'ausstieg', '@idzustieg', $dropOffMap);
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
@@ -440,11 +399,11 @@ class BookingPayloadBuilder
* Generic helper method that converts service ID => participant IDs mappings
* into XML payload structure.
*
* @param array<string, mixed> $payload The payload array to modify
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
* @param array<int|string, int[]> $serviceMap Map of service ID to participant IDs
* @param array $payload The payload array to modify
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
* @param array $serviceMap Map of service ID to participant IDs
*/
public function addServicesFromMap(
array &$payload,
@@ -456,11 +415,10 @@ class BookingPayloadBuilder
if (false === empty($serviceMap)) {
$payload[$sectionKey][$itemKey] = [];
foreach ($serviceMap as $serviceId => $participantIds) {
$uniqueParticipantIds = array_unique($participantIds);
$payload[$sectionKey][$itemKey][] = [
$idAttributeName => $serviceId,
'@anzahl' => count($uniqueParticipantIds),
'@zuordnung' => implode(',', $uniqueParticipantIds),
'@anzahl' => count($participantIds),
'@zuordnung' => implode(',', $participantIds),
];
}
}
@@ -476,9 +434,9 @@ class BookingPayloadBuilder
* - abreise (departure date)
* - anzahl (number of rooms of this type booked)
*
* @param array<string, mixed> $payload The payload array to modify
* @param array<int|string, int[]> $roomMap Map of room ID to participant IDs
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
* @param array $payload The payload array to modify
* @param array $roomMap Map of room ID to participant IDs
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
*/
public function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $bookingDto): void
{
@@ -504,7 +462,6 @@ class BookingPayloadBuilder
}
$quantity = $roomQuantities[$roomId] ?? 1;
$uniqueParticipantIds = array_unique($participantIds);
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
'@idzimmer' => $room->id,
@@ -513,7 +470,7 @@ class BookingPayloadBuilder
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
'@anzahl' => $quantity,
'@zuordnung' => implode(',', $uniqueParticipantIds),
'@zuordnung' => implode(',', $participantIds),
];
}
}
@@ -521,8 +478,8 @@ class BookingPayloadBuilder
/**
* Adds purchase vouchers to the payload.
*
* @param array<string, mixed> $payload The payload array to modify
* @param BookingDto $bookingDto The booking data
* @param array $payload The payload array to modify
* @param BookingDto $bookingDto The booking data
*/
public function addPurchaseVouchersToPayload(array &$payload, BookingDto $bookingDto): void
{
@@ -7,7 +7,6 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\Form\Model\ParticipantDto;
use Psr\Log\LoggerInterface;
/**
* Processes participant service selections for booking updates.
@@ -18,11 +17,6 @@ use Psr\Log\LoggerInterface;
*/
class ParticipantServiceProcessor
{
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/**
* Resets all existing participant-to-service mappings to start with a clean slate.
*
@@ -36,8 +30,8 @@ class ParticipantServiceProcessor
$servicesToReset = [
...$bookingData->additionalServices,
...$bookingData->transportationServices,
...$bookingData->pickups,
...$bookingData->dropOffs,
...$bookingData->pickupsOutbound,
...$bookingData->pickupsInbound,
...$bookingData->rooms,
...$bookingData->insurances,
];
@@ -66,7 +60,6 @@ class ParticipantServiceProcessor
$this->processAdditionalServices($participant, $bookingData, $travelData);
$this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData);
}
@@ -93,15 +86,15 @@ class ParticipantServiceProcessor
}
}
foreach ($bookingData->pickups as $pickup) {
foreach ($bookingData->pickupsOutbound as $pickup) {
if (0 === count($pickup->mapping)) {
unset($bookingData->pickups[$pickup->id]);
unset($bookingData->pickupsOutbound[$pickup->id]);
}
}
foreach ($bookingData->dropOffs as $dropOff) {
if (0 === count($dropOff->mapping)) {
unset($bookingData->dropOffs[$dropOff->id]);
foreach ($bookingData->pickupsInbound as $pickup) {
if (0 === count($pickup->mapping)) {
unset($bookingData->pickupsInbound[$pickup->id]);
}
}
@@ -116,7 +109,7 @@ class ParticipantServiceProcessor
* Collects additional services from a participant for mapping.
*
* Extracts all additional services (courses, board, rentals, ski pass, rental insurance,
* parking, additional services) from a participant into a flat array of Service objects.
* additional services) from a participant into a flat array of Service objects.
*
* @param ParticipantDto $participant The participant data
*
@@ -141,11 +134,6 @@ class ParticipantServiceProcessor
$services[] = $participant->rentalInsurance;
}
// Add parking service if selected (for self-organized PKW transportation)
if (true === $participant->parking && null !== $participant->parkingService) {
$services[] = $participant->parkingService;
}
return $services;
}
@@ -166,18 +154,11 @@ class ParticipantServiceProcessor
foreach ($servicesToMap as $service) {
if (false === isset($bookingData->additionalServices[$service->id])) {
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
if (null === $serviceToAdd) {
$this->logger->warning('Additional service not found in travel data, skipping mapping', [
'bookingId' => $bookingData->id,
'serviceId' => $service->id,
'participantIndex' => $participant->index,
]);
continue;
if (null !== $serviceToAdd) {
$bookingData->additionalServices[$service->id] = $serviceToAdd;
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$bookingData->additionalServices[$service->id] = $serviceToAdd;
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
}
}
@@ -195,24 +176,13 @@ class ParticipantServiceProcessor
private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) {
if (null === $service) {
continue;
}
if (false === isset($bookingData->transportationServices[$service->id])) {
$serviceToAdd = $travelData->transportationServices[$service->id] ?? null;
if (null === $serviceToAdd) {
$this->logger->warning('Transportation service not found in travel data, skipping mapping', [
'bookingId' => $bookingData->id,
'serviceId' => $service->id,
'participantIndex' => $participant->index,
]);
continue;
if (null !== $serviceToAdd) {
$bookingData->transportationServices[$service->id] = $serviceToAdd;
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$bookingData->transportationServices[$service->id] = $serviceToAdd;
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
}
}
@@ -233,34 +203,10 @@ class ParticipantServiceProcessor
$hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType;
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) {
if (false === isset($bookingData->pickups[$selectedPickup->id])) {
$bookingData->pickups[$selectedPickup->id] = $selectedPickup;
if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) {
$bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup;
}
$bookingData->pickups[$selectedPickup->id]->mapping[] = $participant->index;
}
}
/**
* Processes drop-off locations for participants using bus transportation.
*
* Only processes drop-off locations for bus transportation services and maps the participant
* to their selected drop-off location.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
*/
private function processDropOffLocations(ParticipantDto $participant, Booking $bookingData): void
{
$hasOutboundBus = null !== $participant->transportationOutbound
&& 'BUS' === $participant->transportationOutbound->subType;
$hasInboundBus = null !== $participant->transportationInbound
&& 'BUS' === $participant->transportationInbound->subType;
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedDropOff = $participant->dropOff) {
if (false === isset($bookingData->dropOffs[$selectedDropOff->id])) {
$bookingData->dropOffs[$selectedDropOff->id] = $selectedDropOff;
}
$bookingData->dropOffs[$selectedDropOff->id]->mapping[] = $participant->index;
$bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index;
}
}
@@ -6,7 +6,7 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Communication;
use App\Form\Model\ParticipantDto;
/**
@@ -20,53 +20,28 @@ class PersonalDataSynchronizer
/**
* Updates participant personal data from form input.
*
* Processes all participants regardless of status, including canceled participants (status 'S').
* Processes all active participants (status 'F' or 'A'). Skips canceled participants (status 'S').
* Updates all personal data fields and communication information.
*
* Also fills missing mandatory fields on the applicant object for company bookings
* created by travel agencies. The applicant is separate from participant 0 and may
* contain only company data without personal fields required by the BPN API.
*
* IMPORTANT: The applicant's address must never be modified. This method updates
* participant addresses independently to ensure applicant data remains intact.
*
* @param array<ParticipantDto> $participants The participants array from the form
* @param Booking $bookingData The booking data object to update
* @param bool $isInternalAgencyBooking Whether to fill default names for participants with empty names
* @param array<ParticipantDto> $participants The participants array from the form
* @param Booking $bookingData The booking data object to update
*/
public function updateParticipantPersonalData(
array $participants,
Booking $bookingData,
bool $isInternalAgencyBooking = false,
): void {
// Fill missing mandatory fields on applicant (for company bookings by travel agencies)
// This is separate from participants - applicant may be a company while participants are real people
$this->fillMissingApplicantFields($bookingData);
public function updateParticipantPersonalData(array $participants, Booking $bookingData): void
{
foreach ($participants as $participant) {
$bookingData->participants[$participant->index]->firstName = $participant->firstName;
$bookingData->participants[$participant->index]->name = $participant->lastName;
// For internal agency bookings, participants with empty names get default values.
// Agencies pre-book slots with placeholder data (e.g. applicant surname, empty first name),
// and the BPN API rejects updates with empty name fields.
if ($isInternalAgencyBooking) {
$defaultName = sprintf('Teilnehmer:in %d', $participant->index + 1);
$personalData = $bookingData->participants[$participant->index];
if (null === $personalData->firstName || '' === $personalData->firstName) {
$personalData->firstName = $defaultName;
}
if (null === $personalData->name || '' === $personalData->name) {
$personalData->name = $defaultName;
}
// Skip canceled participants (status 'S')
if ('S' === $participant->status) {
continue;
}
$bookingData->participants[$participant->index]->firstName = $participant->firstName;
$bookingData->participants[$participant->index]->name = $participant->lastName;
$bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth;
$bookingData->participants[$participant->index]->gender = $participant->gender;
$bookingData->participants[$participant->index]->nationality = $participant->nationality;
$this->fillMissingParticipantFields($bookingData->participants[$participant->index]);
$bookingData->participants[$participant->index]->height = $participant->height;
$bookingData->participants[$participant->index]->weight = $participant->weight;
$bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize;
@@ -85,61 +60,12 @@ class PersonalDataSynchronizer
}
if ($participant->email || $participant->mobile) {
if (null === $bookingData->participants[$participant->index]->communication) {
$bookingData->participants[$participant->index]->communication = new Communication();
}
$bookingData->participants[$participant->index]->communication->email = $participant->email;
$bookingData->participants[$participant->index]->communication->mobile = $participant->mobile;
}
}
}
/**
* Fills missing mandatory fields on the applicant for company bookings.
*
* Travel agencies often create bookings with company data as the applicant,
* which lacks personal data fields. These fields are mandatory for the BPN
* API, so we fill them with sensible defaults. The applicant is separate from
* participants - participant 0 is a real person and must not be modified here.
*/
private function fillMissingApplicantFields(Booking $bookingData): void
{
if (null === $bookingData->applicant) {
return;
}
$applicant = $bookingData->applicant;
if (null === $applicant->firstName || '' === $applicant->firstName) {
$applicant->firstName = 'Anmelder';
}
if (null === $applicant->gender || '' === $applicant->gender) {
$applicant->gender = 'D';
}
if (null === $applicant->nationality || '' === $applicant->nationality) {
$applicant->nationality = 'D';
}
if (null === $applicant->dateOfBirth) {
$applicant->dateOfBirth = new \DateTimeImmutable('-20 years');
}
if (null === $applicant->communication->mobile || '' === $applicant->communication->mobile) {
$applicant->communication->mobile = '12345';
}
}
/**
* Fills missing mandatory fields on a participant.
*
* Ensures mandatory fields have sensible defaults before submission to the
* BPN API. Currently handles nationality which defaults to 'D' (German).
*
* @param PersonalData $participant The participant to fill defaults for
*/
private function fillMissingParticipantFields(PersonalData $participant): void
{
if (null === $participant->nationality || '' === $participant->nationality) {
$participant->nationality = 'D';
}
}
}
@@ -16,7 +16,7 @@ class PickupPlanningTransformer
/**
* @param array<int, array<string, mixed>> $payload
*
* @return array<string, array<int, array{city: string, location: string, datetime: string, busNumber: string}>>
* @return array<string, array<int, array<string, string>>>
*/
public function transform(array $payload): array
{
@@ -27,25 +27,26 @@ class PickupPlanningTransformer
continue;
}
$code = $item['Reisecode'] ?? '';
$travelCodes = $item['Reisecodes'] ?? [];
if ('' === $code) {
if (0 === count($travelCodes)) {
continue;
}
$dateTime = \DateTimeImmutable::createFromFormat('d.m.Y H:i', $item['Zeit'] ?? '');
$pickup = [
'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
'busNumber' => $item['BusNummer'] ?? '',
];
foreach ($travelCodes as $code) {
$pickup = [
'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
];
$key = sprintf('%s|%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime'], $pickup['busNumber']);
$key = sprintf('%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime']);
if (false === isset($result[$code][$key])) {
$result[$code][$key] = $pickup;
if (false === isset($result[$code][$key])) {
$result[$code][$key] = $pickup;
}
}
}
@@ -20,7 +20,7 @@ class ServiceMappingCollector
*
* Groups participants by their assigned room ID.
*
* @return array<int, list<int>> Map of room ID to participant IDs
* @return array<string, array<int>> Map of room ID to participant IDs
*/
public function collectRoomMappings(BookingDto $bookingDto): array
{
@@ -57,11 +57,6 @@ class ServiceMappingCollector
$serviceMap[$board->id][] = $participantId;
}
// Veg (vegetarian/vegan) preference
if (null !== $participant->veg) {
$serviceMap[$participant->veg->id][] = $participantId;
}
// Ski pass
if (null !== $participant->skiPass) {
$serviceMap[$participant->skiPass->id][] = $participantId;
@@ -143,28 +138,6 @@ class ServiceMappingCollector
return $pickupMap;
}
/**
* Collects drop-off location mappings.
*
* Collects the drop-off selection for the inbound/return direction.
*
* @return array<string, array<int>> Map of drop-off ID to participant IDs
*/
public function collectDropOffMappings(BookingDto $bookingDto): array
{
$dropOffMap = [];
foreach ($bookingDto->participants as $index => $participant) {
$participantId = $index + 1;
if (null !== $participant->dropOff) {
$dropOffMap[$participant->dropOff->id][] = $participantId;
}
}
return $dropOffMap;
}
/**
* Collects insurance mappings.
*
@@ -14,7 +14,6 @@ class CountryDataProvider
{
}
/** @return array<string, Country> */
public function getAll(): array
{
try {
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Exception;
/**
* Thrown when the BPN server closes the connection immediately without sending data.
*
* This typically indicates the server is busy processing other requests.
* Unlike a true timeout, this is a transient condition that may succeed on retry.
*/
class ImmediateConnectionCloseException extends ApiClientException
{
}
-1
View File
@@ -10,7 +10,6 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<string> */
class CountryType extends AbstractType
{
public function __construct(private readonly CountryDataProvider $countries)
-6
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents a physical address with street, postal code, city, and country information.
*
@@ -15,18 +13,14 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class Address
{
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $street = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $postCode = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $city = null;
public ?string $district = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $country = null;
/**
@@ -12,7 +12,6 @@ namespace App\BusProNet\Model;
*/
class AgeConstraintResult
{
/** @param array<string, mixed> $metadata */
public function __construct(
public readonly string $type,
public readonly ?int $ageFrom = null,
+1 -2
View File
@@ -12,7 +12,6 @@ namespace App\BusProNet\Model;
*/
class BaseData
{
/** @param array<array-key, mixed> $items */
public function __construct(private readonly array $items)
{
}
@@ -20,7 +19,7 @@ class BaseData
/**
* Retrieves all items in the base data collection.
*
* @return array<array-key, mixed> The complete items array
* @return array The complete items array
*/
public function getItems(): array
{
+5 -62
View File
@@ -36,50 +36,14 @@ class Booking
public ?string $paymentType = null;
public ?string $paymentLabel = null;
public ?BankAccount $bankAccount = null;
/**
* @var array<int, string>
*/
public array $participantsStatus = [];
/**
* @var array<int, PersonalData>
*/
public array $participants = [];
/**
* @var array<int, Service>
*/
public array $transportationServices = [];
/**
* @var array<int, Service>
*/
public array $additionalServices = [];
/**
* @var array<int, Room>
*/
public array $rooms = [];
/**
* @var array<int, Pickup>
*/
public array $pickups = [];
/**
* @var array<int, Pickup>
*/
public array $dropOffs = [];
/**
* @var array<int, Surcharge>
*/
public array $pickupsOutbound = [];
public array $pickupsInbound = [];
public array $surcharges = [];
/**
* @var array<string|int, Insurance>
*/
public array $insurances = [];
public ?int $invoiceNumber = null;
public ?float $totalPrice = null;
@@ -172,7 +136,7 @@ class Booking
* Retrieves pickup service for a specific participant.
*
* Finds the pickup service assigned to the specified participant
* from the pickup services.
* from the outbound pickup services.
*
* @param int $participantIndex The participant index to search for
*
@@ -180,7 +144,7 @@ class Booking
*/
public function getPickupForParticipant(int $participantIndex): ?Pickup
{
foreach ($this->pickups as $pickup) {
foreach ($this->pickupsOutbound as $pickup) {
if (in_array($participantIndex, $pickup->mapping)) {
return $pickup;
}
@@ -189,27 +153,6 @@ class Booking
return null;
}
/**
* Retrieves drop-off service for a specific participant.
*
* Finds the drop-off service assigned to the specified participant
* from the drop-off services (inbound/return direction).
*
* @param int $participantIndex The participant index to search for
*
* @return Pickup|null The matching drop-off service or null if not found
*/
public function getDropOffForParticipant(int $participantIndex): ?Pickup
{
foreach ($this->dropOffs as $dropOff) {
if (in_array($participantIndex, $dropOff->mapping)) {
return $dropOff;
}
}
return null;
}
/**
* Gets the insurance assigned to a specific participant.
*
@@ -220,7 +163,7 @@ class Booking
public function getInsuranceForParticipant(int $participantIndex): ?Insurance
{
foreach ($this->insurances as $insurance) {
if (in_array($participantIndex, $insurance->mapping, true)) {
if (in_array($participantIndex, $insurance->mapping ?? [], true)) {
return $insurance;
}
}
+6 -50
View File
@@ -16,15 +16,15 @@ namespace App\BusProNet\Model;
class BookingResponse
{
/**
* @param string $status Booking status (möglich|erfolgt)
* @param int|null $bookingNumber Booking number (BPN XML: vorgang)
* @param array<int, PriceItem> $priceItems Individual price items from response
* @param float|null $totalPrice Total price (gesamtpreis)
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
* @param string $status Booking status (möglich|erfolgt)
* @param string|null $transactionNumber Transaction number (vorgang)
* @param array<int, PriceItem> $priceItems Individual price items from response
* @param float|null $totalPrice Total price (gesamtpreis)
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
*/
public function __construct(
public readonly string $status,
public readonly ?int $bookingNumber = null,
public readonly ?string $transactionNumber = null,
public readonly array $priceItems = [],
public readonly ?float $totalPrice = null,
public readonly ?PaymentTerms $paymentTerms = null,
@@ -102,48 +102,4 @@ class BookingResponse
2
);
}
/**
* Gets API-applied discounts that cannot be predicted by the local price calculator.
*
* The BusProNet API may apply automatic discounts based on business rules that
* are not known to the local application, such as:
* - ERM/GRU: Gruppenrabatt (group discount for large bookings)
* - Other ERM subtypes: Various automatic discounts
*
* These discounts appear as negative price items with type "ERM" and must be
* accounted for when comparing the API total price against the locally calculated price.
*
* Important: Only ERM type discounts are considered API-applied. Other negative price
* items (like BEF transportation discounts) are already handled by the local price
* calculator and should not be subtracted again.
*
* Note: Voucher discounts (AKTION, KULANZGUTSCHEIN) are handled separately via
* getVoucherDiscountFromPrices() as they are user-initiated, not automatic.
*
* @return array<PriceItem> Price items representing API-applied automatic discounts
*/
public function getApiAppliedDiscounts(): array
{
return array_filter(
$this->priceItems,
fn (PriceItem $item) => $item->totalPrice < 0 && 'ERM' === $item->type
);
}
/**
* Gets total discount from API-applied automatic discounts.
*
* Returns the absolute sum of negative price items that represent automatic
* discounts applied by the API (excluding voucher discounts which are handled separately).
*/
public function getApiAppliedDiscountTotal(): float
{
$discount = 0.0;
foreach ($this->getApiAppliedDiscounts() as $item) {
$discount += abs($item->totalPrice);
}
return round($discount, 2);
}
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
class RegistrationResponse
class ContactFormResponse
{
public function __construct(
public readonly int $addressId,
-15
View File
@@ -12,23 +12,8 @@ namespace App\BusProNet\Model;
*/
class CrmAttributes
{
/**
* @var array<int, CrmSelectionGroup>|null
*/
public ?array $selectionGroups = null;
/**
* @var array<int, CrmAction>|null
*/
public ?array $crmActions = null;
/**
* @var array<int, string>
*/
public array $hotelCodes = [];
/**
* @var array<int, string>
*/
public array $roles = [];
}
+4 -4
View File
@@ -15,15 +15,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class CrmSelection
{
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?int $id = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?string $label = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $mutable = false;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $selected = false;
}
+4 -7
View File
@@ -19,7 +19,7 @@ class CrmSelectionGroup
* Maps BusPro IDs of CRM selection groups representing
* included services.
*
* @var array<int, string>
* @var array<string, string>
*/
public static array $includedServicesMapping = [
7 => 'Skipass',
@@ -32,16 +32,13 @@ class CrmSelectionGroup
90 => 'Transport',
];
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?int $id = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?string $label = null;
/**
* @var array<int, CrmSelection>|null
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?array $selections = null;
/**
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Represents error codes and messages for the BusProNet API.
*
* This class provides a static mapping of error codes to their corresponding
* German error messages for consistent error handling across the application.
* Error codes range from 100-999 and cover various API scenarios.
*/
class Error
{
/**
* @var array<int, string>
*/
private static array $errors = [
100 => 'Anfrageknoten fehlt',
101 => 'Satzknoten fehlt in Anfrageknoten',
102 => 'Ungültiger Satztyp [#1#]',
103 => 'User fehlt',
104 => 'User fehlerhaft',
105 => 'Key fehlt',
106 => 'Key fehlerhaft',
200 => 'Keine Einträge vorhanden',
201 => 'Keine Partner zur Auswahl gefunden',
202 => 'Keinen Partner mit der ID [#1#] gefunden',
203 => 'Ungültiger Termin',
300 => 'IDReise fehlt',
301 => 'Reise nicht gefunden',
302 => 'IDLeistung (Hin & Rück) fehlt',
303 => 'IDLeistung passt nicht zu IDReise',
304 => 'Es konnte nicht für alle Teilnehmer ein Sitzplatz ermittelt werden',
305 => 'Anzahl Personen fehlt',
306 => 'Leistungen mit ID #1# nicht gefunden',
307 => 'Leistungen mit ID #1# passen nicht zur Reise mit ID #2#',
308 => 'Keine Zahlungsarten gefunden',
400 => 'IDPartner fehlt',
401 => 'Partner nicht gefunden',
402 => 'Bis-Termin fehlt',
403 => 'Keine Unterbringungsleistungen gefunden',
500 => 'Art fehlt',
501 => 'Reise- und Buchungszeitraum fehlen',
502 => 'Keine Kunden gefunden',
600 => 'Keine Gutscheine gefunden',
601 => 'Gutschein-IDs fehlen',
602 => 'Gutscheine mit ID #1# nicht gefunden',
610 => 'Buchungsart fehlt',
611 => 'Buchungsart falsch',
612 => 'Gutscheinart fehlt',
613 => 'Gutscheinart falsch',
614 => 'Gutscheinstamm-ID fehlt',
615 => 'Gutschein (Stamm) mit ID #1# nicht gefunden',
616 => 'Kapazität beim Gutschein #1# nicht ausreichend',
617 => 'Rechnungsempfänger fehlt',
618 => 'Zahlungsart fehlt',
619 => 'Agentur-ID fehlt',
620 => 'Agentur mit ID #1# nicht gefunden',
650 => '#1#',
700 => 'Keine Agenturen gefunden',
701 => 'Agentur mit ID #1# nicht gefunden',
800 => 'Produkt-ID fehlt',
801 => 'Produkt mit ID #1# nicht gefunden',
805 => 'Es wurden keine Produkte gefunden',
810 => 'Stammdaten (#1#) nicht gefunden',
900 => 'Buchungsart fehlt',
901 => 'Buchungsart falsch',
902 => 'Status fehlt',
903 => 'Status falsch',
904 => 'Agentur-ID fehlt',
905 => 'Agentur mit ID #1# nicht gefunden',
906 => 'Reise-ID fehlt',
907 => 'Reise mit ID #1# nicht gefunden',
908 => 'Reise mit ID #1# ist storniert',
909 => 'Beförderungen fehlen',
910 => 'Leistung mit ID #1# gehört nicht zur Reise',
911 => 'Leistung mit ID #1# gehört nicht zum Produkt',
912 => 'Beförderungsleistung für die #1# fehlt',
913 => 'Unterbringungen fehlen',
914 => 'Partner-ID fehlt',
915 => 'Partner mit ID #1# nicht gefunden',
916 => 'Ferienziel-Unterbringungen fehlen',
917 => 'Ferienziel: Zimmer-IDZ fehlt',
918 => 'Ferienziel: Kategorie fehlt',
919 => 'Ferienziel: Verpflegungs-ID fehlt',
920 => 'Ferienziel: Anreise fehlt',
921 => 'Ferienziel: Anreise passt nicht zur Beförderungsleistung (Hinfahrt)',
922 => 'Ferienziel: Abreise fehlt',
923 => 'Ferienziel: Abreise passt nicht zur Beförderungsleistung (Rückfahrt)',
924 => 'Ferienziel: Keine Preise zu den Daten gefunden',
925 => 'Ferienziel: Preis zu den Daten nicht gefunden',
930 => 'Zustiege fehlen',
931 => 'Zustieg mit ID #1# nicht gefunden',
932 => 'Zustieg mit ID #1# bei Leistung #2# nicht freigegeben',
933 => 'Sitzplan mit ID #1# bei Leistung #2# nicht freigegeben',
935 => 'Versicherung mit ID #1# nicht gefunden',
940 => 'Zahlungsart fehlt',
941 => 'Zahlungsart-ID fehlt',
942 => 'Zahlungsart mit ID #1# nicht gültig',
950 => 'Anmelder fehlt',
951 => 'Teilnehmerliste fehlt',
952 => 'Teilnehmer-ID fehlt',
960 => 'Preisfehler: Struct #1# / Obj #2#',
961 => 'Buchung konnte nicht gespeichert werden',
980 => 'Reise ist fürs Internet gesperrt',
981 => 'Reise ist nicht mehr buchbar (#1#)',
982 => 'Optionsbuchungen nicht zugelassen',
983 => 'Anfragebuchung nicht zugelassen',
984 => 'Leistung mit ID #1# nicht fürs Internet buchbar',
985 => 'Zustieg mit ID #1# nicht fürs Internet buchbar',
999 => 'Systemfehler: #1#',
];
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Extended availability details for one service from VERFUEGBARKEIT2.
*
* This model augments classic availability with optional timing/date/description
* fields used to enrich persisted travel snapshots.
*/
class ExtendedAvailability
{
public ?int $serviceId = null;
public ?string $status = null;
public ?int $available = null;
public ?float $price = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?string $timeFrom = null;
public ?string $description = null;
public ?int $ageFrom = null;
public ?int $ageTo = null;
public ?bool $mandatory = null;
}
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Response model for extended availability endpoint payloads.
*/
class ExtendedServiceAvailabilityResponse
{
/**
* @param array<int, ExtendedAvailability> $services
* @param array<string> $allowedBookingStatus
*/
public function __construct(
private readonly array $services,
public readonly array $allowedBookingStatus = [],
public readonly ?string $travelStatus = null,
) {
}
/**
* @return array<int, ExtendedAvailability>
*/
public function getServices(): array
{
return $this->services;
}
}
+2 -2
View File
@@ -14,9 +14,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class Guide
{
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?string $name = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?string $phone = null;
}
+7 -7
View File
@@ -15,24 +15,24 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class Hotel
{
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $name = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $city = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $street = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $phone = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $country = null;
}
+8 -18
View File
@@ -31,18 +31,11 @@ class PersonalData
public ?string $salutation = null;
public ?string $title = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\Choice(choices: ['M', 'W', 'D'], message: 'Bitte gib einen gültigen Wert an', groups: ['personal_data'])]
public ?string $gender = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $nationality = null;
public ?string $height = null;
public ?string $shoeSize = null;
public ?string $weight = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?\DateTimeImmutable $dateOfBirth = null;
public ?string $remarks = null;
@@ -56,9 +49,7 @@ class PersonalData
#[Assert\Valid(groups: ['personal_data'])]
public Communication $communication;
/** @var list<string> */
public array $roles = [];
/** @var list<string> */
public array $hotelCodes = [];
public function __construct()
@@ -80,11 +71,17 @@ class PersonalData
*
* @return array<string, mixed> The payload array for API transmission
*/
public function toPayload(bool $includeDateOfBirth = true): array
public function toPayload(): array
{
$payload = [
// Ensure date of birth is populated
if (null === $dob = $this->dateOfBirth) {
$dob = new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
}
return [
'idadresse' => $this->addressId,
'idadresseperson' => $this->personId,
'geburtsdatum' => $dob->format('d.m.Y'),
'anrede' => $this->salutation,
'geschlecht' => $this->gender,
'nationalitaet' => $this->nationality,
@@ -98,13 +95,6 @@ class PersonalData
'sonstiges3' => $this->shoeSize,
'bemerkung' => $this->remarks,
];
if (true === $includeDateOfBirth) {
$dob = $this->dateOfBirth ?? new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
$payload['geburtsdatum'] = $dob->format('d.m.Y');
}
return $payload;
}
public static function fromContactFormSubmission(ContactFormSubmission $dto): self
+7 -85
View File
@@ -17,35 +17,28 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/
class Pickup
{
#[Groups(['api:list', 'snapshot'])]
#[Groups(['api:list'])]
public ?int $id = null;
#[Groups(['api:list', 'snapshot'])]
#[Groups(['api:list'])]
public ?string $code = null;
#[Groups(['api:list', 'snapshot'])]
#[Groups(['api:list'])]
public ?string $city = null;
#[Groups(['api:list', 'snapshot'])]
#[Groups(['api:list'])]
public ?string $postalCode = null;
#[Groups(['api:list', 'snapshot'])]
#[Groups(['api:list'])]
public ?string $street = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'])]
public ?\DateTimeImmutable $time = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?float $price = null;
#[Groups(['api:single', 'snapshot'])]
public ?float $priceOutbound = null;
#[Groups(['api:single', 'snapshot'])]
public ?float $priceInbound = null;
/** @var list<int> */
#[Groups(['api:booking'])]
public array $mapping = [];
@@ -97,75 +90,4 @@ class Pickup
return sprintf('%s (€%s)', $label, number_format($this->price, 2, ',', '.'));
}
/**
* Gets the formatted label with context-aware pricing based on transportation directions.
*
* Calculates the effective price based on which directions use bus transportation:
* - Both directions BUS: shows combined price (outbound + inbound)
* - Only outbound BUS: shows outbound price only
* - Only inbound BUS: shows inbound price only
* - Falls back to legacy price property when split pricing data is unavailable
*
* @param bool $hasOutboundBus Whether outbound transportation is bus
* @param bool $hasInboundBus Whether inbound transportation is bus
*
* @return string The formatted pickup label with contextual pricing
*/
public function getLabelWithContextualPrice(bool $hasOutboundBus, bool $hasInboundBus): string
{
$label = $this->getLabel();
$effectivePrice = $this->calculateEffectivePrice($hasOutboundBus, $hasInboundBus);
if (null === $effectivePrice) {
return $label;
}
if (0.0 === $effectivePrice) {
return sprintf('%s (inkl.)', $label);
}
if ($effectivePrice < 0) {
return sprintf('%s (-%s€ Rabatt)', $label, number_format(abs($effectivePrice), 2, ',', '.'));
}
return sprintf('%s (€%s)', $label, number_format($effectivePrice, 2, ',', '.'));
}
/**
* Calculates the effective pickup price based on transportation directions.
*
* Uses split pricing (priceOutbound/priceInbound) when available, falling back
* to the legacy price property for backward compatibility with older data.
*
* @param bool $hasOutboundBus Whether outbound transportation is bus
* @param bool $hasInboundBus Whether inbound transportation is bus
*
* @return float|null The calculated effective price or null if no applicable price
*/
public function calculateEffectivePrice(bool $hasOutboundBus, bool $hasInboundBus): ?float
{
// Neither direction is bus - no pickup price applies
if (false === $hasOutboundBus && false === $hasInboundBus) {
return null;
}
$effectivePrice = 0.0;
$hasSplitPricing = null !== $this->priceOutbound || null !== $this->priceInbound;
if ($hasOutboundBus) {
if ($hasSplitPricing) {
$effectivePrice += $this->priceOutbound ?? 0.0;
} else {
// Fallback to legacy price for backward compatibility
$effectivePrice += $this->price ?? 0.0;
}
}
if ($hasInboundBus && null !== $this->priceInbound) {
$effectivePrice += $this->priceInbound;
}
return $effectivePrice;
}
}
+11 -13
View File
@@ -20,19 +20,19 @@ class Room
public const SELECTION_TYPE_BY_PAX = 'by_pax';
public const SELECTION_TYPE_BY_ROOM = 'by_room';
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $category = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $boardId = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $label = null;
#[Groups(['booking'])]
@@ -46,35 +46,33 @@ class Room
#[Groups(['booking'])]
public ?int $totalCount = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $minPax = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $maxPax = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $nights = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $status = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $available = null;
#[Groups(['booking'])]
public ?string $board = null;
/** @var list<int> */
#[Groups(['booking'])]
public array $mapping = [];
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?float $price = null;
#[Groups(['booking'])]
public ?float $totalPrice = null;
/** @var array<int, float|null> */
#[Groups(['booking'])]
public array $individualPrice = [];
+24 -30
View File
@@ -17,90 +17,84 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/
class Service
{
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $id = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $category = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $status = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public bool $mandatory = false;
#[Groups(['api:single', 'api:list', 'snapshot'])]
public bool $autoBook = false;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $label = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $timeFrom = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $dayTime = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateTo = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $subType = null;
#[Groups(['booking'])]
public ?int $totalCount = null;
/** @var list<int> */
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $mapping = [];
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?float $price = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?float $totalPrice = null;
/** @var array<int, float|null> */
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $individualPrice = [];
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $available = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $source = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $direction = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $ageFrom = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $ageTo = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $birthYearFrom = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?int $birthYearTo = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $ageConstraintType = null;
/** @var array<string, mixed>|null */
#[Groups(['api:single'])]
public ?array $ageConstraintMetadata = null;
#[Groups(['api:single'])]
public ?string $rawAgeConstraintData = null;
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public ?string $description = null;
/**
@@ -110,6 +104,6 @@ class Service
* When false, this service's price is excluded when determining which insurance
* tier is appropriate for a participant.
*/
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Groups(['api:single', 'api:list'])]
public bool $includeInInsuranceCalculation = true;
}
-2
View File
@@ -13,9 +13,7 @@ namespace App\BusProNet\Model;
class Surcharge
{
public ?string $label = null;
/** @var list<int> */
public array $mapping = [];
public ?float $totalPrice = null;
/** @var array<int, float|null> */
public array $individualPrice = [];
}
+29 -50
View File
@@ -20,43 +20,40 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Travel
{
use SortByPriceTrait;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $productCode = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $productId = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $label = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?int $hotelId = null;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?Hotel $hotel = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateTo = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?string $type = null;
/**
* Travel booking status from BusProNet API.
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'.
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?string $status = null;
/**
@@ -65,71 +62,53 @@ class Travel
*
* @var array<string>
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $allowedBookingStatus = [];
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Groups(['api:list', 'api:single'])]
public ?float $priceFrom = null;
/**
* @var array<int, CrmSelectionGroup>
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $selectionGroups = [];
/**
* @var array<int, Service>
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $additionalServices = [];
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $additionalServicesMutable = true;
/**
* @var array<int, Service>
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $transportationServices = [];
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $transportationServicesMutable = true;
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single', 'snapshot'])]
public array $pickups = [];
#[Groups(['api:single'])]
public array $pickupsOutbound = [];
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single', 'snapshot'])]
public array $dropOffs = [];
#[Groups(['api:single'])]
public array $pickupsInbound = [];
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $pickupsMutable = true;
/**
* @var array<int, Room>
*/
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public array $rooms = [];
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $roomsMutable = true;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $participantDataMutable = true;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public bool $participantCountMutable = true;
#[Groups(['api:single', 'snapshot'])]
#[Groups(['api:single'])]
public ?Guide $guide = null;
/**
* @var array<string|int, Insurance> Available insurances for this travel package
* @var array<Insurance> Available insurances for this travel package
*/
#[Groups(['api:single'])]
public array $insurances = [];
@@ -203,7 +182,7 @@ class Travel
* Maps selection group IDs to their corresponding services based on
* the predefined included services mapping.
*
* @return array<int, CrmSelectionGroup> The included services from selection groups
* @return array<int, Service> The included services from selection groups
*/
public function getIncludedServices(): array
{
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service;
use App\BusProNet\Service\Contract\BookingStatusRuleInterface;
use App\Form\Model\BookingDto;
/**
* Registry for booking status evaluation rules.
*
* Evaluates registered rules in priority order and returns the first matching
* status. Falls back to 'F' when no rule applies.
*/
class BookingStatusRuleRegistry
{
private const DEFAULT_STATUS = 'F';
/**
* @var BookingStatusRuleInterface[]
*/
private array $sortedRules;
/**
* @param BookingStatusRuleInterface[] $rules
*/
public function __construct(array $rules)
{
$this->sortedRules = $this->sortRulesByPriority($rules);
}
public function evaluateStatus(BookingDto $bookingDto): string
{
foreach ($this->sortedRules as $rule) {
if (true === $rule->evaluate($bookingDto)) {
return $rule->getStatus();
}
}
return self::DEFAULT_STATUS;
}
/**
* @param BookingStatusRuleInterface[] $rules
*
* @return BookingStatusRuleInterface[]
*/
private function sortRulesByPriority(array $rules): array
{
usort($rules, static fn (
BookingStatusRuleInterface $a,
BookingStatusRuleInterface $b,
): int => $b->getPriority() <=> $a->getPriority());
return $rules;
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\Contract;
use App\Form\Model\BookingDto;
/**
* Defines the contract for booking status evaluation rules.
*
* Rules are evaluated in priority order. The first matching rule determines
* the enforced booking status.
*/
interface BookingStatusRuleInterface
{
/**
* Evaluates whether this rule applies to the given booking.
*/
public function evaluate(BookingDto $bookingDto): bool;
/**
* Returns the status code to assign when this rule matches.
*/
public function getStatus(): string;
/**
* Returns the rule priority (higher values evaluated first).
*/
public function getPriority(): int;
/**
* Returns a human-readable rule description.
*/
public function getDescription(): string;
}
@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\StatusRule;
use App\BusProNet\Model\Service;
use App\BusProNet\Service\Contract\BookingStatusRuleInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Status rule for bookings with chaperon (Begleitperson) services.
*
* Assigns 'Option' status to bookings where any participant has selected any
* service containing 'Begleitperson' in its label.
*/
class ChaperonServiceStatusRule implements BookingStatusRuleInterface
{
private const STATUS = 'O';
private const PRIORITY = 100;
private const SEARCH_TERM = 'begleitperson';
/**
* @see BookingStatusRuleInterface::evaluate()
*/
public function evaluate(BookingDto $bookingDto): bool
{
foreach ($bookingDto->participants as $participant) {
if (true === $this->participantHasChaperonService($participant)) {
return true;
}
}
return false;
}
private function participantHasChaperonService(ParticipantDto $participant): bool
{
// Check array services
$arrayServices = [
$participant->additionalServices,
$participant->courses,
$participant->board,
$participant->rentals,
];
foreach ($arrayServices as $services) {
foreach ($services as $service) {
if (true === $this->containsSearchTerm($service->label)) {
return true;
}
}
}
// Check single service properties
$singleServices = [
$participant->skiPass,
$participant->veg,
];
foreach ($singleServices as $service) {
if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) {
return true;
}
}
return false;
}
/**
* @see BookingStatusRuleInterface::getStatus()
*/
public function getStatus(): string
{
return self::STATUS;
}
/**
* @see BookingStatusRuleInterface::getPriority()
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* @see BookingStatusRuleInterface::getDescription()
*/
public function getDescription(): string
{
return 'Assigns Option status when a Begleitperson service is selected';
}
/**
* Checks if the label contains the search term (case-insensitive).
*/
private function containsSearchTerm(?string $label): bool
{
if (null === $label) {
return false;
}
return false !== stripos($label, self::SEARCH_TERM);
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\BusProNet\Traits;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\TimeoutException;
trait ApiClientTrait
{
private float $operationStartTime;
/**
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect(string $host, int $port, int $maxRetries = 25, int $connectionTimeout = 5, int $streamTimeout = 30, int $totalTimeout = 45)
{
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) use ($host, $port, $connectionTimeout) {
return @fsockopen(
$host,
$port,
$errNo,
$errStr,
$connectionTimeout
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $maxRetries > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $totalTimeout,
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $streamTimeout);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @throws TimeoutException
*/
private function send($socket, string $data, int $totalTimeout): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
/**
* @throws TimeoutException
*/
private function receive($socket, int $totalTimeout): string
{
$response = '';
$readAttempts = 0;
while (false === feof($socket)) {
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
return $response;
}
private function disconnect($socket): void
{
@fclose($socket);
}
}
+2 -2
View File
@@ -28,9 +28,9 @@ trait SortByPriceTrait
* collections in the application. Items are sorted from lowest to highest price,
* with null prices being treated as zero (appearing first).
*
* @param array<array-key, object> $items Array of objects with a price property to sort
* @param array $items Array of objects with a price property to sort
*
* @return array<array-key, object> The sorted array of objects (ascending by price)
* @return array The sorted array of objects (ascending by price)
*/
protected function sortByPrice(array $items): array
{
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff. Show more