Author SHA1 Message Date
Björn Fromme 03cb74370b fix: enforce submit-time immutability, scope booking cache per user 2026-03-16 11:20:05 +01:00
Björn Fromme d05524222b chore: update test to recent changes 2026-03-16 11:20:05 +01:00
Björn Fromme c02b1b57c7 fix: reduce mutability data cache ttl to avoid staleness
addresses #869cdzmkq
2026-03-16 11:20:05 +01:00
Björn Fromme efff024196 fix: ensure mutability is accounted for when restoring drafts
addresses #869cdzmkq
2026-03-16 11:20:05 +01:00
Björn Fromme 021671a45f fix: assign mandatory services when applying dummy data 2026-03-07 12:46:56 +01:00
Björn Fromme 744cf55a7e chore: update project dependencies 2026-03-07 12:38:10 +01:00
Björn Fromme f2c12a9f03 feat: update wording 2026-03-04 12:43:49 +01:00
Björn Fromme 5cf4b0f9ea feat: shortened and filename-safe request ids 2026-03-04 11:25:23 +01:00
Björn Fromme 370bc9fc5f fix: ensure pickups node exists in api payload 2026-03-04 11:09:41 +01:00
Björn Fromme a1d43b9c5a fix: ensure drop-offs are submitted when same as pickups 2026-03-04 10:48:57 +01:00
Björn Fromme 90fc66277e feat: anonymization command for request/response XML dumps 2026-03-04 10:47:20 +01:00
Björn Fromme cd5d353158 chore: update project dependencies 2026-03-02 11:57:46 +01:00
Björn Fromme 590146be9e feat: adopt new pickups planning payload JSON structure 2026-03-02 11:55:57 +01:00
Björn Fromme 8e2c688641 chore: cleanup 2026-02-25 17:17:21 +01:00
Björn Fromme f705a851c2 fix: validate mandatory services selection 2026-02-25 17:09:13 +01:00
Björn Fromme d33ff9779c fix: correctly validate pickup selection
closes #869c94jqn
2026-02-25 16:55:22 +01:00
Björn Fromme 876cd5a8fa fix: don't restore selections from draft for immutable fields 2026-02-24 18:16:52 +01:00
Björn Fromme 5a64c85fb6 fix: enforce mutability checks for all fields 2026-02-24 11:53:00 +01:00
Björn Fromme 31153e90b4 chore: remove dead code 2026-02-24 11:27:53 +01:00
Björn Fromme 949bea723d chore: add git ignored temp directory 2026-02-24 11:27:41 +01:00
Björn Fromme 0365daa57b feat: avoid indexing of content by crawlers 2026-02-23 09:45:22 +01:00
Björn Fromme 3996e02dd9 feat: use normalized checkbox values in field handlers 2026-02-23 09:31:19 +01:00
Björn Fromme bd488211bd chore: update plan for symfony 7.4 upgrade 2026-02-23 09:11:15 +01:00
Björn Fromme ca34d4a4f3 chore: update project dependencies 2026-02-23 08:59:35 +01:00
Björn Fromme a93a8469c4 chore: remove claude code entirely 2026-02-23 08:49:32 +01:00
Björn Fromme cec481741b fix: prevent race conditions in htmx boost response handling 2026-02-20 17:24:20 +01:00
Björn Fromme 39bacd2927 fix: ensure loading indicator when entering booking flow in Chrome 2026-02-20 17:23:56 +01:00
Björn Fromme 39972c1418 fix: avoid selection trap for mandatory services with age constraints 2026-02-20 17:06:43 +01:00
Björn Fromme 45cfc459aa fix: always fill-in empty participants names when submitting to api 2026-02-20 09:48:22 +01:00
Björn Fromme 21356f00d5 chore: update project dependencies 2026-02-19 12:57:09 +01:00
Björn Fromme 4529fe5472 fix: correct sort order of pickups and drop-offs 2026-02-19 12:56:06 +01:00
Björn Fromme 08aaff1e2d fix: show pickup select field for outbound bus travel only 2026-02-19 08:22:52 +01:00
Björn Fromme 0884a39f3d feat: selectable drop-offs for inbound bus travel 2026-02-17 11:53:39 +01:00
Björn Fromme 41b0f56b5b chore: split scheduler config for prod and staging 2026-02-17 11:53:14 +01:00
Björn Fromme b034b8c7f8 chore: update project dependencies 2026-02-17 08:57:07 +01:00
Björn Fromme a4cae4f321 fix: handle empty api responses more gracefully 2026-02-17 08:55:49 +01:00
Björn Fromme ab67f9d7b5 feat: legal links in page footer 2026-02-16 09:09:28 +01:00
Björn Fromme a22688c87e fix: catch unexpected api response for personal data 2026-02-15 12:57:03 +01:00
Björn Fromme cfc52a9cf3 fix: backward compatibility for new session format 2026-02-15 12:55:48 +01:00
Björn Fromme 33e09bbe89 fix: correctly determine file size and avoid type mismatch 2026-02-14 16:09:21 +01:00
Björn Fromme a339f8a517 feat: show booking status in sidebar 2026-02-14 16:04:54 +01:00
Björn Fromme abeb91b59f feat: dummy data fill
closes #869c3khk0
2026-02-14 15:56:56 +01:00
Björn Fromme 350a9e6742 chore: update project dependencies 2026-02-14 09:49:38 +01:00
Björn Fromme c373a98953 feat: reduce session payload 2026-02-14 09:47:14 +01:00
Björn Fromme 3863999f2a chore: use correct database type to replicate prod environment 2026-02-13 20:22:29 +01:00
Björn Fromme 54410628b4 chore: add staging environment to deployer config 2026-02-13 13:45:41 +01:00
Björn Fromme 2c3ce32282 fix: correct room assignment and count
closes #869c2jnzr
2026-02-09 11:00:36 +01:00
Björn Fromme dd03cdd77f chore: align node and npm versions of DDEV with host, use npm ci 2026-02-05 18:07:43 +01:00
Björn Fromme bc8d4f74c1 feat: fill default names for canceled participants in agency bookings 2026-02-05 17:57:35 +01:00
Björn Fromme 6f869bdb1a chore: update frontend tooling 2026-02-05 17:56:36 +01:00
Björn Fromme 6adc0769c7 chore: code cleanup 2026-02-05 17:45:54 +01:00
Björn Fromme 2f9930a240 feat: update personal data of canceled participants as well
addresses #869bze0kq
2026-02-05 11:23:25 +01:00
Björn Fromme 3036a7773c chore: update project dependencies 2026-02-05 10:30:01 +01:00
Björn Fromme e668c2d882 feat: revert allow updating room assignments in edit mode 2026-02-05 10:29:34 +01:00
Björn Fromme bb4768c491 chore: update platform specs in composer.json for prod 2026-01-30 17:46:51 +01:00
Björn Fromme 9140e4f3fc chore: update project dependencies 2026-01-30 17:29:56 +01:00
Björn Fromme 6b205c7a86 fix: allow updating room assignments in edit mode 2026-01-30 17:29:26 +01:00
Björn Fromme 0aa4bb5055 chore: streamline namespacing 2026-01-30 16:10:29 +01:00
Björn Fromme ee3b1f47a7 chore: update project dependencies 2026-01-29 16:25:35 +01:00
Björn Fromme 40da2ecaf3 fix: enforce profile completion of applicant in all situations 2026-01-29 16:24:27 +01:00
Björn Fromme 5d0ba0ef9a fix: ensure complete participant data even when canceled 2026-01-29 12:57:37 +01:00
Björn Fromme 786a4306dd chore: update project dependencies 2026-01-28 19:41:28 +01:00
Björn Fromme 677ffc7368 fix: streamline datalayer integration for all events 2026-01-28 19:40:48 +01:00
Björn Fromme 93eb08c610 fix: properly initialize CMP, GTM and data layer for tracking 2026-01-27 21:25:03 +01:00
Björn Fromme 6365e4b24c feat: remove street from hotel address in sidebar
addresses #869bwq1vh
2026-01-27 18:36:03 +01:00
Björn Fromme e138fbeca8 chore: update frontend dependencies 2026-01-27 18:29:59 +01:00
Björn Fromme d29a6a1835 chore: update project dependencies 2026-01-27 18:26:15 +01:00
Björn Fromme dc91660488 feat: require all personal data fields for registration 2026-01-27 18:25:38 +01:00
Björn Fromme e9548d9f38 feat: tracking integration with GTM and CMP 2026-01-27 17:31:41 +01:00
Björn Fromme d5944bf717 feat: domain-based theme-detection 2026-01-27 11:18:37 +01:00
Björn Fromme 2355442ac0 chore: update database and php versions to match prod environment 2026-01-26 08:24:19 +01:00
Björn Fromme 789555078d chore: update deployer config for new hosting environment 2026-01-25 13:53:43 +01:00
Björn Fromme 34f190f469 fix: don't normalize hotel and product codes when fetching cms data 2026-01-23 15:35:24 +01:00
Björn Fromme 8646c4ffdd chore: update project url in ddev 2026-01-23 13:05:48 +01:00
Björn Fromme 419d3b78c6 chore: update project dependencies 2026-01-23 12:54:07 +01:00
Björn Fromme e8df7d193e feat: display error codes in admin overview 2026-01-23 11:15:46 +01:00
Björn Fromme aa08307500 feat: error-codes in flash messages to reference log entries 2026-01-23 11:06:24 +01:00
Björn Fromme c41524c1fd feat: user-friendly error messages on login 2026-01-23 10:30:17 +01:00
Björn Fromme 8dec0be375 feat: streamlined flash messages 2026-01-23 10:15:44 +01:00
Björn Fromme 9471abce94 fix: revert pickup pricing to match current BusPro behavior
Reverts split pricing calculation to use outbound-only pricing,
matching BusPro's current behavior where pickup is only charged when
outbound transportation is BUS.

- Use pickup->price directly instead of calculateEffectivePrice()
- PKW+BUS scenario now correctly charges €0 (matches BusPro loophole)
- priceOutbound/priceInbound remain populated for future activation
- Updated documentation with current behavior and future plan
2026-01-21 17:18:09 +01:00
Björn Fromme 2aeed762fd feat: extensible participant status determination for booking creation
Adds a rule-based system to determine participant status in CREATE
payloads. Participants selecting a 'Begleitperson' service now receive
status 'O' (Option), all others default to 'F' (Final).

- Add ParticipantStatusRuleInterface for defining status rules
- Add ParticipantStatusRuleRegistry for priority-based rule evaluation
- Add ChaperonServiceStatusRule for Begleitperson detection
- Integrate status evaluation into BookingPayloadBuilder
2026-01-21 10:16:57 +01:00
Björn Fromme 512b63b88b feat: infotext for insurance booking conditions
closes #869bvcu2p
2026-01-21 09:33:36 +01:00
Björn Fromme 92d7eac3a1 chore: update httpclient config to use random values for contactform 2026-01-21 09:02:47 +01:00
Björn Fromme 548b8a8ef0 feat: log contact form submissions 2026-01-21 08:43:28 +01:00
Björn Fromme fb8aabe34c chore: remove debug logging in toast controller 2026-01-21 08:11:36 +01:00
Björn Fromme 0c1f143b12 feat: info text and correct field type for service group 'VEG'
addresses #869bvch71
2026-01-20 18:36:33 +01:00
Björn Fromme 24ac2efc53 fix: don't hide voucher fields for booking status 'O'
closes #869bvbkrd
2026-01-20 16:06:17 +01:00
Björn Fromme e7a139b025 chore: update test to fix deprecation warning 2026-01-20 16:05:48 +01:00
Björn Fromme cfe19b7a74 chore: update project dependencies 2026-01-20 16:05:28 +01:00
Björn Fromme f725a6b72f feat: ignored room types
closes #869bvbmcw
2026-01-20 15:52:57 +01:00
Björn Fromme a097159897 feat: move profile completeness check from login to booking flow entry
- Add profileComplete flag to User entity to avoid API calls
- Set flag during login (BpnAuthenticator) and profile save
- Check flag in IndexController when entering booking flow
- Remove ProfileCompletionSubscriber (no longer needed)

Users with incomplete profiles are only redirected when starting
a new booking, not on every login. Eliminates extra API call by
caching completeness status on the user entity.
2026-01-19 18:55:55 +01:00
Björn Fromme da33620ea9 chore: update alert wording to be more descriptive 2026-01-19 18:06:29 +01:00
Björn Fromme 0b44394b11 feat: enforce profile data completeness before entering booking flow 2026-01-19 18:04:19 +01:00
Björn Fromme 2949870acc feat: allow editing of nationality in personal data 2026-01-19 17:12:26 +01:00
Björn Fromme 35c289240d fix: default participant's nationality to German to avoid missing data 2026-01-19 17:12:12 +01:00
Björn Fromme 3b165bf292 chore: update rsync excludes in deployer config 2026-01-19 16:17:35 +01:00
Björn Fromme f0c43a17d8 feat: body dimensions mandatory with selected rentals, updated help text
feat: body dimensions mandatory with selected rentals
2026-01-19 16:12:53 +01:00
Björn Fromme 2776429639 fix: preserve selected services from booking even when unavailable
When editing a booking, services that were previously booked but are no
longer available in the travel catalog were being dropped. This caused
API error 650 ("Anzahl Leistung stimmt nicht mit Teilnehmerzuordnung
überein") because the service participant counts no longer matched.

The fix ensures that in edit mode, booked services are merged with
travel data services in both:
- Form rendering (ParticipantFieldOptionsProvider): so checkboxes appear
- Handler validation (AbstractParticipantFieldHandler): so selections
  are accepted

This allows users to keep their existing service selections or
deliberately replace them with other available options. Create mode
remains unchanged.
2026-01-19 15:51:39 +01:00
Björn Fromme 5c9f8c2a2e doc: add technical documentation 2026-01-17 19:27:06 +01:00
Björn Fromme 3e6533829c fix: ensure unique services mapping when creating api payload 2026-01-16 14:22:17 +01:00
Björn Fromme 054505d853 feat: match modes for room selection condition to detect MBZ*
addresses #869btdcyh
2026-01-15 11:02:27 +01:00
Björn Fromme c793550af5 feat: improved logic to control field visibility
addresses #869btdphv
2026-01-15 10:28:26 +01:00
Björn Fromme b18fccff9d fix: correctly handle discounts applied by dpn api 2026-01-14 17:46:15 +01:00
Björn Fromme 398eb448cb feat: cli command to download travel xml data from buspro api 2026-01-14 16:57:17 +01:00
Björn Fromme 6cdede138e fix: correct body dimensions values 2026-01-14 16:07:30 +01:00
Björn Fromme 3046aad94d fix: account for discounts applied by bpn api in validation request 2026-01-14 12:47:25 +01:00
Björn Fromme ec2ce6ec62 feat: revert room price fetching for performance reasons 2026-01-13 15:54:54 +01:00
Björn Fromme 3c61e7eaf3 feat: trigger form refresh for dob when completely entered only 2026-01-13 15:37:05 +01:00
Björn Fromme f942600dd8 fix: fetch fresh room prices from api to avoid calculation mismatch 2026-01-13 15:14:27 +01:00
Björn Fromme efacbf64cb fix: consider booking status O correctly 2026-01-13 14:42:51 +01:00
Björn Fromme e38445f184 fix: correctly compare age range 2026-01-13 14:42:35 +01:00
Björn Fromme 00803b2e73 feat: always load fresh api data when starting to edit booking 2026-01-13 13:16:35 +01:00
Björn Fromme a52f65919f chore: update project dependencies 2026-01-13 10:22:25 +01:00
Björn Fromme 3d4d88e986 feat: xlsx export of booking edit draft data for admins 2026-01-13 10:22:03 +01:00
Björn Fromme 5221cea53b feat: merge draft data with api response for selected fields 2026-01-12 18:25:51 +01:00
Björn Fromme 7636ee41af fix: ensure nationality is assigned when editing booking data 2026-01-12 18:23:52 +01:00
Björn Fromme 830fd2464c fix: avoid reinstallation of claude code with each restart of ddev 2026-01-11 13:35:50 +01:00
Björn Fromme d922fd2b9c chore: update project dependencies 2026-01-11 13:26:02 +01:00
Björn Fromme d22078b26e fix: populate booking number with edit drafts 2026-01-11 13:25:13 +01:00
Björn Fromme 195af95995 feat: integrate with easy admin bundle 2026-01-11 13:24:07 +01:00
Björn Fromme d95d0a8930 feat: streamlined booking number property 2026-01-10 12:12:54 +01:00
Björn Fromme 6fca40963d wip: integrate with easy admin bundle 2026-01-10 11:35:16 +01:00
Björn Fromme ace251c1dd feat: cleanup favicons 2026-01-10 11:34:46 +01:00
Björn Fromme db2e92c058 feat: custom hx redirect class 2026-01-10 11:32:14 +01:00
Björn Fromme 28b62f5071 feat: adjust xml dump reader command to new filename format 2026-01-10 11:31:24 +01:00
Björn Fromme 168c128b1d chore: update claude code installation in ddev container 2026-01-09 18:07:45 +01:00
Björn Fromme 90af012e56 chore: update lts upgrade plan with current state of the project 2026-01-09 16:34:46 +01:00
Björn Fromme d6c36b4171 chore: simplify and update claude code installation in ddev container 2026-01-09 16:25:43 +01:00
Björn Fromme 9575aad10c feat: always allow editing of participants for internal bookings
Prior to this commit the mutability flag from BusProNet was evaluated
and would potentially conflict with this rule. This flag is now
completely ignored for internal bookings.
2026-01-09 13:37:22 +01:00
Björn Fromme 7b98cccbe9 fix: add suffix to request id in xml-dump filenames to avoid overwrites 2026-01-09 13:28:04 +01:00
Björn Fromme fe2268c897 feat: cleanup, log successful bookings 2026-01-09 10:05:21 +01:00
Björn Fromme f2c1988d75 chore: add draft cleanup command to schedule 2026-01-09 09:49:35 +01:00
Björn Fromme 5b9f35eddc fix: use correct property path for travel date determination 2026-01-09 09:46:29 +01:00
Björn Fromme c8ed5b2cc7 feat: cli command to backfill travel dates on draft records, single use 2026-01-09 09:39:35 +01:00
Björn Fromme da517b37b6 feat: cli command to clean up outdated or stale booking draft records 2026-01-09 09:22:51 +01:00
Björn Fromme 0992c05539 fix: don't update booking session and draft on form refresh
Each form refresh would create a draft even for users that just explore
booking options causing stale draft records in the database.
2026-01-09 09:22:18 +01:00
Björn Fromme ed8a8a2f8b feat: streamlined display of hotel image, name and address 2026-01-08 17:51:39 +01:00
Björn Fromme 5516ef45f6 chore: update project dependencies 2026-01-08 16:43:16 +01:00
Björn Fromme 6beb965c85 feat: new service category 'VEG' 2026-01-08 16:42:42 +01:00
Björn Fromme 479a88e131 chore: update httpclient config 2026-01-08 16:32:02 +01:00
Björn Fromme e4a1a992be feat: command to read xml-dumps from bpn api by provided request id 2026-01-08 14:23:22 +01:00
Björn Fromme 97a5d7f9f1 fix: include gender and nationality into group of mandatory fields 2026-01-08 14:18:37 +01:00
Björn Fromme 776b9d0912 feat: proper determination of agency initiated bookings
For booking that are agency initiated (groups) different rules apply
concerning mutability and requirement of personal data and whether the
first participant is the applicant or not. This commit adds logic to
evaluate the agency id assigned to the booking data to decide.
2026-01-08 12:54:59 +01:00
Björn Fromme df35ed3032 feat: log request ids to map xml dumps of bpn api communication 2026-01-08 10:17:00 +01:00
Björn Fromme 59b08ed098 fix: update draft together with session on refresh 2026-01-08 08:38:21 +01:00
Björn Fromme 936004bc2d fix: show actual error message 2026-01-07 20:30:58 +01:00
Björn Fromme 365658d727 fix: allow null argument for gender mapping 2026-01-07 20:00:51 +01:00
Björn Fromme d603e57c62 fix: insurance ids are strings 2026-01-07 19:50:41 +01:00
Björn Fromme 58ec94eaeb fix: exclude pickup surcharge when self-organized outbound 2026-01-07 19:45:12 +01:00
Björn Fromme 2f4184f60f fix: avoid float rounding errors when comparing 2026-01-07 18:46:28 +01:00
Björn Fromme b0b608f12e feat: load hotel data locally for sidebar 2026-01-07 18:35:09 +01:00
Björn Fromme 97be776b10 feat: default status of new bookings to option during beta phase
addresses #869bpb7fg
2026-01-07 17:55:38 +01:00
Björn Fromme ddb876bc1c fix: ensure rental insurance is selectable when insurance is available
addresses #869bp9ev5
2026-01-07 17:31:20 +01:00
Björn Fromme 6fee5c048a fix: correctly distinguish between first participant and applicant 2026-01-07 17:22:17 +01:00
Björn Fromme 80f22b7cd0 fix: don't include default date of birth in contact form submissions 2026-01-07 14:44:53 +01:00
Björn Fromme e1123577f4 feat: update field label 2026-01-07 11:36:08 +01:00
Björn Fromme 5f33f82b8b fix: redirect to dashboard as default when logged in 2026-01-07 10:15:27 +01:00
Björn Fromme 5285ded46f chore: revert claude code replacement with opencode 2026-01-07 09:27:59 +01:00
Björn Fromme 69df93b773 feat: load balancing of api requests, cleanup api client 2026-01-07 08:33:16 +01:00
Björn Fromme 1e3389e21a fix: properly handle parking service selection 2026-01-06 19:03:10 +01:00
Björn Fromme 192b1fdcf4 fix: add missing LoggerInterface dependency to BookingDataProcessorTest 2026-01-06 18:46:06 +01:00
Björn Fromme 6008a1b2d9 feat: retry api requests when closed immediately due to overload 2026-01-06 17:29:34 +01:00
Björn Fromme 62fc734f82 fix: avoid null pointer exceptions, log warning about removed services 2026-01-06 16:08:51 +01:00
Björn Fromme 7fb5466eb4 fix: avoid scrolling to the bottom when making a selection
closes #869bnn1bw
2026-01-06 15:34:25 +01:00
Björn Fromme aafac9d43b chore: update scheduler config 2026-01-06 15:01:08 +01:00
Björn Fromme 585f87e1f9 chore: reduce logger noise 2026-01-06 14:48:32 +01:00
Björn Fromme eb1d05aa10 fix: only assign dummy data to the applicant 2026-01-06 14:39:49 +01:00
Björn Fromme db1de2653c fix: assign dummy values to missing applicant data fields
This fix avoids rejection of API requests due to missing data of the
applicant. The current user most probably is not aware about this so
they get confused about the error messages otherwise.
2026-01-06 14:29:41 +01:00
Björn Fromme 8bdc33b4f0 feat: draft inspection cli command 2026-01-06 14:06:04 +01:00
Björn Fromme 1c1766bbd4 fix: apply correct return type and response handling 2026-01-06 14:05:51 +01:00
Björn Fromme 96205c14db fix: only invalidate cache and remove draft on successful edit 2026-01-06 13:53:49 +01:00
Björn Fromme 7ec4562064 chore: code cleanup 2026-01-06 12:38:51 +01:00
Björn Fromme 1eb1e9f02f fix: invalidate booking data caches after updating personal data 2026-01-06 12:24:07 +01:00
Björn Fromme 0b35b3494b fix: streamline price label for transportation services 2026-01-06 12:08:13 +01:00
Björn Fromme e021fc7570 feat: link terms in confirmation checkbox label 2026-01-06 11:56:03 +01:00
Björn Fromme 2a13a62291 feat: remove cancel confirmation since obsolete because of drafts 2026-01-06 10:44:30 +01:00
Björn Fromme 60c1459902 feat: automatic saving of drafts when editing bookings 2026-01-06 10:29:17 +01:00
242 changed files with 44992 additions and 2957 deletions
@@ -1,11 +0,0 @@
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: []
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
## Description: Run Claude Code
## Usage: claude [args]
## Example: ddev claude
## Example: ddev claude --help
## Example: ddev claude doctor
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
# Check if claude is installed
if [ ! -f "$HOME/.local/bin/claude" ]; then
echo "Claude Code is not installed. Installing now..."
curl -fsSL https://claude.ai/install.sh | bash
fi
# Run claude with all arguments
exec "$HOME/.local/bin/claude" "$@"
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
#ddev-generated
## Description: OpenCode AI coding assistant
## Usage: opencode
## Example: ddev opencode [...]
set -e
opencode $@
-6
View File
@@ -1,6 +0,0 @@
#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"
+40 -24
View File
@@ -1,44 +1,51 @@
type: php type: php
docroot: public docroot: public
php_version: "8.2" php_version: "8.3"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: [] additional_hostnames:
- mysnz
- mysbw
- myser
additional_fqdns: [] additional_fqdns: []
database: database:
type: mariadb type: mysql
version: "10.11" version: "8.4"
use_dns_when_possible: true use_dns_when_possible: true
composer_version: "2" composer_version: "2"
web_environment: [] web_environment: []
corepack_enable: false corepack_enable: false
nodejs_version: "22"
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides # name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site # 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, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress # type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
# See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# "drupal" covers recent Drupal 8+
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# 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" # php_version: "8.3" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # nginx/php docker image. # webimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, and 11.4. # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0. # MySQL versions can be 5.5-8.0, 8.4
# PostgreSQL versions can be 9-17. # PostgreSQL versions can be 9-18
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # 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) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -48,12 +55,21 @@ corepack_enable: false
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, # "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. # as leaving Xdebug enabled all the time is a big performance hit.
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" # xhgui_http_port: "8143"
# Note that for most people the commands # xhgui_https_port: "8142"
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, # The XHGui ports can be changed from the default 8143 and 8142
# as leaving Xhprof enabled all the time is a big performance hit. # Very rarely used
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn # 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
# timezone: Europe/Berlin # timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone # If timezone is unset, DDEV will attempt to derive it from the host system timezone
@@ -79,9 +95,9 @@ corepack_enable: false
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # 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". # To reinstall Composer after the image was built, run "ddev debug rebuild".
# nodejs_version: "20" # nodejs_version: "22"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://ddev.readthedocs.io/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # 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, # 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'. # can specify any version, and is more robust than using 'nvm'.
@@ -117,7 +133,7 @@ corepack_enable: false
# ddev_version_constraint: "" # ddev_version_constraint: ""
# Example: # Example:
# ddev_version_constraint: ">= 1.22.4" # ddev_version_constraint: ">= 1.24.8"
# This will enforce that the running ddev version is within this constraint. # This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for # See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats # supported constraint formats
@@ -146,8 +162,8 @@ corepack_enable: false
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project. # - "nfs": enables NFS for this project.
# #
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs # See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook # Decide whether 'ddev start' should be interrupted by a failing hook
@@ -196,7 +212,7 @@ corepack_enable: false
# ngrok_args: --basic-auth username:pass1234 # ngrok_args: --basic-auth username:pass1234
# Provide extra flags to the "ngrok http" command, see # Provide extra flags to the "ngrok http" command, see
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" # https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -274,7 +290,7 @@ corepack_enable: false
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db", # DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer" # "pre-composer", "post-composer"
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more # See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define # information on the commands that can be extended and the tasks you can define
# for them. Example: # for them. Example:
#hooks: #hooks:
-5
View File
@@ -1,5 +0,0 @@
#ddev-generated
# OpenCode internal config
/.opencode/
-4
View File
@@ -1,4 +0,0 @@
#ddev-generated
RUN npm install -g opencode-ai
+11 -2
View File
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # 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://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.11-MariaDB&charset=utf8mb4" DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8" # DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
@@ -43,20 +43,29 @@ MAILER_DSN=null://null
APP_BPN_USER= APP_BPN_USER=
APP_BPN_PASSWORD= APP_BPN_PASSWORD=
APP_BPN_IP= APP_BPN_IP=
APP_BPN_PORT= APP_BPN_PORTS=
APP_BPN_DEBUG=false APP_BPN_DEBUG=false
APP_BPN_CONNECTION_TIMEOUT=5 APP_BPN_CONNECTION_TIMEOUT=5
APP_BPN_STREAM_TIMEOUT=30 APP_BPN_STREAM_TIMEOUT=30
APP_BPN_TOTAL_TIMEOUT=45 APP_BPN_TOTAL_TIMEOUT=45
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/" 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_CMS_API_BASE_URL="https://www.ep-reisen.de/"
APP_WEBSITE_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 # Travel Data Service Configuration
APP_TRAVEL_PREFER_REMOTE=false APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_ENABLE_FALLBACK=true
# 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= API_KEYS=
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
+2
View File
@@ -31,3 +31,5 @@ yarn-error.log
/.php-cs-fixer.php /.php-cs-fixer.php
/.php-cs-fixer.cache /.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ### ###< friendsofphp/php-cs-fixer ###
/ray.php
+9 -9
View File
@@ -72,7 +72,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
### API hotel availability remote ### API hotel availability remote
# @no-cookie-jar # @no-cookie-jar
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability GET {{base_url}}/api/travels/11606/152546/2026-01-24/availability
Accept: application/json Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}} Authorization: Bearer {{$auth.token("oauth2_api")}}
@@ -90,14 +90,14 @@ Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}} Authorization: Bearer {{$auth.token("oauth2_api")}}
{ {
"lastName": "Duschen", "lastName": "{{$random.name.lastName}}",
"firstName": "Isolde", "firstName": "{{$random.name.firstName}}",
"gender": "f", "gender": "d",
"street": "Fooroad 1", "street": "{{$random.address.streetAddress}}",
"zipCode": "12345", "zipCode": "{{$random.address.zipCode}}",
"city": "Bartown", "city": "{{$random.address.city}}",
"email": "[email protected]", "email": "{{$random.email}}",
"phone": "12345" "phone": "{{$random.phoneNumber.cellPhone}}"
} }
### API pickups planning webhook ### API pickups planning webhook
+59
View File
@@ -0,0 +1,59 @@
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
}
}
@@ -0,0 +1,13 @@
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);
}
}
}
-9
View File
@@ -24,24 +24,15 @@ export default class extends Controller {
} }
handleNotifications(event) { 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 // 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 // event.detail.value contains the actual array from the HX-Trigger header
const notifications = event.detail?.value || event.detail || [] 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)) { if (Array.isArray(notifications)) {
notifications.forEach((notification, index) => { notifications.forEach((notification, index) => {
console.log(`[VOUCHER DEBUG] Processing notification ${index}:`, notification)
const className = this.getClassForType(notification.type) const className = this.getClassForType(notification.type)
this.showToast(notification.message, className) this.showToast(notification.message, className)
}) })
} else {
console.error('[VOUCHER DEBUG] Notifications is not an array:', notifications)
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+9
View File
@@ -0,0 +1,9 @@
<?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>

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long
+44 -6
View File
@@ -5,6 +5,8 @@ const DEBOUNCE_MS = 200
let debounceTimeout = null let debounceTimeout = null
let historyRestoreGracePeriod = false let historyRestoreGracePeriod = false
let savedScrollPosition = null
let activeRequests = 0
function getIndicator() { function getIndicator() {
return document.getElementById(INDICATOR_ID) return document.getElementById(INDICATOR_ID)
@@ -28,12 +30,20 @@ function hide() {
} }
} }
function handleBeforeRequest() { function handleBeforeRequest(event) {
// Ignore requests triggered during history restore grace period // Ignore requests triggered during history restore grace period
if (true === historyRestoreGracePeriod) { if (true === historyRestoreGracePeriod) {
return 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 // Don't start a new debounce if already pending
if (debounceTimeout) { if (debounceTimeout) {
return return
@@ -45,7 +55,29 @@ function handleBeforeRequest() {
}, DEBOUNCE_MS) }, 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() { function handleHistoryRestore() {
activeRequests = 0
hide() hide()
// Set grace period to ignore change events triggered by history restore // Set grace period to ignore change events triggered by history restore
@@ -56,23 +88,29 @@ function handleHistoryRestore() {
} }
function handleTimeout() { function handleTimeout() {
hide() // Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
// and handles the counter. This handler only shows the alert.
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.') alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
} }
function handleBeforeHistorySave() { function handleBeforeHistorySave() {
// Hide indicator before HTMX saves the page to history cache // Hide indicator before HTMX saves the page to history cache
activeRequests = 0
hide()
}
function handlePageShow() {
activeRequests = 0
hide() hide()
} }
// Initialize event listeners // Initialize event listeners
document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest) document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', hide) document.body.addEventListener('htmx:afterRequest', handleRequestEnd)
document.body.addEventListener('htmx:afterSwap', handleAfterSwap)
document.body.addEventListener('htmx:timeout', handleTimeout) document.body.addEventListener('htmx:timeout', handleTimeout)
document.body.addEventListener('htmx:historyRestore', handleHistoryRestore) document.body.addEventListener('htmx:historyRestore', handleHistoryRestore)
document.body.addEventListener('htmx:sendError', hide)
document.body.addEventListener('htmx:responseError', hide)
document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave) document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave)
window.addEventListener('pageshow', hide) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('popstate', handleHistoryRestore) window.addEventListener('popstate', handleHistoryRestore)
+4 -1
View File
@@ -14,12 +14,15 @@
"doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.27",
"fakerphp/faker": "^1.24",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0", "league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0", "nelexa/zip": "^4.0",
"nesbot/carbon": "^3.8", "nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4",
"phpstan/phpdoc-parser": "^2.0", "phpstan/phpdoc-parser": "^2.0",
"spatie/crypto": "^2.1", "spatie/crypto": "^2.1",
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
@@ -69,7 +72,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.2.28" "php": "8.3.30"
} }
}, },
"autoload": { "autoload": {
Generated
+1240 -637
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,4 +16,6 @@ return [
League\FlysystemBundle\FlysystemBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true], League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true], Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
]; ];
+4 -2
View File
@@ -15,5 +15,7 @@ framework:
#app: cache.adapter.apcu #app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default # Namespaced pools use the above "app" backend by default
#pools: pools:
#my.dedicated.cache: null bpn.cache:
adapter: cache.adapter.filesystem
tags: true
+2 -1
View File
@@ -33,7 +33,8 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https } - { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
when@test: when@test:
security: security:
+4
View File
@@ -4,6 +4,10 @@ twig:
globals: globals:
htmx_change_trigger: 'change delay:100ms' htmx_change_trigger: 'change delay:100ms'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%' 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: when@test:
twig: twig:
+5
View File
@@ -0,0 +1,5 @@
twig_component:
anonymous_template_directory: 'components/'
defaults:
# Namespace & directory for components
App\Twig\Components\: 'components/'
+20 -5
View File
@@ -9,13 +9,32 @@ zenstruck_schedule:
schedule_extensions: schedule_extensions:
email_on_failure: email_on_failure:
to: [email protected] enabled: true
tasks: tasks:
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
frequency: "0 1 * * *" frequency: "0 1 * * *"
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging" description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
- 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"
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 - task: app:bpn:xml-sync
frequency: "*/15 8-19 * * *" frequency: "*/15 8-19 * * *"
description: "Sync BusPro XML data every 15 min. during peak hours" description: "Sync BusPro XML data every 15 min. during peak hours"
@@ -23,7 +42,3 @@ zenstruck_schedule:
- task: app:bpn:xml-sync - task: app:bpn:xml-sync
frequency: "0 20-23,0-7 * * *" frequency: "0 20-23,0-7 * * *"
description: "Sync BusPro XML data hourly outside peak hours" description: "Sync BusPro XML data hourly outside peak hours"
- task: app:cleanup:log-entries
frequency: "30 1 * * *"
description: "Removes outdated log entries with a retention period of 6 months"
+3
View File
@@ -0,0 +1,3 @@
easyadmin:
resource: .
type: easyadmin.routes
+48 -3
View File
@@ -5,8 +5,10 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%' 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' path_to_keys: '%kernel.project_dir%/config/secret'
bpn_debug: '%env(APP_BPN_DEBUG)%' bpn_debug: '%env(APP_BPN_DEBUG)%'
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
# Body dimensions choices for BodyDimensionsType # Body dimensions choices for BodyDimensionsType
body_dimensions.height_choices: body_dimensions.height_choices:
@@ -16,7 +18,7 @@ parameters:
'167 - 178cm': '167-178' '167 - 178cm': '167-178'
'179 - 185cm': '179-185' '179 - 185cm': '179-185'
'186 - 194cm': '186-194' '186 - 194cm': '186-194'
'195cm oder mehr': '195cm+' '195cm oder mehr': '195+'
body_dimensions.shoe_size_min: 36 body_dimensions.shoe_size_min: 36
body_dimensions.shoe_size_max: 48 body_dimensions.shoe_size_max: 48
body_dimensions.weight_choices: body_dimensions.weight_choices:
@@ -26,7 +28,26 @@ parameters:
'67 - 78kg': '67-78' '67 - 78kg': '67-78'
'79 - 85kg': '79-85' '79 - 85kg': '79-85'
'86 - 94kg': '86-94' '86 - 94kg': '86-94'
'95kg oder mehr': '95k+' '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
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@@ -39,6 +60,7 @@ services:
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$jsonExport: '@json_export.storage' $jsonExport: '@json_export.storage'
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%' $travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
$termsAndConditionsUrl: '%terms_and_conditions_url%'
$logger: '@monolog.logger.core' $logger: '@monolog.logger.core'
$environment: '%kernel.environment%' $environment: '%kernel.environment%'
@@ -59,7 +81,7 @@ services:
bpn_username: '%env(APP_BPN_USER)%' bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%' bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%' bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_port: '%env(APP_BPN_PORT)%' bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
debug: '%env(bool:APP_BPN_DEBUG)%' debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%' connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%' stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
@@ -83,6 +105,10 @@ services:
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%' $preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%' $enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
App\Service\BookingEditDataLoaderService:
arguments:
$bpnCache: '@bpn.cache'
App\Service\VoucherValidationService: App\Service\VoucherValidationService:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
@@ -111,6 +137,7 @@ services:
- 'App\Form\Service\ParticipantTransportationOutboundFieldHandler' - 'App\Form\Service\ParticipantTransportationOutboundFieldHandler'
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler' - 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
- 'App\Form\Service\ParticipantPickupFieldHandler' - 'App\Form\Service\ParticipantPickupFieldHandler'
- 'App\Form\Service\ParticipantDropOffFieldHandler'
- 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantParkingFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler'
@@ -121,7 +148,25 @@ services:
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler' - '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
# Participant Status Rules
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
# Participant Status Rule Registry
App\BusProNet\Service\ParticipantStatusRuleRegistry:
arguments:
$rules:
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
App\Service\CmsDataService: App\Service\CmsDataService:
arguments: arguments:
$httpClient: '@typo3.client' $httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented $apiKey: 'AbcAbc123'# dummy key, not yet implemented
App\Logger\RequestIdProcessor:
tags:
- { name: monolog.processor }
App\Service\DomainConfigProvider:
arguments:
$domainConfig: '%domain_config%'
+19
View File
@@ -0,0 +1,19 @@
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
+27 -23
View File
@@ -17,7 +17,6 @@ add('shared_dirs', [
add('shared_files', [ add('shared_files', [
'public/.htaccess', 'public/.htaccess',
'.env.local.php',
]); ]);
set('keep_releases', 3); set('keep_releases', 3);
@@ -33,6 +32,7 @@ $rsyncOptions = [
'.ddev', '.ddev',
'.git', '.git',
'.github', '.github',
'.jj',
'node_modules', 'node_modules',
'.editorconfig', '.editorconfig',
'.env.dev.local', '.env.dev.local',
@@ -46,14 +46,17 @@ $rsyncOptions = [
'.phpunit.result.cache', '.phpunit.result.cache',
'config/secret/', 'config/secret/',
'CLAUDE.md', 'CLAUDE.md',
'cypress*',
'deploy.php', 'deploy.php',
'docs',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
'*.http', '*.http',
'http-client.private.env.json', 'http-client*.json',
'postcss.config.js', 'postcss.config.js',
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'ray.php',
'tailwind.config.js', 'tailwind.config.js',
'webpack.config.js', 'webpack.config.js',
'bin/.phpunit', 'bin/.phpunit',
@@ -74,31 +77,31 @@ $rsyncOptions = [
]; ];
host('prod') host('prod')
->setHostname('92.205.212.39') ->setHostname('185.243.135.29')
->setRemoteUser('ssh-53857-root') ->setRemoteUser('p704161')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/kunden/tiefschnee.de/myep') ->setDeployPath('/home/www/p704161/html/myep')
->set('writable_mode', 'chmod')
->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={{release_or_current_path}}/public/ --web-url=https://my.ep-reisen.de')
;
host('staging')
->setHostname('185.243.132.170')
->setRemoteUser('p546473')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p546473/html/myep')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php') ->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('keep_releases', 5) ->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://myep.dreipunktnull.net') ;
host('staging')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-staging')
->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')
; ;
task('deploy', [ task('deploy', [
@@ -110,14 +113,15 @@ task('deploy', [
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
'deploy:cache:clear',
'database:migrate', 'database:migrate',
'deploy:publish',
'cachetool:clear:opcache', 'cachetool:clear:opcache',
'deploy:publish',
'deploy:stop-workers', 'deploy:stop-workers',
]); ]);
task('deploy:assets', function () { task('deploy:assets', function () {
runLocally('npm i && npm run build'); runLocally('npm ci && npm run build');
}); });
task('deploy:stop-workers', function () { task('deploy:stop-workers', function () {
+29 -20
View File
@@ -1,9 +1,9 @@
# Symfony 7.4 Upgrade Plan # Symfony 7.4 Upgrade Plan
**Project:** MyEP Next Booking **Project:** MyEP Next Booking
**Current Version:** Symfony 6.4.31 **Current Version:** Symfony 6.4.x (6.4.33)
**Target Version:** Symfony 7.4 (LTS) **Target Version:** Symfony 7.4 (LTS)
**Date:** 2026-01-05 **Date:** 2026-02-23
**Status:** Ready for Upgrade **Status:** Ready for Upgrade
--- ---
@@ -18,17 +18,16 @@ The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade.
**Key Facts:** **Key Facts:**
- Symfony 7.4 was released on November 27, 2025 - Symfony 7.4 was released on November 27, 2025
- Requires PHP 8.2.0+ (current: PHP 8.2.29) - Requires PHP 8.2.0+ (current: PHP 8.3.30)
- LTS version with bug fixes until November 2028, security fixes until November 2029 - 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) - Symfony 8.0 = Symfony 7.4 minus deprecations (same features, PHP 8.4 required)
**Pre-Upgrade Status:** **Pre-Upgrade Status:**
- PHP version: 8.2.29 (compatible) - PHP version: 8.3.30 (compatible; Symfony 7.4 requires 8.2+)
- OAuth2 Server Bundle: 1.1.0 (compatible) - OAuth2 Server Bundle: 1.1.0 (compatible)
- Stimulus Bundle: 2.32.0 (compatible) - Stimulus Bundle: 2.32.0 (compatible)
- KnpMenuBundle: Removed (no longer in use)
- Doctrine ORM: 3.6.0 (compatible) - Doctrine ORM: 3.6.0 (compatible)
- All 312 tests passing - All 433 tests passing
- Email validation mode: Configured (html5) - Email validation mode: Configured (html5)
- 18 test-only deprecation warnings (cosmetic, do not affect runtime) - 18 test-only deprecation warnings (cosmetic, do not affect runtime)
@@ -49,19 +48,14 @@ 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) | | Email validator "loose" mode deprecation | Needs fix | **Completed** (validator.yaml configured) |
| Failing test in BookingDataProcessorTest | Unrelated to upgrade | **Resolved** (test now passes) | | 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 ## Current State Analysis
### PHP Version Compatibility ### PHP Version Compatibility
- **Current Runtime:** PHP 8.2.29 - **Current Runtime:** PHP 8.3.30
- **Composer Platform:** PHP 8.2.28 - **Composer Platform:** PHP 8.3.30
- **Symfony 7.4 Required:** PHP 8.2.0+ - **Symfony 7.4 Required:** PHP 8.2.0+
- **Status:** Compatible - **Status:** Compatible
@@ -81,6 +75,11 @@ All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes | | `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
| `symfony/monolog-bundle` | 3.11.0 | Yes | | `symfony/monolog-bundle` | 3.11.0 | Yes |
| `zenstruck/schedule-bundle` | 1.9.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 ### Code Quality Assessment
@@ -124,7 +123,7 @@ All Email constraints in the codebase also explicitly use `mode: 'strict'`:
**Impact:** These warnings are cosmetic and do not affect: **Impact:** These warnings are cosmetic and do not affect:
- Runtime behavior (all Email constraints use explicit `mode: 'strict'`) - Runtime behavior (all Email constraints use explicit `mode: 'strict'`)
- Symfony 7.4 compatibility (no code changes needed) - Symfony 7.4 compatibility (no code changes needed)
- Test results (all 312 tests pass) - Test results (all 433 tests pass)
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired. **Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
@@ -182,12 +181,16 @@ Update all packages from `6.4.*` to `7.4.*`:
"symfony/browser-kit": "7.4.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*", "symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*", "symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "7.4.*",
"symfony/stopwatch": "7.4.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "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 ### 2. Update Symfony Flex Configuration
**File:** `composer.json` **File:** `composer.json`
@@ -252,6 +255,7 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
1. **Update composer.json** 1. **Update composer.json**
- Update all Symfony package constraints to `7.4.*` - Update all Symfony package constraints to `7.4.*`
- Update `extra.symfony.require` 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** 2. **Run Composer Update**
```bash ```bash
@@ -305,6 +309,7 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
- [ ] User registration - [ ] User registration
- [ ] Password reset - [ ] Password reset
- [ ] Personal data management - [ ] Personal data management
- [ ] EasyAdmin CRUD / admin panels
4. **Check Symfony Profiler** 4. **Check Symfony Profiler**
- Review deprecation warnings (should be minimal) - Review deprecation warnings (should be minimal)
@@ -320,6 +325,10 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
- Verify Toastify notifications - Verify Toastify notifications
- Check TailwindCSS compilation - 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) ### Phase 4: Bug Fixes (0-2 hours)
Address any issues discovered during testing: Address any issues discovered during testing:
@@ -386,7 +395,7 @@ ddev exec vendor/bin/dep rollback production
## Testing Checklist ## Testing Checklist
### Unit Tests ### Unit Tests
- [ ] All PHPUnit tests pass (312 tests) - [ ] All PHPUnit tests pass (433 tests)
- [ ] No new deprecation warnings - [ ] No new deprecation warnings
- [ ] Code coverage maintained - [ ] Code coverage maintained
@@ -442,7 +451,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
## Success Criteria ## Success Criteria
- [ ] All PHPUnit tests pass (312 tests) - [ ] All PHPUnit tests pass (433 tests)
- [ ] No deprecation warnings in Symfony Profiler - [ ] No deprecation warnings in Symfony Profiler
- [ ] All critical user flows functional - [ ] All critical user flows functional
- [ ] OAuth2 authentication working - [ ] OAuth2 authentication working
@@ -469,7 +478,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
--- ---
**Document Version:** 5.0 **Document Version:** 7.0
**Previous Version:** 4.0 (dated 2025-12-06) **Previous Version:** 6.0 (dated 2026-01-09)
**Last Updated:** 2026-01-05 **Last Updated:** 2026-02-23
**Status:** Ready for Symfony 7.4 Upgrade **Status:** Ready for Symfony 7.4 Upgrade (updated for current project state)
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -7,8 +7,8 @@
"Grant Type": "Authorization Code", "Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}", "Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}", "Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep-next-booking.ddev.site/authorize", "Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token", "Token URL": "https://myep.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check", "Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email" "Scope": "email"
}, },
@@ -17,8 +17,8 @@
"Grant Type": "Authorization Code", "Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}", "Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}", "Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep-next-booking.ddev.site/authorize", "Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token", "Token URL": "https://myep.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check", "Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email profile" "Scope": "email profile"
}, },
@@ -27,8 +27,8 @@
"Grant Type": "Client Credentials", "Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}", "Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}", "Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://myep-next-booking.ddev.site/authorize", "Auth URL": "https://myep.ddev.site/authorize",
"Token URL": "https://myep-next-booking.ddev.site/token", "Token URL": "https://myep.ddev.site/token",
"Scope": "api" "Scope": "api"
} }
} }
+33
View File
@@ -0,0 +1,33 @@
<?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
@@ -0,0 +1,33 @@
<?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
@@ -0,0 +1,26 @@
<?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
@@ -0,0 +1,28 @@
<?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
@@ -0,0 +1,29 @@
<?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');
}
}
+403 -400
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-1
View File
@@ -1 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 995 B

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
-6
View File
@@ -1,6 +0,0 @@
{
"icons": [
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
]
}
-8
View File
@@ -1,8 +0,0 @@
<?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
@@ -0,0 +1,19 @@
<?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');
}
}
+342 -49
View File
@@ -4,34 +4,35 @@ namespace App\BusProNet;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BaseData; use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse; use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate; use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\ContactFormResponse;
use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher; use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher; use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser; use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\RegistrationDto; use App\Form\Model\RegistrationDto;
use App\Service\RequestIdGenerator;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
class ApiClient class ApiClient
{ {
use ApiClientTrait;
public const TYPE_NOTIFICATION = 'HINWEIS'; public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO'; public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER'; public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
@@ -47,6 +48,9 @@ class ApiClient
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN'; public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
private array $config; private array $config;
private float $operationStartTime;
private int $selectedPort;
private int $requestCounter = 0;
public function __construct( public function __construct(
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
@@ -54,6 +58,8 @@ class ApiClient
private readonly FilesystemOperator $xmlDump, private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
array $options, array $options,
) { ) {
$this->config = $this->resolveOptions($options); $this->config = $this->resolveOptions($options);
@@ -79,21 +85,14 @@ class ApiClient
/** /**
* @throws ApiClientException * @throws ApiClientException
*/ */
public function register(RegistrationDto $registrationData): Notification public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
{ {
$data = [ $data = [
'user' => $this->config['bpn_username'], 'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu', 'art' => 'Adresse_Neu',
'adressdaten' => [ 'adressdaten' => $registrationData->toPayload(),
'geschlecht' => $registrationData->gender,
'vorname' => $registrationData->firstName,
'name' => $registrationData->name,
'kommunikation' => [
'email' => $registrationData->email,
],
],
]; ];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
@@ -144,13 +143,13 @@ class ApiClient
public function createAddress( public function createAddress(
PersonalData $personalData, PersonalData $personalData,
bool $debug = false, bool $debug = false,
): ContactFormResponse|Notification { ): RegistrationResponse|Notification {
$data = [ $data = [
'user' => $this->config['bpn_username'], 'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu', 'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(), 'adressdaten' => $personalData->toPayload(false),
'ohnemailversand' => 'True', 'ohnemailversand' => 'True',
]; ];
@@ -397,6 +396,30 @@ class ApiClient
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]); 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 * @throws ApiClientException
*/ */
@@ -489,10 +512,11 @@ class ApiClient
} }
/** /**
* Sends raw XML to the BPN API with key regeneration. * Sends raw XML to the BPN API with key regeneration and automatic retry.
* *
* Parses the XML to extract the request type, regenerates the authentication key * 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. * 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 string $xml The raw XML request body
* @param bool $debug Enable debug mode (XML dumps) * @param bool $debug Enable debug mode (XML dumps)
@@ -503,7 +527,18 @@ class ApiClient
*/ */
public function sendRawXml(string $xml, bool $debug = false): string public function sendRawXml(string $xml, bool $debug = false): string
{ {
$requestId = date(DATE_ATOM).uniqid(); 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();
$doc = new \DOMDocument(); $doc = new \DOMDocument();
if (false === @$doc->loadXML($xml)) { if (false === @$doc->loadXML($xml)) {
@@ -534,25 +569,19 @@ class ApiClient
$body = $doc->saveXML(); $body = $doc->saveXML();
$this->logger->info('Sending raw XML request to BPN API', [
'requestId' => $requestId,
'type' => $type,
]);
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body); $this->dumpXmlToFile('request', $requestId, $body);
} }
$socket = $this->connect( $socket = $this->connect();
$this->config['bpn_api_ip'],
$this->config['bpn_api_port'], $this->logger->info('Sending raw XML request to BPN API', [
$this->config['max_retries'], 'requestId' => $requestId,
$this->config['connection_timeout'], 'type' => $type,
$this->config['stream_timeout'], 'port' => $this->selectedPort,
$this->config['total_timeout'] ]);
); $this->send($socket, $body);
$this->send($socket, $body, $this->config['total_timeout']); $response = $this->receive($socket);
$response = $this->receive($socket, $this->config['total_timeout']);
$this->disconnect($socket); $this->disconnect($socket);
$responseXml = substr($response, 10); $responseXml = substr($response, 10);
@@ -569,7 +598,70 @@ class ApiClient
*/ */
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{ {
$requestId = date(DATE_ATOM).uniqid(); return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
}
/**
* @throws ApiClientException
*/
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
*/
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
$requestId = $this->getRequestId();
$body = $this $body = $this
->serializer ->serializer
@@ -579,30 +671,33 @@ class ApiClient
]) ])
; ;
$this->logger->info('Sending request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
]);
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body); $this->dumpXmlToFile('request', $requestId, $body);
} }
$socket = $this->connect( $socket = $this->connect();
$this->config['bpn_api_ip'],
$this->config['bpn_api_port'], $this->logger->info('Sending request to BPN API', [
$this->config['max_retries'], 'requestId' => $requestId,
$this->config['connection_timeout'], 'type' => $data['satz']['@typ'],
$this->config['stream_timeout'], 'port' => $this->selectedPort,
$this->config['total_timeout'] ]);
);
$this->send($socket, $body, $this->config['total_timeout']); $this->send($socket, $body);
$response = $this->receive($socket, $this->config['total_timeout']); $response = $this->receive($socket);
$this->disconnect($socket); $this->disconnect($socket);
// message length (10 bytes) is prepended to actual message // message length (10 bytes) is prepended to actual message
$xml = substr($response, 10); $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']) { if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
} }
@@ -620,6 +715,44 @@ class ApiClient
throw new ApiClientException('Unexpected response received from API'); throw new ApiClientException('Unexpected response received from API');
} }
/**
* @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 private function dumpXmlToFile(string $type, string $requestId, string $body): void
{ {
try { try {
@@ -628,6 +761,14 @@ 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 private function createKey(string $username, string $password, string $type): string
{ {
$date = (new \DateTimeImmutable())->format('Ymd'); $date = (new \DateTimeImmutable())->format('Ymd');
@@ -635,6 +776,155 @@ class ApiClient
return md5($username.$password.$date.$type); return md5($username.$password.$date.$type);
} }
/**
* @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;
}
/**
* @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);
}
/**
* @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;
}
private function disconnect($socket): void
{
@fclose($socket);
}
private function resolveOptions(array $options): array private function resolveOptions(array $options): array
{ {
$optionsResolver = new OptionsResolver(); $optionsResolver = new OptionsResolver();
@@ -642,14 +932,17 @@ class ApiClient
'bpn_username', 'bpn_username',
'bpn_password', 'bpn_password',
'bpn_api_ip', 'bpn_api_ip',
'bpn_api_port', 'bpn_api_ports',
]); ]);
$optionsResolver->setAllowedTypes('bpn_api_ports', ['array']);
$optionsResolver->setDefaults([ $optionsResolver->setDefaults([
'max_retries' => 25, 'max_retries' => 25,
'debug' => false, 'debug' => false,
'connection_timeout' => 5, 'connection_timeout' => 5,
'stream_timeout' => 30, 'stream_timeout' => 30,
'total_timeout' => 45, 'total_timeout' => 45,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]); ]);
return $optionsResolver->resolve($options); return $optionsResolver->resolve($options);
+6 -1
View File
@@ -14,6 +14,7 @@ final class Constants
public const TOKEN_SKI_PASS = 'SPA'; public const TOKEN_SKI_PASS = 'SPA';
public const TOKEN_ADDITIONAL = 'SON'; public const TOKEN_ADDITIONAL = 'SON';
public const TOKEN_BOARD = 'VPF'; 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_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public const TOKEN_RENTAL_INSURANCE = 'LVS'; public const TOKEN_RENTAL_INSURANCE = 'LVS';
public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG']; public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG'];
@@ -37,6 +38,7 @@ final class Constants
self::TOKEN_SKI_PASS => 'Skipässe', self::TOKEN_SKI_PASS => 'Skipässe',
self::TOKEN_ADDITIONAL => 'Zusatzleistungen', self::TOKEN_ADDITIONAL => 'Zusatzleistungen',
self::TOKEN_BOARD => 'Verpflegung', self::TOKEN_BOARD => 'Verpflegung',
self::TOKEN_VEG => 'Verpflegungswunsch',
self::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung', self::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
self::TOKEN_PARKING => 'Parkplatz', self::TOKEN_PARKING => 'Parkplatz',
self::GROUP_TRANSPORTATION => 'Beförderung', self::GROUP_TRANSPORTATION => 'Beförderung',
@@ -55,7 +57,7 @@ final class Constants
// Booking status codes from buchungstatusmoeglich attribute // Booking status codes from buchungstatusmoeglich attribute
public const BOOKING_STATUS_FREE = 'F'; public const BOOKING_STATUS_FREE = 'F';
public const BOOKING_STATUS_INQUIRY = 'A'; public const BOOKING_STATUS_INQUIRY = 'A';
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung - not relevant for current implementation public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung
// Payment methods // Payment methods
public const PAYMENT_METHOD_TRANSFER = 'transfer'; public const PAYMENT_METHOD_TRANSFER = 'transfer';
@@ -68,4 +70,7 @@ final class Constants
// Baby room constraints // Baby room constraints
public const BABY_MAX_AGE = 2; public const BABY_MAX_AGE = 2;
public const BABY_ROOM_CODE = 'Baby'; public const BABY_ROOM_CODE = 'Baby';
// Pseudo price room - used for website display only, not bookable
public const PSEUDO_PRICE_ROOM_CODE = 'PDGS';
} }
@@ -124,9 +124,19 @@ class BookingDataProcessor
// Pickup location // Pickup location
$participantData->pickup = $booking->getPickupForParticipant($index); $participantData->pickup = $booking->getPickupForParticipant($index);
// Parking (stored in form data, not in booking entity - needs special handling) // Drop-off location
// For now, leave as false - may need to extract from transportation services $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->parking = false;
$participantData->parkingService = null;
}
// License plate (not stored in booking entity) // License plate (not stored in booking entity)
$participantData->licensePlate = null; $participantData->licensePlate = null;
@@ -156,6 +166,8 @@ class BookingDataProcessor
$roomSelection->label = $room->label; $roomSelection->label = $room->label;
$roomSelection->price = $room->price; $roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $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; $dto->roomSelections[] = $roomSelection;
} }
} }
@@ -213,6 +225,11 @@ class BookingDataProcessor
$participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id]; $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 // Enrich transportation services
if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) { if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) {
$participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id]; $participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id];
@@ -222,9 +239,33 @@ class BookingDataProcessor
$participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id]; $participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id];
} }
// Enrich pickup // Enrich pickup with data from travel (includes both outbound and inbound prices)
if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) { // The pickups already have inbound prices merged in from TravelParser
$participant->pickup = $travel->pickupsOutbound[$participant->pickup->id]; 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 insurance // Enrich insurance
@@ -266,13 +307,18 @@ class BookingDataProcessor
} }
$this->serviceProcessor->removeUnusedServices($bookingData); $this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData($formData->participants, $bookingData); $this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants,
$bookingData,
$formData->isInternalAgencyBooking()
);
$payload = $this->payloadBuilder->buildBasePayload($bookingData); $payload = $this->payloadBuilder->buildBasePayload($bookingData);
$this->payloadBuilder->addBankAccountToPayload($payload, $bookingData); $this->payloadBuilder->addBankAccountToPayload($payload, $bookingData);
$this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants); $this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants);
$this->payloadBuilder->buildServicesPayload($payload, $bookingData); $this->payloadBuilder->buildServicesPayload($payload, $bookingData);
$this->payloadBuilder->buildPickupPayload($payload, $bookingData); $this->payloadBuilder->buildPickupPayload($payload, $bookingData);
$this->payloadBuilder->buildDropOffPayload($payload, $bookingData);
// Add purchase vouchers if any exist // Add purchase vouchers if any exist
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData); $purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
@@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Service\ParticipantStatusRuleRegistry;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
/** /**
@@ -18,6 +19,7 @@ class BookingPayloadBuilder
{ {
public function __construct( public function __construct(
private readonly ServiceMappingCollector $mappingCollector, private readonly ServiceMappingCollector $mappingCollector,
private readonly ParticipantStatusRuleRegistry $statusRuleRegistry,
) { ) {
} }
@@ -145,22 +147,25 @@ class BookingPayloadBuilder
public function buildServicesPayload(array &$payload, Booking $bookingData): void public function buildServicesPayload(array &$payload, Booking $bookingData): void
{ {
foreach ($bookingData->additionalServices as $service) { foreach ($bookingData->additionalServices as $service) {
$uniqueMapping = array_unique($service->mapping);
$payload['zusatzleistungen']['zusatzleistung'][] = [ $payload['zusatzleistungen']['zusatzleistung'][] = [
'@idleistung' => $service->id, '@idleistung' => $service->id,
'@anzahl' => count($service->mapping), '@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
]; ];
} }
foreach ($bookingData->transportationServices as $service) { foreach ($bookingData->transportationServices as $service) {
$uniqueMapping = array_unique($service->mapping);
$payload['beförderungen']['beförderung'][] = [ $payload['beförderungen']['beförderung'][] = [
'@idleistung' => $service->id, '@idleistung' => $service->id,
'@anzahl' => count($service->mapping), '@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
]; ];
} }
foreach ($bookingData->rooms as $room) { foreach ($bookingData->rooms as $room) {
$uniqueMapping = array_unique($room->mapping);
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
'@idzimmer' => $room->id, '@idzimmer' => $room->id,
'@kategorie' => $room->category, '@kategorie' => $room->category,
@@ -168,7 +173,7 @@ class BookingPayloadBuilder
'@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null,
'@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null, '@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null,
'@anzahl' => $room->totalCount, '@anzahl' => $room->totalCount,
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
]; ];
} }
@@ -176,11 +181,12 @@ class BookingPayloadBuilder
if (false === empty($bookingData->insurances)) { if (false === empty($bookingData->insurances)) {
$payload['versicherungen']['versicherung'] = []; $payload['versicherungen']['versicherung'] = [];
foreach ($bookingData->insurances as $insurance) { foreach ($bookingData->insurances as $insurance) {
if (count($insurance->mapping) > 0) { $uniqueMapping = array_unique($insurance->mapping);
if (count($uniqueMapping) > 0) {
$payload['versicherungen']['versicherung'][] = [ $payload['versicherungen']['versicherung'][] = [
'@idversicherung' => $insurance->id, '@idversicherung' => $insurance->id,
'@anzahl' => count($insurance->mapping), '@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
]; ];
} }
} }
@@ -197,13 +203,39 @@ class BookingPayloadBuilder
*/ */
public function buildPickupPayload(array &$payload, Booking $bookingData): void public function buildPickupPayload(array &$payload, Booking $bookingData): void
{ {
if (0 < count($bookingData->pickupsOutbound)) { $payload['zustiege'] = [];
if (0 < count($bookingData->pickups)) {
$payload['zustiege']['zustieg'] = []; $payload['zustiege']['zustieg'] = [];
foreach ($bookingData->pickupsOutbound as $pickup) { foreach ($bookingData->pickups as $pickup) {
$uniqueMapping = array_unique($pickup->mapping);
$payload['zustiege']['zustieg'][] = [ $payload['zustiege']['zustieg'][] = [
'@idzustieg' => $pickup->id, '@idzustieg' => $pickup->id,
'@anzahl' => count($pickup->mapping), '@anzahl' => count($uniqueMapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)), '@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 $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)),
]; ];
} }
} }
@@ -270,6 +302,7 @@ class BookingPayloadBuilder
foreach ($bookingDto->participants as $index => $participant) { foreach ($bookingDto->participants as $index => $participant) {
$participantData = [ $participantData = [
'@id' => $index + 1, '@id' => $index + 1,
'status' => $this->statusRuleRegistry->evaluateStatus($participant),
'name' => $participant->lastName, 'name' => $participant->lastName,
'vorname' => $participant->firstName, 'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '', 'geschlecht' => $participant->gender ?? '',
@@ -359,6 +392,13 @@ class BookingPayloadBuilder
$this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto); $this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto);
$this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap); $this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap);
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap); $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); $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers) // Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
@@ -415,10 +455,11 @@ class BookingPayloadBuilder
if (false === empty($serviceMap)) { if (false === empty($serviceMap)) {
$payload[$sectionKey][$itemKey] = []; $payload[$sectionKey][$itemKey] = [];
foreach ($serviceMap as $serviceId => $participantIds) { foreach ($serviceMap as $serviceId => $participantIds) {
$uniqueParticipantIds = array_unique($participantIds);
$payload[$sectionKey][$itemKey][] = [ $payload[$sectionKey][$itemKey][] = [
$idAttributeName => $serviceId, $idAttributeName => $serviceId,
'@anzahl' => count($participantIds), '@anzahl' => count($uniqueParticipantIds),
'@zuordnung' => implode(',', $participantIds), '@zuordnung' => implode(',', $uniqueParticipantIds),
]; ];
} }
} }
@@ -462,6 +503,7 @@ class BookingPayloadBuilder
} }
$quantity = $roomQuantities[$roomId] ?? 1; $quantity = $roomQuantities[$roomId] ?? 1;
$uniqueParticipantIds = array_unique($participantIds);
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
'@idzimmer' => $room->id, '@idzimmer' => $room->id,
@@ -470,7 +512,7 @@ class BookingPayloadBuilder
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'), '@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'), '@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
'@anzahl' => $quantity, '@anzahl' => $quantity,
'@zuordnung' => implode(',', $participantIds), '@zuordnung' => implode(',', $uniqueParticipantIds),
]; ];
} }
} }
@@ -7,6 +7,7 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use Psr\Log\LoggerInterface;
/** /**
* Processes participant service selections for booking updates. * Processes participant service selections for booking updates.
@@ -17,6 +18,11 @@ use App\Form\Model\ParticipantDto;
*/ */
class ParticipantServiceProcessor class ParticipantServiceProcessor
{ {
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/** /**
* Resets all existing participant-to-service mappings to start with a clean slate. * Resets all existing participant-to-service mappings to start with a clean slate.
* *
@@ -30,8 +36,8 @@ class ParticipantServiceProcessor
$servicesToReset = [ $servicesToReset = [
...$bookingData->additionalServices, ...$bookingData->additionalServices,
...$bookingData->transportationServices, ...$bookingData->transportationServices,
...$bookingData->pickupsOutbound, ...$bookingData->pickups,
...$bookingData->pickupsInbound, ...$bookingData->dropOffs,
...$bookingData->rooms, ...$bookingData->rooms,
...$bookingData->insurances, ...$bookingData->insurances,
]; ];
@@ -60,6 +66,7 @@ class ParticipantServiceProcessor
$this->processAdditionalServices($participant, $bookingData, $travelData); $this->processAdditionalServices($participant, $bookingData, $travelData);
$this->processTransportationServices($participant, $bookingData, $travelData); $this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData); $this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData); $this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData); $this->processInsurance($participant, $bookingData, $travelData);
} }
@@ -86,15 +93,15 @@ class ParticipantServiceProcessor
} }
} }
foreach ($bookingData->pickupsOutbound as $pickup) { foreach ($bookingData->pickups as $pickup) {
if (0 === count($pickup->mapping)) { if (0 === count($pickup->mapping)) {
unset($bookingData->pickupsOutbound[$pickup->id]); unset($bookingData->pickups[$pickup->id]);
} }
} }
foreach ($bookingData->pickupsInbound as $pickup) { foreach ($bookingData->dropOffs as $dropOff) {
if (0 === count($pickup->mapping)) { if (0 === count($dropOff->mapping)) {
unset($bookingData->pickupsInbound[$pickup->id]); unset($bookingData->dropOffs[$dropOff->id]);
} }
} }
@@ -109,7 +116,7 @@ class ParticipantServiceProcessor
* Collects additional services from a participant for mapping. * Collects additional services from a participant for mapping.
* *
* Extracts all additional services (courses, board, rentals, ski pass, rental insurance, * Extracts all additional services (courses, board, rentals, ski pass, rental insurance,
* additional services) from a participant into a flat array of Service objects. * parking, additional services) from a participant into a flat array of Service objects.
* *
* @param ParticipantDto $participant The participant data * @param ParticipantDto $participant The participant data
* *
@@ -134,6 +141,11 @@ class ParticipantServiceProcessor
$services[] = $participant->rentalInsurance; $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; return $services;
} }
@@ -154,11 +166,18 @@ class ParticipantServiceProcessor
foreach ($servicesToMap as $service) { foreach ($servicesToMap as $service) {
if (false === isset($bookingData->additionalServices[$service->id])) { if (false === isset($bookingData->additionalServices[$service->id])) {
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null; $serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
if (null !== $serviceToAdd) { 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;
}
$bookingData->additionalServices[$service->id] = $serviceToAdd; $bookingData->additionalServices[$service->id] = $serviceToAdd;
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; $bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
} }
}
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index; $bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
} }
} }
@@ -176,13 +195,24 @@ class ParticipantServiceProcessor
private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{ {
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) { foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) {
if (null === $service) {
continue;
}
if (false === isset($bookingData->transportationServices[$service->id])) { if (false === isset($bookingData->transportationServices[$service->id])) {
$serviceToAdd = $travelData->transportationServices[$service->id] ?? null; $serviceToAdd = $travelData->transportationServices[$service->id] ?? null;
if (null !== $serviceToAdd) { 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;
}
$bookingData->transportationServices[$service->id] = $serviceToAdd; $bookingData->transportationServices[$service->id] = $serviceToAdd;
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; $bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
} }
}
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index; $bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
} }
} }
@@ -203,10 +233,34 @@ class ParticipantServiceProcessor
$hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType; $hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType;
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) { if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) {
if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) { if (false === isset($bookingData->pickups[$selectedPickup->id])) {
$bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup; $bookingData->pickups[$selectedPickup->id] = $selectedPickup;
} }
$bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index; $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;
} }
} }
@@ -7,6 +7,7 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication; use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
/** /**
@@ -20,28 +21,53 @@ class PersonalDataSynchronizer
/** /**
* Updates participant personal data from form input. * Updates participant personal data from form input.
* *
* Processes all active participants (status 'F' or 'A'). Skips canceled participants (status 'S'). * Processes all participants regardless of status, including canceled participants (status 'S').
* Updates all personal data fields and communication information. * 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 * IMPORTANT: The applicant's address must never be modified. This method updates
* participant addresses independently to ensure applicant data remains intact. * participant addresses independently to ensure applicant data remains intact.
* *
* @param array<ParticipantDto> $participants The participants array from the form * @param array<ParticipantDto> $participants The participants array from the form
* @param Booking $bookingData The booking data object to update * @param Booking $bookingData The booking data object to update
* @param bool $isInternalAgencyBooking Whether to fill default names for participants with empty names
*/ */
public function updateParticipantPersonalData(array $participants, Booking $bookingData): void public function updateParticipantPersonalData(
{ array $participants,
foreach ($participants as $participant) { Booking $bookingData,
// Skip canceled participants (status 'S') bool $isInternalAgencyBooking = false,
if ('S' === $participant->status) { ): void {
continue; // 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);
foreach ($participants as $participant) {
$bookingData->participants[$participant->index]->firstName = $participant->firstName; $bookingData->participants[$participant->index]->firstName = $participant->firstName;
$bookingData->participants[$participant->index]->name = $participant->lastName; $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;
}
}
$bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth; $bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth;
$bookingData->participants[$participant->index]->gender = $participant->gender; $bookingData->participants[$participant->index]->gender = $participant->gender;
$bookingData->participants[$participant->index]->nationality = $participant->nationality; $bookingData->participants[$participant->index]->nationality = $participant->nationality;
$this->fillMissingParticipantFields($bookingData->participants[$participant->index]);
$bookingData->participants[$participant->index]->height = $participant->height; $bookingData->participants[$participant->index]->height = $participant->height;
$bookingData->participants[$participant->index]->weight = $participant->weight; $bookingData->participants[$participant->index]->weight = $participant->weight;
$bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize; $bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize;
@@ -68,4 +94,60 @@ class PersonalDataSynchronizer
} }
} }
} }
/**
* 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) {
$applicant->communication = new Communication();
}
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 * @param array<int, array<string, mixed>> $payload
* *
* @return array<string, array<int, array<string, string>>> * @return array<string, array<int, array{city: string, location: string, datetime: string, busNumber: string}>>
*/ */
public function transform(array $payload): array public function transform(array $payload): array
{ {
@@ -27,28 +27,27 @@ class PickupPlanningTransformer
continue; continue;
} }
$travelCodes = $item['Reisecodes'] ?? []; $code = $item['Reisecode'] ?? '';
if (0 === count($travelCodes)) { if ('' === $code) {
continue; continue;
} }
$dateTime = \DateTimeImmutable::createFromFormat('d.m.Y H:i', $item['Zeit'] ?? ''); $dateTime = \DateTimeImmutable::createFromFormat('d.m.Y H:i', $item['Zeit'] ?? '');
foreach ($travelCodes as $code) {
$pickup = [ $pickup = [
'city' => $item['Zustieg/Ausstieg Ort'] ?? '', 'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '', 'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '', 'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
'busNumber' => $item['BusNummer'] ?? '',
]; ];
$key = sprintf('%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime']); $key = sprintf('%s|%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime'], $pickup['busNumber']);
if (false === isset($result[$code][$key])) { if (false === isset($result[$code][$key])) {
$result[$code][$key] = $pickup; $result[$code][$key] = $pickup;
} }
} }
}
foreach ($result as $code => $pickups) { foreach ($result as $code => $pickups) {
$pickups = array_values($pickups); $pickups = array_values($pickups);
@@ -57,6 +57,11 @@ class ServiceMappingCollector
$serviceMap[$board->id][] = $participantId; $serviceMap[$board->id][] = $participantId;
} }
// Veg (vegetarian/vegan) preference
if (null !== $participant->veg) {
$serviceMap[$participant->veg->id][] = $participantId;
}
// Ski pass // Ski pass
if (null !== $participant->skiPass) { if (null !== $participant->skiPass) {
$serviceMap[$participant->skiPass->id][] = $participantId; $serviceMap[$participant->skiPass->id][] = $participantId;
@@ -138,6 +143,28 @@ class ServiceMappingCollector
return $pickupMap; 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. * Collects insurance mappings.
* *
@@ -0,0 +1,15 @@
<?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
{
}
+6
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Represents a physical address with street, postal code, city, and country information. * Represents a physical address with street, postal code, city, and country information.
* *
@@ -13,14 +15,18 @@ namespace App\BusProNet\Model;
*/ */
class Address class Address
{ {
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $street = null; public ?string $street = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $postCode = null; public ?string $postCode = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $city = null; public ?string $city = null;
public ?string $district = null; public ?string $district = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $country = null; public ?string $country = null;
/** /**
+25 -4
View File
@@ -41,8 +41,8 @@ class Booking
public array $transportationServices = []; public array $transportationServices = [];
public array $additionalServices = []; public array $additionalServices = [];
public array $rooms = []; public array $rooms = [];
public array $pickupsOutbound = []; public array $pickups = [];
public array $pickupsInbound = []; public array $dropOffs = [];
public array $surcharges = []; public array $surcharges = [];
public array $insurances = []; public array $insurances = [];
public ?int $invoiceNumber = null; public ?int $invoiceNumber = null;
@@ -136,7 +136,7 @@ class Booking
* Retrieves pickup service for a specific participant. * Retrieves pickup service for a specific participant.
* *
* Finds the pickup service assigned to the specified participant * Finds the pickup service assigned to the specified participant
* from the outbound pickup services. * from the pickup services.
* *
* @param int $participantIndex The participant index to search for * @param int $participantIndex The participant index to search for
* *
@@ -144,7 +144,7 @@ class Booking
*/ */
public function getPickupForParticipant(int $participantIndex): ?Pickup public function getPickupForParticipant(int $participantIndex): ?Pickup
{ {
foreach ($this->pickupsOutbound as $pickup) { foreach ($this->pickups as $pickup) {
if (in_array($participantIndex, $pickup->mapping)) { if (in_array($participantIndex, $pickup->mapping)) {
return $pickup; return $pickup;
} }
@@ -153,6 +153,27 @@ class Booking
return null; 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. * Gets the insurance assigned to a specific participant.
* *
+46 -2
View File
@@ -17,14 +17,14 @@ class BookingResponse
{ {
/** /**
* @param string $status Booking status (möglich|erfolgt) * @param string $status Booking status (möglich|erfolgt)
* @param string|null $transactionNumber Transaction number (vorgang) * @param int|null $bookingNumber Booking number (BPN XML: vorgang)
* @param array<int, PriceItem> $priceItems Individual price items from response * @param array<int, PriceItem> $priceItems Individual price items from response
* @param float|null $totalPrice Total price (gesamtpreis) * @param float|null $totalPrice Total price (gesamtpreis)
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung) * @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
*/ */
public function __construct( public function __construct(
public readonly string $status, public readonly string $status,
public readonly ?string $transactionNumber = null, public readonly ?int $bookingNumber = null,
public readonly array $priceItems = [], public readonly array $priceItems = [],
public readonly ?float $totalPrice = null, public readonly ?float $totalPrice = null,
public readonly ?PaymentTerms $paymentTerms = null, public readonly ?PaymentTerms $paymentTerms = null,
@@ -102,4 +102,48 @@ class BookingResponse
2 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);
}
} }
+16 -8
View File
@@ -31,11 +31,18 @@ class PersonalData
public ?string $salutation = null; public ?string $salutation = null;
public ?string $title = 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; public ?string $gender = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $nationality = null; public ?string $nationality = null;
public ?string $height = null; public ?string $height = null;
public ?string $shoeSize = null; public ?string $shoeSize = null;
public ?string $weight = null; public ?string $weight = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?\DateTimeImmutable $dateOfBirth = null; public ?\DateTimeImmutable $dateOfBirth = null;
public ?string $remarks = null; public ?string $remarks = null;
@@ -71,17 +78,11 @@ class PersonalData
* *
* @return array<string, mixed> The payload array for API transmission * @return array<string, mixed> The payload array for API transmission
*/ */
public function toPayload(): array public function toPayload(bool $includeDateOfBirth = true): array
{ {
// Ensure date of birth is populated $payload = [
if (null === $dob = $this->dateOfBirth) {
$dob = new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
}
return [
'idadresse' => $this->addressId, 'idadresse' => $this->addressId,
'idadresseperson' => $this->personId, 'idadresseperson' => $this->personId,
'geburtsdatum' => $dob->format('d.m.Y'),
'anrede' => $this->salutation, 'anrede' => $this->salutation,
'geschlecht' => $this->gender, 'geschlecht' => $this->gender,
'nationalitaet' => $this->nationality, 'nationalitaet' => $this->nationality,
@@ -95,6 +96,13 @@ class PersonalData
'sonstiges3' => $this->shoeSize, 'sonstiges3' => $this->shoeSize,
'bemerkung' => $this->remarks, '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 public static function fromContactFormSubmission(ContactFormSubmission $dto): self
+77
View File
@@ -39,6 +39,12 @@ class Pickup
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?float $price = null; public ?float $price = null;
#[Groups(['api:single'])]
public ?float $priceOutbound = null;
#[Groups(['api:single'])]
public ?float $priceInbound = null;
#[Groups(['api:booking'])] #[Groups(['api:booking'])]
public array $mapping = []; public array $mapping = [];
@@ -90,4 +96,75 @@ class Pickup
return sprintf('%s (€%s)', $label, number_format($this->price, 2, ',', '.')); 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;
}
} }
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
class ContactFormResponse class RegistrationResponse
{ {
public function __construct( public function __construct(
public readonly int $addressId, public readonly int $addressId,
+2 -2
View File
@@ -84,10 +84,10 @@ class Travel
public bool $transportationServicesMutable = true; public bool $transportationServicesMutable = true;
#[Groups(['api:single'])] #[Groups(['api:single'])]
public array $pickupsOutbound = []; public array $pickups = [];
#[Groups(['api:single'])] #[Groups(['api:single'])]
public array $pickupsInbound = []; public array $dropOffs = [];
#[Groups(['api:single'])] #[Groups(['api:single'])]
public bool $pickupsMutable = true; public bool $pickupsMutable = true;
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\Contract;
use App\Form\Model\ParticipantDto;
/**
* Defines the contract for participant status evaluation rules.
*
* Status rules are evaluated in priority order to determine the appropriate
* status code for a participant during booking creation. The first rule that
* matches (evaluate returns true) determines the participant's status.
*/
interface ParticipantStatusRuleInterface
{
/**
* Evaluates whether this rule applies to the given participant.
*
* @param ParticipantDto $participant The participant to evaluate
*
* @return bool True if this rule applies, false otherwise
*/
public function evaluate(ParticipantDto $participant): bool;
/**
* Returns the status code to assign when this rule matches.
*
* @return string The status code (e.g., 'O' for Option, 'F' for Final)
*/
public function getStatus(): string;
/**
* Returns the priority of this rule.
*
* Higher priority rules are evaluated first. Rules with the same priority
* are evaluated in registration order.
*
* @return int The priority value (higher = evaluated first)
*/
public function getPriority(): int;
/**
* Returns a human-readable description of this rule.
*
* Used for debugging and logging purposes.
*
* @return string Description of what this rule checks
*/
public function getDescription(): string;
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service;
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
use App\Form\Model\ParticipantDto;
/**
* Registry for participant status evaluation rules.
*
* Manages a collection of status rules and evaluates them in priority order
* to determine the appropriate status code for a participant during booking
* creation. Returns a default status of 'F' (Final) when no rules match.
*/
class ParticipantStatusRuleRegistry
{
private const DEFAULT_STATUS = 'F';
/**
* @var ParticipantStatusRuleInterface[]
*/
private array $sortedRules;
/**
* @param ParticipantStatusRuleInterface[] $rules The rules to register
*/
public function __construct(array $rules)
{
$this->sortedRules = $this->sortRulesByPriority($rules);
}
/**
* Evaluates all rules to determine the participant's status.
*
* Rules are evaluated in priority order (highest first). The first rule
* that matches determines the status. Returns 'F' if no rules match.
*
* @param ParticipantDto $participant The participant to evaluate
*
* @return string The determined status code
*/
public function evaluateStatus(ParticipantDto $participant): string
{
foreach ($this->sortedRules as $rule) {
if (true === $rule->evaluate($participant)) {
return $rule->getStatus();
}
}
return self::DEFAULT_STATUS;
}
/**
* Sorts rules by priority in descending order.
*
* @param ParticipantStatusRuleInterface[] $rules The rules to sort
*
* @return ParticipantStatusRuleInterface[] The sorted rules
*/
private function sortRulesByPriority(array $rules): array
{
usort($rules, static fn (
ParticipantStatusRuleInterface $a,
ParticipantStatusRuleInterface $b,
): int => $b->getPriority() <=> $a->getPriority());
return $rules;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\StatusRule;
use App\BusProNet\Model\Service;
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
use App\Form\Model\ParticipantDto;
/**
* Status rule for participants with chaperon (Begleitperson) services.
*
* Assigns 'Option' status to participants who have selected any service
* containing 'Begleitperson' in its label. This applies to accompanying
* persons whose booking confirmation may depend on the main participant.
*/
class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
{
private const STATUS = 'O';
private const PRIORITY = 100;
private const SEARCH_TERM = 'begleitperson';
/**
* @see ParticipantStatusRuleInterface::evaluate()
*/
public function evaluate(ParticipantDto $participant): bool
{
// Check array services
$arrayServices = [
$participant->additionalServices,
$participant->courses,
$participant->board,
$participant->rentals,
];
foreach ($arrayServices as $services) {
foreach ($services as $service) {
if ($service instanceof Service && 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 ParticipantStatusRuleInterface::getStatus()
*/
public function getStatus(): string
{
return self::STATUS;
}
/**
* @see ParticipantStatusRuleInterface::getPriority()
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* @see ParticipantStatusRuleInterface::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
@@ -1,136 +0,0 @@
<?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
@@ -18,8 +18,8 @@ class DayTimeUtility
return 'abends'; return 'abends';
} elseif ($time >= '20:00' && $time <= '23:59') { } elseif ($time >= '20:00' && $time <= '23:59') {
return 'nachts'; return 'nachts';
} else { }
return null; return null;
} }
}
} }
+1
View File
@@ -15,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class AgencyLoader class AgencyLoader
{ {
public const DEFAULT_AGENCY_CODE = 'INTERN'; public const DEFAULT_AGENCY_CODE = 'INTERN';
public const INTERNAL_AGENCY_CODE = '0004';
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
+6 -1
View File
@@ -36,12 +36,17 @@ class HotelLoader extends AbstractLoader
} }
public function mapCodeToId(string $hotelCode, ?string $filename = 'hotel.xml'): ?int public function mapCodeToId(string $hotelCode, ?string $filename = 'hotel.xml'): ?int
{
return $this->loadByCode($hotelCode, $filename)?->id;
}
public function loadByCode(string $hotelCode, ?string $filename = 'hotel.xml'): ?Hotel
{ {
$hotels = $this->loadAll($filename); $hotels = $this->loadAll($filename);
foreach ($hotels as $hotel) { foreach ($hotels as $hotel) {
if ($hotelCode === $hotel->code) { if ($hotelCode === $hotel->code) {
return $hotel->id; return $hotel;
} }
} }
+72 -8
View File
@@ -61,24 +61,88 @@ class PickupLoader extends AbstractLoader
public function patchPickupsDetails(Travel $travel): void public function patchPickupsDetails(Travel $travel): void
{ {
$travel->pickupsOutbound = $this->patchAndSortPickups($travel->pickupsOutbound); $travel->pickups = $this->patchAndSortPickups($travel->pickups);
$travel->pickupsInbound = $this->patchAndSortPickups($travel->pickupsInbound); $travel->dropOffs = $this->patchAndOrderDropOffs($travel->dropOffs, $travel->pickups);
} }
private function patchAndSortPickups(array $pickups): array private function patchAndSortPickups(array $pickups): array
{ {
foreach ($pickups as $pickupId => $pickup) { $this->enrichPickupDetails($pickups);
$pickupData = $this->loadById($pickupId); uasort($pickups, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
return $pickups;
}
/**
* Enriches drop-off details and determines their order.
*
* When drop-offs carry their own time data, they are sorted chronologically.
* Otherwise the return journey order is derived by reversing the chronologically
* sorted outbound pickup order. Any drop-offs without a matching pickup ID are
* appended at the end.
*
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
* @param array<int, Pickup> $sortedPickups Already sorted pickups indexed by ID
*
* @return array<int, Pickup> Ordered drop-offs
*/
private function patchAndOrderDropOffs(array $dropOffs, array $sortedPickups): array
{
$this->enrichPickupDetails($dropOffs);
if ($this->hasTimeData($dropOffs)) {
uasort($dropOffs, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
return $dropOffs;
}
// Fallback: order drop-offs in reverse of the sorted pickup route
$reversedPickupIds = array_reverse(array_keys($sortedPickups));
$ordered = [];
foreach ($reversedPickupIds as $id) {
if (isset($dropOffs[$id])) {
$ordered[$id] = $dropOffs[$id];
}
}
// Append any drop-offs not present in pickups
foreach ($dropOffs as $id => $dropOff) {
if (false === isset($ordered[$id])) {
$ordered[$id] = $dropOff;
}
}
return $ordered;
}
/**
* @param array<int, Pickup> $pickups
*/
private function hasTimeData(array $pickups): bool
{
foreach ($pickups as $pickup) {
if (null !== $pickup->time) {
return true;
}
}
return false;
}
/**
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
*/
private function enrichPickupDetails(array $pickups): void
{
foreach ($pickups as $pickup) {
$pickupData = $this->loadById($pickup->id);
$pickup->code = $pickupData->code; $pickup->code = $pickupData->code;
$pickup->postalCode = $pickupData->postalCode; $pickup->postalCode = $pickupData->postalCode;
$pickup->city = $pickupData->city; $pickup->city = $pickupData->city;
$pickup->street = $pickupData->street; $pickup->street = $pickupData->street;
} }
uasort($pickups, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
return $pickups;
} }
private function comparePickupsByTime(Pickup $a, Pickup $b): int private function comparePickupsByTime(Pickup $a, Pickup $b): int
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\AgeConstraintResult; use App\BusProNet\Model\AgeConstraintResult;
use App\BusProNet\XmlParser\Contract\AgeConstraintParserInterface;
/** /**
* Registry for managing and executing age constraint parsers. * Registry for managing and executing age constraint parsers.
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\AgeConstraintResult; use App\BusProNet\Model\AgeConstraintResult;
use App\BusProNet\XmlParser\Contract\AgeConstraintParserInterface;
/** /**
* Parser for birth year constraint data in JG:YYYY-YYYY format. * Parser for birth year constraint data in JG:YYYY-YYYY format.
+5 -4
View File
@@ -91,11 +91,12 @@ class BookingParser extends AbstractParser
$pickupsData = $node->filterXPath('//zustiege/zustieg'); $pickupsData = $node->filterXPath('//zustiege/zustieg');
if (0 < $pickupsData->count()) { if (0 < $pickupsData->count()) {
$booking->pickupsOutbound = $this->pickupsParser->parse($pickupsData); $booking->pickups = $this->pickupsParser->parse($pickupsData);
} }
$pickupsData = $node->filterXPath('//zustiege_rueck/zustieg_rueck');
if (0 < $pickupsData->count()) { $dropOffsData = $node->filterXPath('//ausstiege/ausstieg');
$booking->pickupsInbound = $this->pickupsParser->parse($pickupsData); if (0 < $dropOffsData->count()) {
$booking->dropOffs = $this->pickupsParser->parse($dropOffsData);
} }
$surchargesData = $node->filterXPath('//zuschlaege/zuschlag'); $surchargesData = $node->filterXPath('//zuschlaege/zuschlag');
@@ -33,7 +33,7 @@ class BookingResponseParser extends AbstractParser
public function parse(Crawler $node): BookingResponse public function parse(Crawler $node): BookingResponse
{ {
$status = $node->filterXPath('//buchung')->text(); $status = $node->filterXPath('//buchung')->text();
$transactionNumber = $this->getStringOrNullValue($node->filterXPath('//vorgang')); $bookingNumber = $this->getIntOrNullValue($node->filterXPath('//vorgang'));
$totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis')); $totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis'));
$message = $this->getStringOrNullValue($node->filterXPath('//hinweis')); $message = $this->getStringOrNullValue($node->filterXPath('//hinweis'));
@@ -42,7 +42,7 @@ class BookingResponseParser extends AbstractParser
return new BookingResponse( return new BookingResponse(
status: $status, status: $status,
transactionNumber: $transactionNumber, bookingNumber: $bookingNumber,
priceItems: $priceItems, priceItems: $priceItems,
totalPrice: $totalPrice, totalPrice: $totalPrice,
paymentTerms: $paymentTerms, paymentTerms: $paymentTerms,
@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\ContactFormResponse; use App\BusProNet\Model\RegistrationResponse;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
class ContactFormResponseParser extends AbstractParser class ContactFormResponseParser extends AbstractParser
{ {
public function parse(Crawler $node): ContactFormResponse public function parse(Crawler $node): RegistrationResponse
{ {
$addressId = (int) $node->filterXPath('//idadresse')->text(); $addressId = (int) $node->filterXPath('//idadresse')->text();
$personId = (int) $node->filterXPath('//idperson')->text(); $personId = (int) $node->filterXPath('//idperson')->text();
$isNewRecord = $this->getBoolValue($node->filterXPath('//neuanlage')); $isNewRecord = $this->getBoolValue($node->filterXPath('//neuanlage'));
return new ContactFormResponse( return new RegistrationResponse(
addressId: $addressId, addressId: $addressId,
personId: $personId, personId: $personId,
isNewRecord: $isNewRecord, isNewRecord: $isNewRecord,
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser\Contract;
use App\BusProNet\Model\AgeConstraintResult; use App\BusProNet\Model\AgeConstraintResult;
+98 -6
View File
@@ -24,6 +24,13 @@ class TravelParser extends AbstractParser
{ {
private AgeConstraintParserRegistry $ageConstraintRegistry; private AgeConstraintParserRegistry $ageConstraintRegistry;
/**
* @var array<string>
*/
private array $ignoredRoomCodes = [
Constants::PSEUDO_PRICE_ROOM_CODE,
];
public function __construct() public function __construct()
{ {
$this->ageConstraintRegistry = new AgeConstraintParserRegistry(); $this->ageConstraintRegistry = new AgeConstraintParserRegistry();
@@ -69,8 +76,12 @@ class TravelParser extends AbstractParser
$travel->transportationServices = $this $travel->transportationServices = $this
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung')); ->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
$travel->rooms = $this->getRooms($hotelNode); $travel->rooms = $this->getRooms($hotelNode);
$travel->pickupsOutbound = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom); $travel->pickups = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom, true);
$travel->pickupsInbound = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo); $travel->dropOffs = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo, false);
// Merge drop-off prices into pickups for split pricing support
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
$travel->guide = $this->getGuide($node); $travel->guide = $this->getGuide($node);
return $travel; return $travel;
@@ -249,14 +260,16 @@ class TravelParser extends AbstractParser
* *
* @param Crawler $node The XML node containing pickup data * @param Crawler $node The XML node containing pickup data
* @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only) * @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only)
* @param bool $isOutbound Whether these are outbound pickups (for split pricing)
* *
* @return array<int, Pickup> Array of pickup locations indexed by ID * @return array<int, Pickup> Array of pickup locations indexed by ID
*/ */
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null, bool $isOutbound = true): array
{ {
$pickups = []; $pickups = [];
$timeOnlyIds = [];
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) { $node->each(function (Crawler $pickupNode) use (&$pickups, &$timeOnlyIds, $defaultDate, $isOutbound) {
$pickupId = (int) $pickupNode->attr('idbuspro'); $pickupId = (int) $pickupNode->attr('idbuspro');
$pickup = new Pickup(); $pickup = new Pickup();
@@ -264,14 +277,28 @@ class TravelParser extends AbstractParser
$pickup->price = $pickupNode->attr('preis') ? $pickup->price = $pickupNode->attr('preis') ?
$this->stringToFloat($pickupNode->attr('preis')) : null; $this->stringToFloat($pickupNode->attr('preis')) : null;
// Set direction-specific price for split pricing support
if ($isOutbound) {
$pickup->priceOutbound = $pickup->price;
} else {
$pickup->priceInbound = $pickup->price;
}
// parse date and time only for direction 'to' indicated by provided default date // parse date and time only for direction 'to' indicated by provided default date
if (null !== $defaultDate) { if (null !== $defaultDate) {
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate); $timeValue = $pickupNode->attr('zeit');
$pickup->time = $this->stringToDateTimeFuzzy($timeValue, $defaultDate);
if (null !== $timeValue && 1 === preg_match('/^\d{2}:\d{2}$/', $timeValue)) {
$timeOnlyIds[] = $pickupId;
}
} }
$pickups[$pickupId] = $pickup; $pickups[$pickupId] = $pickup;
}); });
$this->adjustOvernightTimes($pickups, $timeOnlyIds);
return $pickups; return $pickups;
} }
@@ -319,11 +346,17 @@ class TravelParser extends AbstractParser
$rooms = []; $rooms = [];
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) { $roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
$roomCode = $roomNode->attr('zimmercode');
if (true === in_array($roomCode, $this->ignoredRoomCodes, true)) {
return;
}
$roomId = (int) $roomNode->attr('idbuspro_zimmer'); $roomId = (int) $roomNode->attr('idbuspro_zimmer');
$room = new Room(); $room = new Room();
$room->id = $roomId; $room->id = $roomId;
$room->code = $roomNode->attr('zimmercode'); $room->code = $roomCode;
$room->category = $roomNode->attr('kat'); $room->category = $roomNode->attr('kat');
$room->boardId = (int) $roomNode->attr('idbuspro_vp'); $room->boardId = (int) $roomNode->attr('idbuspro_vp');
$room->label = $roomNode->attr('zimmertext'); $room->label = $roomNode->attr('zimmertext');
@@ -395,4 +428,63 @@ class TravelParser extends AbstractParser
$service->rawAgeConstraintData = $constraintData; $service->rawAgeConstraintData = $constraintData;
} }
} }
/**
* Merges drop-off prices into pickups for split pricing support.
*
* For each pickup, finds the matching drop-off by ID and copies
* the inbound price to the pickup's priceInbound property. This enables
* split pricing calculations where costs are distributed between outbound
* and inbound transportation.
*
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
*/
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
{
foreach ($pickups as $pickupId => $pickup) {
if (isset($dropOffs[$pickupId])) {
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
}
}
}
/**
* Adjusts time-only pickup entries for overnight bus routes.
*
* When a bus route crosses midnight, time-only entries (e.g. "01:00") are
* initially stamped with the departure date. This method detects overnight
* scenarios by comparing time-only entries against the latest full-datetime
* entry and adds one day where needed.
*
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int> $timeOnlyIds IDs of pickups parsed from time-only values
*/
private function adjustOvernightTimes(array $pickups, array $timeOnlyIds): void
{
if (true === empty($timeOnlyIds)) {
return;
}
// Find latest datetime among full-datetime entries
$latestFullDateTime = null;
foreach ($pickups as $id => $pickup) {
if (null !== $pickup->time && false === in_array($id, $timeOnlyIds, true)) {
if (null === $latestFullDateTime || $pickup->time > $latestFullDateTime) {
$latestFullDateTime = $pickup->time;
}
}
}
if (null === $latestFullDateTime) {
return;
}
// Adjust time-only entries that fall before the latest full-datetime
foreach ($timeOnlyIds as $id) {
if (isset($pickups[$id]) && null !== $pickups[$id]->time && $pickups[$id]->time < $latestFullDateTime) {
$pickups[$id]->time = $pickups[$id]->time->modify('+1 day');
}
}
}
} }
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;
#[AsCommand(
name: 'app:bpn:fetch-travel',
description: 'Fetches travel data directly from the BusProNet API and saves it to the XML export directory'
)]
class BpnFetchTravelCommand extends Command
{
private const CACHE_KEYS_TO_INVALIDATE = [
'bpn_travels_mapping',
];
public function __construct(
private readonly ApiClient $apiClient,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('travel-id', InputArgument::REQUIRED, 'The travel product ID to fetch')
->addOption('filename', 'f', InputOption::VALUE_REQUIRED, 'Output filename (default: Ziel_{travel-id}.xml)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and display info without saving');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$travelId = (int) $input->getArgument('travel-id');
$filename = $input->getOption('filename') ?? sprintf('Ziel_%d.xml', $travelId);
$dryRun = $input->getOption('dry-run');
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
try {
$xml = $this->apiClient->getTravelDataXml($travelId);
} catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage()));
$this->logger->error('Failed to fetch travel data from API', [
'travelId' => $travelId,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
$io->text(sprintf('Received %d bytes of XML data', strlen($xml)));
if ($dryRun) {
$io->note('Dry-run mode: not saving to file');
$io->text($xml);
return Command::SUCCESS;
}
try {
$this->xmlExport->write($filename, $xml);
} catch (FilesystemException $e) {
$io->error(sprintf('Failed to write file: %s', $e->getMessage()));
$this->logger->error('Failed to write travel XML to file', [
'travelId' => $travelId,
'filename' => $filename,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
$this->invalidateCaches();
$io->success(sprintf('Saved to %s and invalidated travel mapping cache', $filename));
$this->logger->info('Travel data fetched and saved', [
'travelId' => $travelId,
'filename' => $filename,
'bytes' => strlen($xml),
]);
return Command::SUCCESS;
}
private function invalidateCaches(): void
{
foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) {
$this->cache->delete($key);
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BpnXmlAnonymizer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn:xml-anonymize',
description: 'Anonymize personal data in BPN XML request/response dumps',
)]
class BpnXmlAnonymizeCommand extends Command
{
public function __construct(
private readonly BpnXmlAnonymizer $xmlAnonymizer,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file')
->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inputFile = (string) $input->getArgument('infile');
$outputFile = $input->getArgument('outfile');
if (false === is_file($inputFile)) {
$io->error(sprintf('Input file not found: %s', $inputFile));
return Command::FAILURE;
}
if (false === is_readable($inputFile)) {
$io->error(sprintf('Input file is not readable: %s', $inputFile));
return Command::FAILURE;
}
$targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
$this->xmlAnonymizer->resetState();
$processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false);
if (Command::SUCCESS !== $processingResult) {
return $processingResult;
}
$pairedInputFile = $this->derivePairedFilename($inputFile);
if (null !== $pairedInputFile && true === is_file($pairedInputFile)) {
if (false === is_readable($pairedInputFile)) {
$io->error(sprintf('Paired file is not readable: %s', $pairedInputFile));
return Command::FAILURE;
}
$pairedTargetFile = $this->derivePairedOutputFilename($targetFile, $inputFile, $pairedInputFile);
if (null === $pairedTargetFile) {
$io->error(sprintf(
'Unable to derive paired output filename from: %s',
$targetFile
));
return Command::FAILURE;
}
$processingResult = $this->anonymizeFile($io, $pairedInputFile, $pairedTargetFile, true);
if (Command::SUCCESS !== $processingResult) {
return $processingResult;
}
$io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile));
}
$io->success(sprintf(
'Anonymized %d persons from %s to %s',
$this->xmlAnonymizer->getLastIdentityCount(),
$inputFile,
$targetFile,
));
return Command::SUCCESS;
}
private function anonymizeFile(SymfonyStyle $io, string $inputFile, string $targetFile, bool $keepState): int
{
$xml = file_get_contents($inputFile);
if (false === $xml) {
$io->error(sprintf('Unable to read input file: %s', $inputFile));
return Command::FAILURE;
}
try {
$anonymizedXml = $this->xmlAnonymizer->anonymize($xml, false === $keepState);
} catch (\InvalidArgumentException|\RuntimeException $exception) {
$io->error(sprintf('Failed to anonymize %s: %s', $inputFile, $exception->getMessage()));
return Command::FAILURE;
}
$writeResult = file_put_contents($targetFile, $anonymizedXml);
if (false === $writeResult) {
$io->error(sprintf('Unable to write output file: %s', $targetFile));
return Command::FAILURE;
}
return Command::SUCCESS;
}
private function derivePairedFilename(string $filename): ?string
{
if (str_ends_with($filename, '_request.xml')) {
return substr($filename, 0, -12).'_response.xml';
}
if (str_ends_with($filename, '_response.xml')) {
return substr($filename, 0, -13).'_request.xml';
}
return null;
}
private function derivePairedOutputFilename(string $outputFile, string $inputFile, string $pairedInputFile): ?string
{
if (str_ends_with($inputFile, '_request.xml') && str_ends_with($pairedInputFile, '_response.xml')) {
if (false === str_ends_with($outputFile, '_request.xml')) {
return null;
}
return substr($outputFile, 0, -12).'_response.xml';
}
if (str_ends_with($inputFile, '_response.xml') && str_ends_with($pairedInputFile, '_request.xml')) {
if (false === str_ends_with($outputFile, '_response.xml')) {
return null;
}
return substr($outputFile, 0, -13).'_request.xml';
}
return null;
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\BusProNet\Model\Booking;
use App\Repository\BookingEditDraftRepository;
use App\Service\BookingEditDataLoaderService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:draft:backfill-booking-number',
description: 'Backfill booking numbers (vorgang) for existing drafts from API',
)]
class DraftBackfillBookingNumberCommand extends Command
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly BookingEditDataLoaderService $dataLoader,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be updated without actually updating');
}
/**
* @see Command::execute()
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$drafts = $this->draftRepository->findBy(['bookingNumber' => null]);
if (0 === \count($drafts)) {
$io->success('No drafts without booking number found.');
return Command::SUCCESS;
}
$io->title(sprintf('Processing %d draft(s)', \count($drafts)));
$updated = 0;
$failed = 0;
$skipped = 0;
foreach ($drafts as $draft) {
$bookingId = $draft->getBookingId();
$user = $draft->getUser();
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (!$bookingData instanceof Booking) {
$io->warning(sprintf('Could not fetch booking %d for user %s', $bookingId, $user->getEmail()));
++$failed;
continue;
}
// bookingNumber maps to BPN XML vorgang
$bookingNumber = $bookingData->bookingNumber;
if (null === $bookingNumber) {
$io->warning(sprintf('No booking number (vorgang) found for booking %d', $bookingId));
++$skipped;
continue;
}
if ($dryRun) {
$io->text(sprintf(
'[DRY RUN] Would set booking number %d for draft %d (bookingId %d)',
$bookingNumber,
$draft->getId(),
$bookingId
));
} else {
$draft->setBookingNumber($bookingNumber);
}
++$updated;
}
if (false === $dryRun) {
$this->entityManager->flush();
}
$io->newLine();
$io->table(
['Status', 'Count'],
[
['Updated', $updated],
['Failed (API error)', $failed],
['Skipped (no booking number)', $skipped],
]
);
if ($dryRun) {
$io->note('Dry run - no changes were made.');
} else {
$io->success(sprintf('Updated %d draft(s).', $updated));
}
return Command::SUCCESS;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\BookingEditDraftRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:draft:cleanup',
description: 'Delete booking edit drafts for departed travels',
)]
class DraftCleanupCommand extends Command
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting');
}
/**
* @see Command::execute()
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
if ($dryRun) {
$expiredDrafts = $this->draftRepository->createQueryBuilder('d')
->where('d.travelDate < :today')
->setParameter('today', new \DateTimeImmutable('today'))
->getQuery()
->getResult();
$count = \count($expiredDrafts);
if (0 === $count) {
$io->success('No expired drafts found.');
return Command::SUCCESS;
}
$io->note(sprintf('[DRY RUN] Would delete %d expired draft(s)', $count));
return Command::SUCCESS;
}
$deletedCount = $this->draftRepository->deleteExpiredDrafts();
if (0 === $deletedCount) {
$io->success('No expired drafts found.');
return Command::SUCCESS;
}
$this->logger->info('Deleted expired booking edit drafts', [
'count' => $deletedCount,
]);
$io->success(sprintf('Deleted %d expired draft(s).', $deletedCount));
return Command::SUCCESS;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\BookingEditDraftRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:draft:inspect',
description: 'Inspect booking edit drafts in the database',
)]
class DraftInspectCommand extends Command
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Show only drafts older than N days', null)
->addOption('booking-id', 'b', InputOption::VALUE_OPTIONAL, 'Filter by booking ID', null)
->addOption('delete', null, InputOption::VALUE_NONE, 'Delete the listed drafts (use with caution)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$days = $input->getOption('days');
$bookingId = $input->getOption('booking-id');
$shouldDelete = $input->getOption('delete');
$qb = $this->draftRepository->createQueryBuilder('d')
->join('d.user', 'u')
->select('d', 'u')
->orderBy('d.updatedAt', 'DESC');
if (null !== $days) {
$cutoffDate = new \DateTimeImmutable(sprintf('-%d days', (int) $days));
$qb->andWhere('d.updatedAt < :cutoff')
->setParameter('cutoff', $cutoffDate);
}
if (null !== $bookingId) {
$qb->andWhere('d.bookingId = :bookingId')
->setParameter('bookingId', (int) $bookingId);
}
$drafts = $qb->getQuery()->getResult();
if (0 === \count($drafts)) {
$io->success('No drafts found matching the criteria.');
return Command::SUCCESS;
}
$io->title(sprintf('Found %d draft(s)', \count($drafts)));
$rows = [];
foreach ($drafts as $draft) {
$formData = $draft->getFormData();
$participantCount = isset($formData['participants']) ? \count($formData['participants']) : 0;
$rows[] = [
$draft->getId(),
$draft->getBookingId(),
$draft->getUser()->getEmail(),
$draft->getTravelDate()->format('Y-m-d'),
$draft->getCreatedAt()->format('Y-m-d H:i:s'),
$draft->getUpdatedAt()->format('Y-m-d H:i:s'),
$this->getAgeDays($draft->getUpdatedAt()),
$participantCount,
];
}
$io->table(
['ID', 'Booking ID', 'User Email', 'Travel Date', 'Created At', 'Updated At', 'Days Old', 'Participants'],
$rows
);
$io->section('Investigation Tips');
$io->listing([
'Check logs for "Booking update successful" with matching booking_id after the draft\'s updated_at',
'If a successful update exists after draft creation, the draft should have been deleted (potential bug)',
'Drafts are preserved when: user cancels, API rejects submission, or user simply leaves',
]);
if ($shouldDelete) {
if (false === $io->confirm(sprintf('Are you sure you want to delete %d draft(s)?', \count($drafts)), false)) {
$io->warning('Deletion cancelled.');
return Command::SUCCESS;
}
$em = $this->draftRepository->getEntityManager();
foreach ($drafts as $draft) {
$em->remove($draft);
}
$em->flush();
$io->success(sprintf('Deleted %d draft(s).', \count($drafts)));
}
return Command::SUCCESS;
}
private function getAgeDays(\DateTimeImmutable $date): string
{
$now = new \DateTimeImmutable();
$diff = $now->diff($date);
if ($diff->days > 0) {
return sprintf('%d days', $diff->days);
}
if ($diff->h > 0) {
return sprintf('%d hours', $diff->h);
}
return sprintf('%d min', $diff->i);
}
}
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Controller\Account; namespace App\Controller\Account;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
@@ -9,6 +11,9 @@ use App\BusProNet\Model\PersonalData;
use App\Entity\User; use App\Entity\User;
use App\Form\PersonalDataType; use App\Form\PersonalDataType;
use App\Security\Crypt; use App\Security\Crypt;
use App\Service\BookingEditDataLoaderService;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -25,14 +30,22 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*/ */
class PersonalDataController extends AbstractController class PersonalDataController extends AbstractController
{ {
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/** /**
* @param ApiClient $apiClient BusProNet API client for data operations * @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling * @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param LoggerInterface $logger Logger for audit trails and debugging * @param LoggerInterface $logger Logger for audit trails and debugging
*/ */
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly BookingEditDataLoaderService $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -87,10 +100,29 @@ class PersonalDataController extends AbstractController
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) { if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
try { try {
$this->apiClient->updatePersonalData($email, $password, $personalData); $this->apiClient->updatePersonalData($email, $password, $personalData);
// Invalidate cached bookings to ensure edit mode shows updated applicant data
$this->dataLoader->invalidateUserBookingCaches($user);
// Update profile completeness flag on user entity
$isComplete = $this->completenessChecker->isComplete($personalData);
$user->setProfileComplete($isComplete);
$this->entityManager->flush();
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert'); $this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
$this->logger->info('Updated personal data', [ $this->logger->info('Updated personal data', [
'email' => $user->getEmail(), 'email' => $user->getEmail(),
]); ]);
// Handle profile completion redirect
$session = $request->getSession();
$redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
if (null !== $redirectUrl && true === $isComplete) {
$session->remove(self::SESSION_REDIRECT_KEY);
return $this->redirect($redirectUrl);
}
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage()); $this->addFlash('error', $e->getMessage());
} }
@@ -130,7 +162,18 @@ class PersonalDataController extends AbstractController
->getPersonalData($email, $password); ->getPersonalData($email, $password);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden'); $this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
$personalData = new PersonalData();
return $this->redirectToRoute('app_personal_data');
}
if ($personalData instanceof Notification) {
$this->logger->error('Unable to fetch personal data', [
'code' => $personalData->code,
'error' => $personalData->message,
]);
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
return $this->redirectToRoute('app_personal_data');
} }
$personalData->communication->newsletter = !$personalData->communication->newsletter; $personalData->communication->newsletter = !$personalData->communication->newsletter;
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Admin\Field\JsonDataField;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Service\BookingExportService;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingEditDraftCrudController extends AbstractCrudController
{
public function __construct(
private readonly BookingExportService $exportService,
private readonly AdminUrlGenerator $adminUrlGenerator,
) {
}
public static function getEntityFqcn(): string
{
return BookingEditDraft::class;
}
public function configureActions(Actions $actions): Actions
{
$exportAction = Action::new('exportExcel', 'Excel Export', 'fa fa-file-excel')
->linkToRoute(
'admin_booking_draft_export',
static fn (BookingEditDraft $entity): array => ['id' => $entity->getId()]
)
->displayIf(static fn (BookingEditDraft $entity): bool => $entity->hasExportData());
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $exportAction)
->add(Crud::PAGE_DETAIL, $exportAction)
->remove(Crud::PAGE_INDEX, Action::NEW)
->remove(Crud::PAGE_INDEX, Action::EDIT)
->remove(Crud::PAGE_DETAIL, Action::EDIT)
;
}
#[Route('/admin/booking-draft/{id}/export', name: 'admin_booking_draft_export', requirements: ['id' => '\d+'])]
public function export(BookingEditDraft $draft): Response
{
if (false === $draft->hasExportData()) {
$this->addFlash('danger', 'Export nicht möglich: Reisedaten fehlen');
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
try {
return $this->exportService->createExportResponse($draft);
} catch (\RuntimeException $e) {
$this->addFlash('danger', 'Export fehlgeschlagen: '.$e->getMessage());
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Buchungsentwurf')
->setEntityLabelInPlural('Buchungsentwürfe')
->setDefaultSort(['createdAt' => 'DESC']);
}
public function configureFields(string $pageName): iterable
{
return [
IntegerField::new('bookingNumber', 'Vorgang'),
AssociationField::new('user', 'Kundenaccount')->formatValue(fn (User $user) => $user->getEmail()),
DateTimeField::new('createdAt', 'erstellt am'),
DateTimeField::new('updatedAt', 'aktualisiert am'),
JsonDataField::new('formData', 'Daten')->onlyOnDetail(),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Controller\Admin;
use App\Entity\BookingEditDraft;
use App\Entity\LogEntry;
use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Config\UserMenu;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\UserInterface;
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
class DashboardController extends AbstractDashboardController
{
public function index(): Response
{
return $this->render('admin/dashboard/index.html.twig');
}
public function configureDashboard(): Dashboard
{
return Dashboard::new()
->setTitle('MyE&P')
->setLocales(['de'])
->setFaviconPath('build/favicon/icon.svg')
;
}
public function configureUserMenu(UserInterface $user): UserMenu
{
return parent::configureUserMenu($user)
->addMenuItems([
MenuItem::linkToRoute('MyE&P', 'fa fa-user', 'app_account'),
]);
}
public function configureMenuItems(): iterable
{
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
yield MenuItem::linkToCrud('Log', 'fa fa-list', LogEntry::class);
yield MenuItem::linkToCrud('Buchungsentwürfe', 'fa fa-pen-to-square', BookingEditDraft::class);
yield MenuItem::linkToCrud('Benutzeraccounts', 'fa fa-users', User::class);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\LogEntry;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
class LogEntryCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return LogEntry::class;
}
public function configureActions(Actions $actions): Actions
{
$viewDumps = Action::new('viewDumps', 'XML Dumps', 'fa fa-file-code')
->linkToRoute('admin_xml_dump_list', static fn (LogEntry $entity): array => ['id' => $entity->getId()])
->displayIf(static fn (LogEntry $entity): bool => 'bpn' === $entity->getChannel());
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $viewDumps)
->add(Crud::PAGE_DETAIL, $viewDumps)
->remove(Crud::PAGE_INDEX, Action::NEW)
->remove(Crud::PAGE_INDEX, Action::EDIT)
->remove(Crud::PAGE_INDEX, Action::DELETE)
->remove(Crud::PAGE_DETAIL, Action::EDIT)
->remove(Crud::PAGE_DETAIL, Action::DELETE)
;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Logeintrag')
->setEntityLabelInPlural('Logeinträge')
->setDefaultSort(['createdAt' => 'DESC']);
}
public function configureFields(string $pageName): iterable
{
return [
DateTimeField::new('createdAt', 'Zeitstempel'),
TextField::new('errorCode', 'Fehlercode'),
TextField::new('username', 'Benutzer'),
TextField::new('message', 'Aktion'),
TextField::new('requestId', 'Request ID'),
TextField::new('uri', 'URI'),
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Controller\Admin;
use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
class UserCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return User::class;
}
public function configureActions(Actions $actions): Actions
{
return $actions
->remove(Crud::PAGE_INDEX, Action::NEW)
->remove(Crud::PAGE_INDEX, Action::EDIT)
->remove(Crud::PAGE_INDEX, Action::DELETE)
;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Benutzeraccount')
->setEntityLabelInPlural('Benutzeraccounts')
->setDefaultSort(['lastLoginAt' => 'DESC']);
}
public function configureFields(string $pageName): iterable
{
return [
TextField::new('email', 'E-Mail'),
DateTimeField::new('lastLoginAt', 'letzter Login'),
];
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Repository\LogEntryRepository;
use App\Service\XmlDumpService;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/xml-dump')]
#[IsGranted('ROLE_ADMIN')]
class XmlDumpController extends AbstractController
{
public function __construct(
private readonly XmlDumpService $xmlDumpService,
private readonly LogEntryRepository $logEntryRepository,
) {
}
#[Route('/{id}', name: 'admin_xml_dump_list', methods: ['GET'])]
public function list(int $id): Response
{
$logEntry = $this->logEntryRepository->find($id);
if (null === $logEntry) {
throw new NotFoundHttpException('Log entry not found.');
}
if ('bpn' !== $logEntry->getChannel()) {
throw new NotFoundHttpException('XML dumps are only available for BPN channel entries.');
}
$requestId = $logEntry->getRequestId();
$dumps = [];
try {
$dumps = $this->xmlDumpService->findDumpsForRequestId($requestId);
} catch (FilesystemException) {
// Dumps directory may not exist or be inaccessible
}
return $this->render('admin/xml_dump/list.html.twig', [
'logEntry' => $logEntry,
'requestId' => $requestId,
'dumps' => $dumps,
]);
}
#[Route('/download/{filename}', name: 'admin_xml_dump_download', methods: ['GET'], requirements: ['filename' => '.+'])]
public function download(string $filename): Response
{
try {
if (false === $this->xmlDumpService->fileExists($filename)) {
throw new NotFoundHttpException('Dump file not found. It may have been cleaned up.');
}
$content = $this->xmlDumpService->getContent($filename);
} catch (FilesystemException $e) {
throw new NotFoundHttpException('Failed to read dump file: '.$e->getMessage());
}
$response = new Response($content);
$response->headers->set('Content-Type', 'application/xml');
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($filename)
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}
+16 -1
View File
@@ -9,6 +9,7 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\ContactFormSubmission; use App\BusProNet\Model\ContactFormSubmission;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -20,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_API')] #[IsGranted('ROLE_OAUTH2_API')]
class ContactFormController extends AbstractController class ContactFormController extends AbstractController
{ {
public function __construct(private readonly ApiClient $apiClient) public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
{ {
} }
@@ -33,12 +34,21 @@ class ContactFormController extends AbstractController
$result = $this->apiClient->createAddress($personalData); $result = $this->apiClient->createAddress($personalData);
if ($result instanceof Notification) { if ($result instanceof Notification) {
$this->logger->info('Unable to submit contact form', [
'message' => $result->message,
]);
return new JsonResponse([ return new JsonResponse([
'success' => false, 'success' => false,
'message' => $result->message, 'message' => $result->message,
], Response::HTTP_CONFLICT); ], Response::HTTP_CONFLICT);
} }
$this->logger->info('Contact form submitted', [
'addressId' => $result->addressId,
'personId' => $result->personId,
]);
return $this->json([ return $this->json([
'success' => true, 'success' => true,
'addressId' => $result->addressId, 'addressId' => $result->addressId,
@@ -46,6 +56,11 @@ class ContactFormController extends AbstractController
'isNewRecord' => $result->isNewRecord, 'isNewRecord' => $result->isNewRecord,
], Response::HTTP_CREATED); ], Response::HTTP_CREATED);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$this->logger->error('Unable to submit contact form', [
'message' => $e->getMessage(),
'dto' => $dto,
]);
return new JsonResponse([ return new JsonResponse([
'success' => false, 'success' => false,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
@@ -47,7 +47,7 @@ class IndexController extends AbstractController
path: '/bookings/create', path: '/bookings/create',
name: 'app_booking_create', name: 'app_booking_create',
)] )]
public function index(#[MapQueryString] ?BookingQueryParams $params): Response public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response
{ {
if (null === $params) { if (null === $params) {
throw $this->createNotFoundException('Invalid booking parameters provided'); throw $this->createNotFoundException('Invalid booking parameters provided');
@@ -92,9 +92,6 @@ class IndexController extends AbstractController
// Store optional return URL in session (defaults to main EP site) // Store optional return URL in session (defaults to main EP site)
$this->bookingService->storeReturnUrl($request, $params->returnUrl); $this->bookingService->storeReturnUrl($request, $params->returnUrl);
// Store theme for consistent styling throughout the booking flow
$this->bookingService->storeTheme($request, $params->theme);
// Create fresh booking session with the provided parameters // Create fresh booking session with the provided parameters
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); $bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
@@ -106,7 +103,9 @@ class IndexController extends AbstractController
// Redirect to login page (optional authentication before Step 1) // Redirect to login page (optional authentication before Step 1)
return $this->htmxRedirect($request, $this->generateUrl('app_login')); return $this->htmxRedirect($request, $this->generateUrl('app_login'));
} catch (TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException) { } catch (TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
$this->addFlash('error', $e->getMessage());
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_error')); return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_error'));
} }
} }
@@ -110,8 +110,6 @@ class Step1Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
// Get complete summary data (pricing, rooms, CMS data) // Get complete summary data (pricing, rooms, CMS data)
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $availableRooms = $bookingCreateDto->travel->getAvailableRooms();
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create; namespace App\Controller\Booking\Create;
use App\BusProNet\Constants;
use App\Controller\Booking\Traits\BookingCreateTrait; use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Controller\Booking\Traits\ParticipantCardFlowTrait; use App\Controller\Booking\Traits\ParticipantCardFlowTrait;
use App\Form\BookingCreateStep2Type; use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Service\DummyDataFillService;
use App\Form\Service\ParticipantFieldOptionsProvider; use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\BookingService; use App\Service\BookingService;
@@ -18,6 +20,7 @@ use App\Service\ParticipantPrepopulationService;
use App\Service\RoomAssignmentService; use App\Service\RoomAssignmentService;
use App\Service\TravelDataService; use App\Service\TravelDataService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -43,6 +46,7 @@ class Step2Controller extends AbstractController
private readonly ParticipantCardDataService $participantCardService, private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider,
private readonly ParticipantPrepopulationService $prepopulationService, private readonly ParticipantPrepopulationService $prepopulationService,
private readonly DummyDataFillService $dummyDataFillService,
) { ) {
} }
@@ -146,6 +150,27 @@ class Step2Controller extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
// Detect dummy data fill token — render pre-filled form immediately, skipping validation
$isDummyDataFill = $this
->dummyDataFillService
->isTokenMatch($bookingDto->participants[$index], $bookingDto->getMode())
;
if (true === $form->isSubmitted() && true === $isDummyDataFill) {
$this->dummyDataFillService->fill($bookingDto->participants[$index], $index);
// Keep behavior consistent with cards view: newly filled participant data
// (especially dateOfBirth) must immediately trigger mandatory service preselection.
$this->bookingService->preselectMandatoryServices($bookingDto);
$bookingDto->bookingStatus = Constants::BOOKING_STATUS_OPEN;
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
// Recreate form with filled DTO so the view shows the dummy data
$form = $this->createParticipantForm($bookingDto, $index);
return $this->renderParticipantForm($form, $index, $bookingDto);
}
// Collect notifications from field handlers (run during PRE_SUBMIT) // Collect notifications from field handlers (run during PRE_SUBMIT)
$notifications = $this->collectAndClearNotifications($bookingDto); $notifications = $this->collectAndClearNotifications($bookingDto);
@@ -160,18 +185,7 @@ class Step2Controller extends AbstractController
return $this->redirectToRoute('app_booking_create_step_2'); return $this->redirectToRoute('app_booking_create_step_2');
} }
// Get complete summary data (pricing, rooms, CMS data) return $this->renderParticipantForm($form, $index, $bookingDto);
$summaryData = $this->summaryDataService->getSummaryData($bookingDto);
$templateData = [
'form' => $form->createView(),
'participantIndex' => $index,
'bookingDto' => $bookingDto,
'summaryData' => $summaryData,
'refreshRouteName' => 'app_booking_create_step_2_participant_refresh',
];
return $this->render('booking/create/step_2_participant.html.twig', $templateData);
} }
/** /**
@@ -207,4 +221,18 @@ class Step2Controller extends AbstractController
'app_booking_create_step_2_participant_refresh' 'app_booking_create_step_2_participant_refresh'
); );
} }
private function renderParticipantForm(
FormInterface $form,
int $index,
BookingDto $bookingDto,
): Response {
return $this->render('booking/create/step_2_participant.html.twig', [
'form' => $form->createView(),
'participantIndex' => $index,
'bookingDto' => $bookingDto,
'summaryData' => $this->summaryDataService->getSummaryData($bookingDto),
'refreshRouteName' => 'app_booking_create_step_2_participant_refresh',
]);
}
} }
@@ -75,7 +75,7 @@ class Step3Controller extends AbstractController
return $this->handleApiError( return $this->handleApiError(
'Booking inquiry failed', 'Booking inquiry failed',
['message' => $inquiryResponse->message], ['message' => $inquiryResponse->message],
$inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', $inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuche es erneut oder wende dich an den Kundenservice.',
$bookingCreateDto, $bookingCreateDto,
$form $form
); );
@@ -115,24 +115,28 @@ class Step3Controller extends AbstractController
} }
// Validate price match (rounded to cent precision to avoid floating-point errors) // Validate price match (rounded to cent precision to avoid floating-point errors)
// API gesamtpreis includes promotional/goodwill voucher discounts (negative price items), // API gesamtpreis includes:
// but NOT purchase vouchers (those reduce restzahlung, not gesamtpreis) // - Promotional/goodwill voucher discounts (negative price items with art=AKTION/KULANZGUTSCHEIN)
// - API-applied automatic discounts (e.g., Gruppenrabatt with art=ERM)
// - NOT purchase vouchers (those reduce restzahlung, not gesamtpreis)
$apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2); $apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2);
$calculatedSubtotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); $calculatedSubtotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2);
$promoGoodwillDiscount = round($inquiryResponse->getVoucherDiscountFromPrices(), 2); $promoGoodwillDiscount = round($inquiryResponse->getVoucherDiscountFromPrices(), 2);
$expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount, 2); $apiAppliedDiscount = round($inquiryResponse->getApiAppliedDiscountTotal(), 2);
$expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount - $apiAppliedDiscount, 2);
if ($apiTotal !== $expectedTotal) { if ((int) round($apiTotal * 100) !== (int) round($expectedTotal * 100)) {
return $this->handleApiError( return $this->handleApiError(
'Price mismatch detected - payload incomplete', 'Price mismatch detected - payload incomplete',
[ [
'apiTotal' => $apiTotal, 'apiTotal' => $apiTotal,
'calculatedSubtotal' => $calculatedSubtotal, 'calculatedSubtotal' => $calculatedSubtotal,
'promoGoodwillDiscount' => $promoGoodwillDiscount, 'promoGoodwillDiscount' => $promoGoodwillDiscount,
'apiAppliedDiscount' => $apiAppliedDiscount,
'expectedTotal' => $expectedTotal, 'expectedTotal' => $expectedTotal,
'difference' => abs($apiTotal - $expectedTotal), 'difference' => abs($apiTotal - $expectedTotal),
], ],
'Ein technischer Fehler ist aufgetreten.', 'Preisabweichung festgestellt. Bitte wende dich an den Kundenservice.',
$bookingCreateDto, $bookingCreateDto,
$form $form
); );
@@ -154,7 +158,7 @@ class Step3Controller extends AbstractController
'exception' => $e->getMessage(), 'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
], ],
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.', 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut oder wende dich an den Kundenservice.',
$bookingCreateDto, $bookingCreateDto,
$form $form
); );
@@ -165,7 +169,7 @@ class Step3Controller extends AbstractController
'exception' => $e->getMessage(), 'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
], ],
'Ein technischer Fehler ist aufgetreten.', 'Ein allgemeiner Fehler ist aufgetreten. Bitte wende dich an den Kundenservice.',
$bookingCreateDto, $bookingCreateDto,
$form $form
); );
@@ -192,8 +196,6 @@ class Step3Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
return $this->renderStepForm($bookingCreateDto, $form); return $this->renderStepForm($bookingCreateDto, $form);
} }
@@ -92,11 +92,20 @@ class Step4Controller extends AbstractController
); );
} }
// Success: Store booking number in flash and clear session // Success: Store booking data in flash for conversion tracking
$this->addFlash('booking_number', $bookingResponse->transactionNumber); $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
$this->addFlash('booking_total', $summaryData->payableAmount);
$this->addFlash('booking_travel_name', $bookingCreateDto->travel->label);
$this->clearTravelDataCache($bookingCreateDto); $this->clearTravelDataCache($bookingCreateDto);
$this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE); $this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE);
$this->logger->info('Booking successfully created.', [
'date_id' => $bookingCreateDto->travel->id,
'hotel_id' => $bookingCreateDto->hotelId,
'booking_number' => $bookingResponse->bookingNumber,
]);
return $this->redirectToRoute('app_booking_create_success'); return $this->redirectToRoute('app_booking_create_success');
} catch (TimeoutException $e) { } catch (TimeoutException $e) {
return $this->handleApiError( return $this->handleApiError(
@@ -156,10 +165,5 @@ class Step4Controller extends AbstractController
// Clear travel data cache (both local and remote variants) // Clear travel data cache (both local and remote variants)
$this->cache->delete(sprintf('travel_unified_%d_%d_local', $dateId, $hotelId)); $this->cache->delete(sprintf('travel_unified_%d_%d_local', $dateId, $hotelId));
$this->cache->delete(sprintf('travel_unified_%d_%d_remote', $dateId, $hotelId)); $this->cache->delete(sprintf('travel_unified_%d_%d_remote', $dateId, $hotelId));
$this->logger->info('Cleared travel data cache after successful booking', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
} }
} }
@@ -23,7 +23,10 @@ class SuccessController extends AbstractController
#[Route('/bookings/create/success', name: 'app_booking_create_success')] #[Route('/bookings/create/success', name: 'app_booking_create_success')]
public function success(Request $request): Response public function success(Request $request): Response
{ {
$bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null; $flashBag = $request->getSession()->getFlashBag();
$bookingNumber = $flashBag->get('booking_number')[0] ?? null;
$bookingTotal = $flashBag->get('booking_total')[0] ?? null;
$travelName = $flashBag->get('booking_travel_name')[0] ?? null;
$returnUrl = $this->bookingService->getReturnUrl($request); $returnUrl = $this->bookingService->getReturnUrl($request);
// Redirect to return URL if no booking number (direct access or refresh) // Redirect to return URL if no booking number (direct access or refresh)
@@ -33,6 +36,8 @@ class SuccessController extends AbstractController
return $this->render('booking/create/success.html.twig', [ return $this->render('booking/create/success.html.twig', [
'bookingNumber' => $bookingNumber, 'bookingNumber' => $bookingNumber,
'bookingTotal' => $bookingTotal,
'travelName' => $travelName,
'returnUrl' => $returnUrl, 'returnUrl' => $returnUrl,
]); ]);
} }
@@ -5,7 +5,6 @@ namespace App\Controller\Booking;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingDataTrait;
use App\Entity\User; use App\Entity\User;
use App\Security\Crypt; use App\Security\Crypt;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -15,17 +14,13 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
class DownloadController extends AbstractController class DownloadController extends AbstractController
{ {
use BookingDataTrait;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {

Some files were not shown because too many files have changed in this diff Show More