Compare commits

..
Author SHA1 Message Date
fromme ac5ca7b18b feat: enable scheduled content sync with typo3 for mcp 2026-10-08 15:35:23 +02:00
fromme 4ab2714bde feat: turn search_trips into the website's travel search 2026-10-08 13:14:12 +02:00
fromme bfdedefae3 feat: give every MCP client its own token, rate limit and log name 2026-10-08 12:37:49 +02:00
fromme 3af41c06ab feat: let get_trip resolve a date code to its trip and date 2026-10-08 11:46:59 +02:00
fromme 754360605c fix: leave access denials on stateless firewalls to the plain 401/403 2026-10-08 11:46:59 +02:00
fromme 1b4384e3f3 feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed 2026-10-08 11:46:58 +02:00
fromme ff7ddf7a9e feat: drop the TYPO3 api key query parameter 2026-10-08 11:45:24 +02:00
fromme 2d876152aa fix: drop unreachable null check 2026-10-08 11:10:13 +02:00
fromme 24498e6fd1 chore: update project dependencies 2026-10-08 11:09:41 +02:00
fromme de5d80c2df feat: replace hardcoded dummy api-key with env variable 2026-10-08 11:05:42 +02:00
fromme 18d3edcd53 chore: list the active unprefixed legacy promo codes 2026-10-06 11:34:33 +02:00
fromme 56af0f921b fix: stop caching rejected voucher checks, document response codes 2026-10-06 11:29:14 +02:00
fromme a14c41de4e fix: keep icon-only buttons at least one line tall 2026-10-06 11:22:54 +02:00
fromme 9a8879dfbe fix: give buttons the input box model so they align with fields 2026-10-06 10:50:30 +02:00
fromme 21d756a2e3 feat: fold purchase and promo voucher fields into one code field 2026-10-06 10:41:30 +02:00
fromme 93512b8345 feat: add employee-only supervisor role nominated by crm selection 1487 2026-10-05 17:06:37 +02:00
fromme 5f95492a7f feat: add flag to disable hx-boost locally 2026-10-05 11:51:25 +02:00
fromme 627b190fcb feat: update voucher purchase urls to match established patterns 2026-10-05 09:10:19 +02:00
fromme e42f836d9b chore: higher resolution voucher thumbnails, drop source pdfs 2026-10-05 09:09:17 +02:00
fromme b755a6f43e refactor: split buspro ApiClient into domain clients 2026-10-02 13:55:30 +02:00
fromme 0a55544ca8 refactor: move buspro XML protocol into XmlChannel 2026-10-02 13:55:16 +02:00
fromme 3c81c79819 refactor: move buspro socket framing into SocketTransport 2026-10-02 13:55:04 +02:00
fromme e51664df6c refactor: deduplicate request building in buspro api client 2026-10-02 13:54:50 +02:00
fromme f3dd122678 feat: voucher purchase flow 2026-10-01 15:03:27 +02:00
fromme a628e96d06 chore: re-add buspro documentation 2026-10-01 12:25:18 +02:00
fromme 2bb0148e49 chore: replace nelexa/zip with maennchen/zipstream-php 2026-10-01 10:48:49 +02:00
fromme 289741d5d4 chore: bump php platform requirement to 8.5 2026-10-01 10:48:44 +02:00
fromme f070f4117c chore: update project dependencies 2026-10-01 09:50:19 +02:00
fromme 72ef3ff105 chore: bump project to php 8.5 2026-10-01 09:48:37 +02:00
fromme 1301df80c2 chore: update company address in email footer 2026-10-01 08:54:29 +02:00
fromme 56deb1e24b feat: name the accommodation booking PDF after its record label 2026-10-01 08:46:12 +02:00
fromme ec10ca3623 feat: offer the accommodation booking PDF download in every status 2026-10-01 08:46:06 +02:00
fromme 67c0c2fb0c fix: date group heading in offer summary 2026-10-01 08:45:59 +02:00
fromme eb45693257 feat: panel layout for the accommodation booking edit and detail pages 2026-09-30 18:06:45 +02:00
fromme 5fdee68ccd fix: show each admin money field's own currency 2026-09-30 18:06:39 +02:00
fromme 7001bc0cf2 feat: split nights off an accommodation booking as separate periods
addresses #869f6z36v
2026-09-30 18:06:20 +02:00
fromme 972839286c fix: never reuse a collection entry index after a removal 2026-09-30 18:05:23 +02:00
fromme ede8fd840a fix: emit export pickup times as clock readings without an offset 2026-09-30 14:52:04 +02:00
fromme 6c700ea18b chore: update project dependencies 2026-09-30 14:12:35 +02:00
fromme fe18ebf479 fix: give the product export builder the tag-aware bpn.cache pool 2026-09-30 14:09:50 +02:00
fromme 1d42565a84 feat: unfiltered product export for the website's catalogue import 2026-09-30 12:19:10 +02:00
fromme 272c811f4f feat: report the source export timestamp on every travel export page 2026-09-30 12:19:04 +02:00
fromme c2c55bc8b2 fix: answer null instead of 500 when the export marker is missing 2026-09-30 12:18:59 +02:00
fromme 8a6ce7606f feat: add a cleanup command for accounts without recent activity 2026-09-29 16:23:32 +02:00
fromme 7ec21c1cac chore: update project dependencies 2026-09-29 16:18:17 +02:00
fromme f951242465 chore: document the travel export endpoint and the new hotel fields 2026-09-29 15:16:47 +02:00
fromme 7dadb6806a feat: add a paginated travel export for the teamer app 2026-09-29 14:46:36 +02:00
fromme 6b539f43f3 fix: skip travels whose hotel is missing from the master data 2026-09-29 14:46:27 +02:00
fromme 1845441bc2 feat: expose hotel cost unit and iso country code via the api 2026-09-29 14:46:20 +02:00
fromme 33c31d088c feat: enable client credentials grant for machine-to-machine api clients 2026-09-29 14:46:13 +02:00
272 changed files with 16620 additions and 2698 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
name: myep
type: php
docroot: public
php_version: "8.4"
php_version: "8.5"
webserver_type: apache-fpm
xdebug_enabled: false
additional_hostnames:
+11
View File
@@ -82,6 +82,12 @@ APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireisever
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/"
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync;
# the real value per stage goes in .env.local
APP_CMS_API_KEY=
# Bearer tokens for the chatbot MCP endpoint (/mcp), one per client: "name:token,name:token"
# (e.g. "n8n:…,claude-desktop:…"); empty refuses every request
APP_MCP_TOKENS=
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
@@ -101,6 +107,11 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# because the cached travels and TravelSnapshot rows carry the parsed price.
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
APP_HTMX_BOOST=true
# Emails
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
@@ -0,0 +1,22 @@
import {Controller} from '@hotwired/stimulus'
/*
* Keeps buttons disabled until the given fields pass the browser's constraint validation
* (required, min, max, step, …), which the form types render from the same rules the server
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
*
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
*/
export default class extends Controller {
static targets = ['field', 'button']
connect() {
this.update()
}
update() {
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
this.buttonTargets.forEach(button => button.disabled = invalid)
}
}
@@ -11,10 +11,13 @@ export default class extends Controller {
connect() {
this.itemsCountValue = this.fieldTargets.length
// A running counter rather than the current count: after a remove, count + 1 can be an
// index a remaining entry still holds, and the two would submit into the same slot.
this.nextIndex = this.fieldTargets.length
}
addItem() {
const index = this.fieldTargets.length + 1
const index = this.nextIndex++
const prototype = JSON.parse(this.prototypeValue)
const newField = prototype.replace(/__name__/g, index)
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
@@ -0,0 +1,26 @@
import {Controller} from '@hotwired/stimulus'
/*
* Shows an indicator over an iframe until its content has loaded.
*
* The controller sets the frame's src itself, after the load listener is attached, so a fast
* response cannot finish before anybody listens.
*/
export default class extends Controller {
static targets = ['frame', 'indicator']
static values = {src: String}
connect() {
this.loaded = this.loaded.bind(this)
this.frameTarget.addEventListener('load', this.loaded)
this.frameTarget.src = this.srcValue
}
disconnect() {
this.frameTarget.removeEventListener('load', this.loaded)
}
loaded() {
this.indicatorTarget.classList.add('hidden')
}
}
+10 -4
View File
@@ -1,16 +1,22 @@
.button {
@apply inline-flex items-center justify-center space-x-2 h-8 leading-8 md:h-10 md:leading-10 px-4 md:px-8 cursor-pointer;
@apply uppercase leading-none text-center text-sm md:text-base;
/* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
@apply uppercase text-center text-sm/6 md:text-base/6;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply rounded-md;
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
min-height: calc(1lh + 2 * theme('spacing.2') + 2px);
}
.button--small {
@apply px-2 py-1 md:px-4 text-xs sm:text-sm;
@apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
min-height: calc(1lh + 2 * theme('spacing.1') + 2px);
}
.button--primary {
background: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
/* Only the image: the background shorthand would reset .button's background-origin */
background-image: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
@apply text-white;
@apply hover:bg-none hover:bg-pink/80;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+6 -3
View File
@@ -4,7 +4,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": ">=8.5",
"ext-ctype": "*",
"ext-dom": "*",
"ext-iconv": "*",
@@ -21,8 +21,9 @@
"knplabs/knp-paginator-bundle": "^6.10",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/html-to-markdown": "^5.1",
"league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0",
"maennchen/zipstream-php": "^3.2",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4",
@@ -43,12 +44,14 @@
"symfony/intl": "7.4.*",
"symfony/lock": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mcp-bundle": "^0.14.1",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
@@ -77,7 +80,7 @@
},
"sort-packages": true,
"platform": {
"php": "8.4.24"
"php": "8.5.0"
}
},
"autoload": {
Generated
+1004 -397
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -20,4 +20,5 @@ return [
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
];
+8
View File
@@ -16,12 +16,20 @@ framework:
php_errors:
log: true
rate_limiter:
# Per MCP client. A chatbot turn makes a few tool calls; this catches runaway loops.
chatbot_mcp:
policy: sliding_window
limit: 60
interval: '1 minute'
http_client:
scoped_clients:
typo3.client:
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
headers:
accept: 'text/json'
X-Api-Key: '%env(APP_CMS_API_KEY)%'
when@test:
framework:
+10
View File
@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory
+16
View File
@@ -0,0 +1,16 @@
# The website chatbot's MCP server, called by the n8n AI Agent through its MCP Client Tool
# node. Read-only tools over the mirrored TYPO3 content and our own trip data; see
# docs/chatbot-mcp.md. Authentication is the "mcp" firewall in security.yaml.
mcp:
servers:
chatbot:
name: 'MyE&P chatbot'
version: '1.0.0'
instructions: 'Answers questions about the trips and website content of E&P Reisen and its brands. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.'
http:
path: /mcp
# DNS rebinding protection guards unauthenticated local servers. This one is
# public, behind a bearer token, and never called from a browser.
allowed_hosts: false
registry:
tools: ['App\Chatbot\Mcp\Tool\']
+15 -2
View File
@@ -5,6 +5,7 @@ monolog:
- core
- auth
- admin
- chatbot
when@dev:
monolog:
@@ -33,11 +34,16 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: debug
channels: ["admin"]
chatbot:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
level: debug
channels: ["chatbot"]
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event", "!bpn", "!core", "!auth", "!admin"]
channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
@@ -96,13 +102,20 @@ when@prod: &prod_config
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: info
channels: ["admin"]
# MCP tool calls, file only: the log for judging search quality later.
chatbot:
type: rotating_file
max_files: 30
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
level: info
channels: ["chatbot"]
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
channels: ["!bpn", "!core", "!auth", "!admin"]
channels: ["!bpn", "!core", "!auth", "!admin", "!chatbot"]
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
+12
View File
@@ -15,6 +15,11 @@ security:
mailjet:
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
roles: [ ROLE_MAILJET_WEBHOOK ]
# Never consulted: McpAccessTokenHandler builds the user from the client name its
# token belongs to. Named only because a firewall must have a provider.
mcp_provider:
memory:
users: []
role_hierarchy:
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
@@ -45,6 +50,12 @@ security:
provider: mailjet_webhook_provider
http_basic:
realm: 'Mailjet Webhook'
mcp:
pattern: ^/mcp$
stateless: true
provider: mcp_provider
access_token:
token_handler: App\Security\McpAccessTokenHandler
main:
lazy: true
provider: app_user_provider
@@ -58,6 +69,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
- { path: ^/mcp$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
+1
View File
@@ -5,6 +5,7 @@ twig:
'%kernel.project_dir%/assets/images': images
globals:
htmx_change_trigger: 'change delay:100ms'
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
+12
View File
@@ -67,3 +67,15 @@ when@prod:
- task: app:bpn:sync-contingents
frequency: "5 20-23,0-7 * * *"
description: "Syncs the full contingent horizon hourly outside peak hours"
- task: app:content:sync
frequency: "0 * * * *"
description: "Mirrors changes in the TYPO3 content feed for the chatbot MCP server"
- task: app:content:sync --refresh
frequency: "5 1 * * 0"
description: "Refetches TYPO3 content stored more than 7 days ago, within the night window"
- task: app:content:check-freshness --max-age 150
frequency: "10 * * * *"
description: "Fails when the TYPO3 content mirror has missed more than one hourly sync"
+3
View File
@@ -0,0 +1,3 @@
mcp:
resource: .
type: mcp
+73 -11
View File
@@ -16,6 +16,26 @@ parameters:
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
# reading it once the last date has passed.
legacy_promo_voucher_codes:
BEWERTUNG25: '2026-10-31'
ANNA50: '2026-12-20'
SIDNEY50: '2026-12-20'
LENA20: '2027-03-04'
BOTSCHAFTER2627: '2027-04-30'
# Named travel periods of the website search ("Reisezeitraum"), offered by the chatbot's
# search_trips. Duplicated from the TYPO3 search configuration on purpose: update both each
# season. Periods may overlap; one whose end has passed is no longer offered.
chatbot_travel_periods:
- { label: 'Vorsaison', from: '2026-11-01', to: '2026-12-20' }
- { label: 'um Weihnachten', from: '2026-12-20', to: '2026-12-28' }
- { label: 'Silvester', from: '2026-12-26', to: '2027-01-03' }
- { label: '1 Januarwoche', from: '2027-01-02', to: '2027-01-09' }
- { label: 'Nachsaison', from: '2027-03-20', to: '2027-05-01' }
- { label: 'Ostern', from: '2027-03-20', to: '2027-04-03' }
# MailJet list ids and their labels
mailjet_lists:
@@ -128,18 +148,22 @@ services:
- '../src/Model'
- '../src/Kernel.php'
App\BusProNet\ApiClient:
App\BusProNet\Transport\XmlChannel:
arguments:
$logger: '@monolog.logger.bpn'
$options:
bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\BusProNet\Transport\SocketTransport:
arguments:
$logger: '@monolog.logger.bpn'
$host: '%env(APP_BPN_IP)%'
$ports: '%env(csv:APP_BPN_PORTS)%'
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\Twig\AppRuntime:
arguments:
@@ -228,17 +252,44 @@ services:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
$logger: '@monolog.logger.bpn'
App\Service\VoucherCodeClassifier:
arguments:
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
# Voucher purchase flow (/gutschein)
App\Service\VoucherTemplateProvider:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherThumbnailResolver:
arguments:
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step1Controller:
arguments:
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step3Controller:
arguments:
$logger: '@monolog.logger.bpn'
# Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
App\Form\Service\ParticipantVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -264,8 +315,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules
@@ -294,10 +344,22 @@ services:
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
App\ContentFeed\ContentFeedClient:
arguments:
$baseUrl: '%env(APP_CMS_API_BASE_URL)%'
$apiKey: '%env(APP_CMS_API_KEY)%'
App\Chatbot\TravelPeriods:
arguments:
$periods: '%chatbot_travel_periods%'
App\Security\McpAccessTokenHandler:
arguments:
$tokens: '%env(APP_MCP_TOKENS)%'
App\Service\CmsDataProvider:
arguments:
$httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
App\Logger\RequestIdProcessor:
tags:
+179 -16
View File
@@ -12,8 +12,8 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`.
@@ -52,7 +52,7 @@ There is no single error envelope; three shapes occur:
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
@@ -92,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
status 200 — treat null as "unknown", not as "changed".
---
@@ -146,7 +148,7 @@ This is a full overwrite, not a merge.
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
This is the only paginated endpoint and the only one with a `meta` envelope.
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
arbitrary string, so an export hung off that collection would share a namespace with every date code.
@@ -188,7 +190,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00"
"datetime": "2026-12-25T20:15:00"
}
]
}
@@ -200,6 +202,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
@@ -209,8 +212,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -222,12 +225,17 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
an offset to compare or sort them; they already sort correctly as strings.
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
`datetime` carries that resolution for bare-time sources too.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -238,6 +246,155 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
#### `GET /api/exports/products` — scope `api`
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
them a website shows, and how, is the client's rule, not this endpoint's.
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
returns it in one page.
```json
{
"data": [
{
"product": { "code": "DKIWE", "busProId": 1127 },
"date": {
"code": "DKIWE041226",
"busProId": 12437,
"from": "2026-12-04",
"to": "2026-12-06",
"type": "F"
},
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
"pseudoPrice": null,
"selectionGroups": [
{
"busProId": 7,
"label": "Leistungen Skipass",
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
}
],
"transportServices": [
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
],
"dateServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"hotelServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"rooms": [
{
"busProId": 74,
"code": "MBZET",
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
"category": "GR",
"minPax": 1,
"maxPax": 1,
"nights": 2,
"price": 229,
"available": 24,
"status": "Frei",
"boardCode": "Lt. Auss.",
"boardBusProId": 2
}
],
"pickups": [
{
"busProId": 4,
"code": "FRA-Route",
"city": "Frankfurt",
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
"price": 5.9,
"datetime": "2026-12-03T23:30:00"
}
],
"guide": null
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 419,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
"generatedAt": "2026-09-30T14:05:00+02:00"
}
}
```
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
**Types.**
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
**Rooms.** One entry per room offered on the date at that hotel.
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
empty.
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
**Services.** Three lists, deliberately kept apart:
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
- `dateServices` — the date's general services.
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
`busProId`: it identifies a service across both lists.
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
such as for arriving by car.
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
| `busProId` | Label |
|---|---|
| 7 | Leistungen Skipass |
| 8 | Leistungen Sonstige |
| 71 | Leistungen Verpflegung |
| 85 | Leistungen Übernachtung |
| 86 | Leistungen Programm |
| 87 | Leistungen Kurse |
| 89 | Leistungen Events |
| 90 | Leistungen Transport |
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
none. It is not levelled against the return leg. An id absent from
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
of `0` is emitted as-is but not counted.
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
---
### Travels
@@ -398,14 +555,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"totalDiscountLabel": null,
"totalPrice": 123456,
"pricingCurrency": "EUR",
"pricingVersion": 3,
"pricingVersion": 2,
"priceBreakdown": {}
}
```
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted.
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
@@ -413,6 +572,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
| field | meaning |
| --- | --- |
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
| `periodsTotal` | the sum of all `periods[].total` |
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
+82 -8
View File
@@ -25,7 +25,7 @@ Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
key = md5( user + password + date("YYYYMMDD") + satz-typ )
```
Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates
Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
the key before sending, so a hand-built payload can carry a placeholder.
## Satz types
@@ -39,7 +39,7 @@ the key before sending, so a hand-built payload can carry a placeholder.
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
Its contract is observed, not specified.
@@ -201,11 +201,11 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
## How this app maps onto the contract
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
`addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them.
`addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
| Documented element | Sent on create | Sent on edit | Notes |
|---|---|---|---|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
@@ -253,7 +253,7 @@ booking before relying on it.
### Gap: edit never dry-runs
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
price *before* mutating a live booking.
@@ -312,6 +312,67 @@ answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `prei
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
`anzahlung` or errors `1011`-`1018` follow.
## Selling vouchers (Kaufgutschein)
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
| Satz | Request | Response |
|---|---|---|
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
```xml
<satz typ="GUTSCHEINBUCHUNG"/>
<buchungsart>Anfrage|Buchung</buchungsart>
<gutscheinart>K</gutscheinart>
<idgutscheinvorlage>3</idgutscheinvorlage>
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
<kaufpreis>25</kaufpreis>
<text/> <!-- Zusatztext; the live shop sends it empty -->
<rechnungsempfänger>
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
</rechnungsempfänger>
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
```
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
live shop also collects a business phone, whose element name is not documented anywhere, so the
flow does not collect it.
### Measured on the test system (2026-10-01)
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
issued. `idagentur` is not required (619/620 never fired).
| Variant | Answer |
|---|---|
| Valid (control), 25 € | `möglich` |
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
| `empfaenger` missing | `605 Empfängername fehlt` |
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
| `strasse` empty | `650 … Strasse` |
| `anrede` Herr, `telefonmobil` set | `möglich` |
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
shop, which suggests they are shared with production; confirm on the first production read.
## Error codes
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
@@ -320,7 +381,7 @@ runtime.
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
> carrier whose entire content is substituted. `Notification::isError()`
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
@@ -359,8 +420,21 @@ zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm /
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt ·
631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig ·
**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum
ungültig**
633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means
the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`):
a test promo whose booking period was being edited answered 633 and was accepted once the period
covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was
returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/
`TerminBis`. What it actually compares has not been established.
## Applying this: the teamer placeholder booking
BIN
View File
Binary file not shown.
@@ -9,7 +9,7 @@ Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (liv
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
the document that drives the portal's entire authorization model.
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends
Request side: `AccountClient::getCrmAttributes()` (`src/BusProNet/Client/AccountClient.php`) sends
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
Response side: `CrmAttributesResponseParser`
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
@@ -24,6 +24,7 @@ Symfony roles:
| `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
| `1487` | `ROLE_SUPERVISOR` (not consumed here — exported through `/api/userinfo` for ephub) |
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` |
@@ -45,7 +46,7 @@ filtered before anything is stored:
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT`, `ROLE_SUPERVISOR` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
@@ -229,7 +230,7 @@ Three conclusions:
`dbo.AgenturSelektionenKunde` is a bare two-column link table,
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
portal authenticates against BusPro as an **agency** account (`bpn_username` in
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see
`XmlChannel`), so a per-agency whitelist of which customer selections that agency may see
and/or edit is exactly the right shape. It is the only candidate found so far that could
explain the `1478` omission **and** `aenderbar` through one mechanism.
@@ -461,7 +462,7 @@ and they are long shots. Prefer the escalation path below.
Escalation path, cheapest first:
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()`
`xml-sync` cache tag covers the XML *export* sync, and `AccountClient::getCrmAttributes()`
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
from a response captured after the insert, the staleness is on the BusProNet host and
an application-pool/service restart there is the test.
+2 -2
View File
@@ -5,8 +5,8 @@
> per-endpoint migration verdicts and the `frei` discovery SQL.
This complements `travel-queries.md`. That doc replaced the **XML export** (static
catalog). This one covers the **live socket API** methods on
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL
catalog). This one covers the **live socket API** methods of the clients in
`src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
query in the Go proxy.
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
+146
View File
@@ -0,0 +1,146 @@
# Chatbot MCP server
The website chatbot runs in n8n. Its AI Agent answers from a read-only MCP server in this app instead
of scraping the websites. The server combines two sources:
- **Editorial content from TYPO3**: the content feed of all public sites (ep-reisen, ser,
ski-boarderweek, ski-wochenenden, snowzone), mirrored into `content_document` by `app:content:sync`.
- **Trip data of our own**: dates, rooms, prices, availability and pickups from the product export
(`ProductExportBuilder`, the data behind `GET /api/exports/products`).
TYPO3 sends no dates or prices, and nothing flows back to TYPO3.
## Endpoint
`POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle`
from `config/packages/mcp.yaml`. Tools only, no resources or prompts.
Authentication is a static bearer token per client, `Authorization: Bearer <token>`, checked by
`App\Security\McpAccessTokenHandler` on the `mcp` firewall. `APP_MCP_TOKENS` lists the clients as
`name:token` pairs separated by commas, e.g. `APP_MCP_TOKENS="n8n:3f9c…,claude-desktop:a71e…"`. Give every
system its own token, so one can be revoked by removing its entry without touching the others. Use tokens
without commas, e.g. `openssl rand -hex 32`. A malformed list (no name, a duplicate name, a token shared by
two clients) fails loudly with a 500 on `/mcp` rather than locking a client out silently. If the list is
empty, every request is refused. It is not the OAuth2 client-credentials flow of `/api`: n8n's MCP Client Tool node sends a fixed
header and cannot renew a token every 10 minutes.
The client name becomes the authenticated user. Tool calls are rate-limited per client (`chatbot_mcp`
limiter, 60 per minute), so testing from Claude Desktop does not eat into n8n's budget. The limit is there to stop
runaway agent loops. Each call is logged on the `chatbot` channel (`var/log/<env>.chatbot.log`, file
only) with the client, tool, arguments, result count and duration. Nothing identifies the person chatting.
### n8n
MCP Client Tool node: endpoint `https://<host>/mcp`, transport *HTTP Streamable*, authentication *Bearer*
with n8n's own token from the stage's `APP_MCP_TOKENS`. The agent prompt should tell the bot to cite each answer's `url`, and
that prices and availability are a snapshot (`dataAsOf`) and only the booking page is binding.
## Tools
| Tool | Use | Returns |
|---|---|---|
| `search_trips` | The website's travel search: which trips exist, when, where, how much | One result per trip × hotel × travel date (default 10, max 25), with "ab" price, nights, inclusions, room types, availability, link; `total` and `dataAsOf` |
| `get_trip` | One trip by product `code`, or one date of it by date code such as `SSLMP061226` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates |
| `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt |
| `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters |
Every result carries the public `url`. Output is compact JSON with Markdown in the text fields.
An empty result is `{"results": []}`. Invalid input produces a tool error the model can read.
Trip rules (`App\Chatbot\TripCatalog`):
- TYPO3 products are joined to the export on `product.code`. Several products can share a code, and
each is listed as its own trip. Hotels are joined on any entry of the TYPO3 hotel's `codes` (BusPro
uses the matchcode aliases). A code that is one hotel's own and another's alias resolves to its owner.
- A room is bookable unless its status is `Buchungsstop`. The baby row (room code `Baby`) is not a room.
`available` counts rooms, so the places of a date at a hotel are `Σ available × maxPax` over its
bookable rooms. The tools never output room counts, only `frei` or `auf Anfrage`.
- `search_trips` only offers products that TYPO3 lists in its own website search (`searchable`) and does
not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still
answers when someone names their code.
- The export's `guide` (name, phone) and all BusPro ids are never output.
`search_trips` mirrors the search page (`/skireisen/suche/`), whose data is TYPO3's import of our own
product export. Its rules follow TYPO3's `DateImportService` and search repository, derived per date and
hotel in `App\Model\Chatbot\TravelDateFacts`:
- **"ab" price:** the cheapest bookable room with places left, otherwise the cheapest bookable room.
`priceMin`/`priceMax` apply to it. The site's price bands are presets of this.
- **Travel period:** `dateFrom`/`dateTo` match by containment (start on or after, end on or before), as on
the site, not by overlap. `period` names one of the site's presets, from the `chatbot_travel_periods`
parameter in `config/services.yaml`. It's duplicated from the TYPO3 search configuration on purpose, so
update both each season. Expired periods are no longer offered.
- **`persons`:** the site's condition (a room takes groups this small: smallest `minPax` ≤ persons) **and**
enough places left. The site checks only the first.
- **Room type:** capacity, as TYPO3's `RoomTypeResolver` (`App\Enum\RoomType`): `maxPax` 1–8, 9 or more
is `9er+`. A label containing "bett im" or "mit ander" is `mit anderen` (a shared bed, sold per bed).
- **Accommodation:** the TYPO3 hotel `type` (1 Ferienwohnung, 2 Sportclub, 3 Apartment, 4 Gästehaus,
5 Pension), `App\Enum\HotelType`.
- **Board:** the items of selection group 71 ("4x Abendessen", "Frühstück inkl. … Lunchpaket", "Vollpension",
…), matched by word against any item. The site keys on the first item only. "Halbpension" occurs in no
label, so dinner is asked as "Abendessen".
- **Inclusions:** selection groups 7 (Skipass), 71 (board), 86 (Programm), 87 (Kurse), 89 (Events), and the
outbound bus when its price is 0. `bus: true` asks for any bookable outbound bus.
- **Nights:** days between start and end. The site's "short" is ≤ 4 and its "long" is ≥ 5.
- **Order:** by start date, then "ab" price. The site sorts by concept first, which the feed doesn't carry.
- Results link to the product page. Links with the hotel preselected and the site's "Restplätze" badge need
two fields the TYPO3 content feed doesn't send yet (`hotelUrls`, `lowContingent`).
Search (`App\Chatbot\Search\ContentSearch`) is MariaDB full text in boolean mode over
`title, breadcrumb, body_md`. Each term becomes a prefix match, because InnoDB has no German stemming,
and German stopwords are dropped in PHP. Relevance is weighted by type: products and hotels, then
destinations and concepts, then pages, then blog posts.
## Content sync
```bash
php bin/console app:content:sync # delta: what the manifest says changed
php bin/console app:content:sync --refresh # also refetch items older than 7 days, oldest first
php bin/console app:content:sync --ignore-window # deliberate bulk run outside 01:00–05:00
php bin/console app:content:check-freshness # fails if the last good manifest is > 60 min old
```
Scheduled on prod (`config/packages/zenstruck_schedule.yaml`): an hourly delta sync,
`--refresh` on Sunday at 01:05, and the freshness check hourly at :10 with `--max-age 150`, so that
`email_on_failure` reports a stalled sync but not a single missed run. Website content changes
rarely, and an hour's delay is acceptable for the chatbot; trip data and availability do not come
from this mirror. The refresh is off the quarter hour because a run that
finds another one holding the lock exits successfully without doing anything.
The feed is read with `X-Api-Key: <APP_CMS_API_KEY>` from `APP_CMS_API_BASE_URL`. The manifest lists
every site whichever host serves it. The key is only sent to hosts of the sites the manifest names.
**Load rules.** These are agreed with the TYPO3 side, so do not tune them:
- One request at a time, and at most one page fetch per second.
- A response slower than 2 s makes the next request wait 5 s. Three slow pages in a row end the page
phase for the run.
- A `503` is TYPO3's kill switch. The run stops, and `Retry-After` pauses later runs (`content_sync_state.paused_until`).
Three consecutive 5xx responses or timeouts (10 s) also stop the run. Nothing is retried in a loop.
- Outside 01:00–05:00 Europe/Berlin a run fetches at most 50 pages and leaves the rest for later.
**Robustness.**
- A failed, invalid or empty manifest changes nothing.
- A record that is requested but absent from the records response is deleted, and so is an item that
is missing from the manifest.
- A page answering 404 or 5xx is remembered as `failed`, and one that converts to no text as `empty`.
Each is stored with its `modified` and not refetched until that changes or a `--refresh` reaches it.
- A timeout is retried on the next run.
**Document model.** One `content_document` row per `(type, uid, language)`. A record's structured
payload is kept in `data`, and HTML is converted to Markdown (`HtmlToMarkdownConverter`). A page whose
`detailOf` names a record is appended to that record's `body_md` and kept out of the index
(`merged_into`), so a product is never found twice. Merges are recomputed on every run. A record without
a reachable page (`site`/`url` null) is not shown in search results, unless a detail page lends it its URL.
After a complete run that changed something, the codes the trip join cannot place are logged on `core`
(`Content feed: unmatched codes`).
## Configuration
| Variable | Meaning |
|---|---|
| `APP_CMS_API_BASE_URL` | TYPO3 base URL per stage (also used by `CmsDataProvider`) |
| `APP_CMS_API_KEY` | TYPO3 `X-Api-Key`: the content feed and its `contentUrl` pages, and `typo3.client` (`CmsDataProvider`) |
| `APP_MCP_TOKENS` | Bearer tokens for `/mcp`, one per client: `name:token,name:token` |
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
* priced exactly as before.
*/
final class Version20260930150445 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
$this->addSql('DROP TABLE accommodation_booking_period');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The mirror of the TYPO3 content feed behind the chatbot MCP server. No backfill: the
* first app:content:sync fills it.
*/
final class Version20261008081401 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds content_document and content_sync_state for the chatbot content mirror.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE content_document (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(16) NOT NULL, uid INT NOT NULL, language VARCHAR(8) NOT NULL, site VARCHAR(32) DEFAULT NULL, url VARCHAR(768) DEFAULT NULL, title VARCHAR(255) NOT NULL, breadcrumb VARCHAR(512) DEFAULT NULL, body_md MEDIUMTEXT NOT NULL, data JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', modified DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetched_modified DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetch_state VARCHAR(16) NOT NULL, synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', merged_into VARCHAR(32) DEFAULT NULL, indexed TINYINT(1) NOT NULL, INDEX content_document_indexed_site (indexed, site), INDEX content_document_url (url), FULLTEXT INDEX content_document_fulltext (title, breadcrumb, body_md), UNIQUE INDEX content_document_identity (type, uid, language), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE content_sync_state (id INT AUTO_INCREMENT NOT NULL, manifest_generated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_run_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_error LONGTEXT DEFAULT NULL, paused_until DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE content_document');
$this->addSql('DROP TABLE content_sync_state');
}
}
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PersonalDataUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\RegistrationDto;
/**
* A customer's BusPro account: login, address data, newsletter, CRM selections.
*/
class AccountClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getPersonalData(string $email, string $password): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $registrationData->toPayload(),
]);
}
/**
* @throws ApiClientException
*/
public function resetPassword(string $email): Notification
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Passwort_Anfrage',
'email' => $email,
]);
}
/**
* @throws ApiClientException
*/
public function updatePersonalData(
string $email,
string $password,
PersonalData $personalData,
bool $debug = false,
): Notification|PersonalDataUpdateResponse {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten_Ändern',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'adressdaten' => $personalData->toPayload(),
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function createAddress(
PersonalData $personalData,
bool $debug = false,
): RegistrationResponse|Notification {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(false),
'ohnemailversand' => 'True',
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Newsletter',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'newsletter' => [
'email' => $email,
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
],
]);
}
/**
* @throws ApiClientException
*/
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'SelektionCRM',
'email' => $email,
'passwort' => $password,
]);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\BookingDto;
/**
* A customer's bookings: listing, details and documents, creating and changing them.
*/
class BookingClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly BookingDataProcessor $bookingDataProcessor,
) {
}
/**
* @throws ApiClientException
*/
public function getBookings(string $email, string $password): Notification|BaseData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgänge',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function getBooking(string $email, string $password, int $id): Notification|Booking
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgang_Details',
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
]);
}
/**
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
{
return $this->channel->request(MessageType::CustomerData, [
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $bookingId,
]);
}
/**
* @throws ApiClientException
*/
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
{
return $this->channel->request(MessageType::BookingUpdate, [
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
...$this->bookingDataProcessor->createUpdateRequestPayload($formData),
], [], $debug);
}
/**
* First phase of the two-phase booking process: validates the booking and returns the
* prices without creating it.
*
* @throws ApiClientException
*/
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug);
}
/**
* Second phase of the two-phase booking process: creates the booking after a successful
* inquiry.
*
* @throws ApiClientException
*/
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug);
}
/**
* @throws ApiClientException
*/
private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse
{
return $this->channel->request(MessageType::Booking, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
/**
* Lists that change rarely and that callers cache for long periods.
*/
class ReferenceDataClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @return Agency[]|Notification
*
* @throws ApiClientException
*/
public function getAgencies(): array|Notification
{
return $this->channel->request(MessageType::Agencies);
}
/**
* @throws ApiClientException
*/
public function getBaseData(MessageType $type): Notification|BaseData
{
return $this->channel->request($type);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\Transport\XmlChannel;
/**
* Travels from the live API. Most reads belong in TravelDataProvider, which arbitrates
* between this, the XML export and the snapshots.
*/
class TravelClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
{
return $this->channel->request(MessageType::ProductData, ['idprodukt' => $travelId], ['hotelId' => $hotelId]);
}
/**
* Returns the unparsed response, which has the structure of BusPro's XML export and is
* stored in the export directory as is.
*
* @throws ApiClientException
*/
public function getTravelDataXml(int $travelId): string
{
return $this->channel->requestRaw(MessageType::ProductData, ['idprodukt' => $travelId]);
}
/**
* @throws ApiClientException
*/
public function getProducts(): Notification|BaseData
{
return $this->channel->request(MessageType::Products);
}
/**
* @throws ApiClientException
*/
public function getMutableData(int $dateId): Notification|BaseData
{
return $this->channel->request(MessageType::MutableData, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
{
return $this->channel->request(MessageType::Availability, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
return $this->channel->request(MessageType::AvailabilityExtended, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
return $this->channel->request(MessageType::AvailabilityHotel, [
'idreise' => $dateId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
]);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherPreview;
use App\BusProNet\Model\VoucherSaleResponse;
use App\BusProNet\Model\VoucherTemplates;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\VoucherPurchaseDto;
/**
* Redeeming vouchers in a booking, and selling purchase vouchers (Kaufgutscheine).
*/
class VoucherClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
) {
}
/**
* Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular
* or goodwill (Kulanz), carry a balance that each redemption reduces.
*
* @throws ApiClientException
*/
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
{
return $this->channel->request(MessageType::PurchaseVoucher, ['einloesecode' => $redemptionCode]);
}
/**
* Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo
* vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person
* says.
*
* @throws ApiClientException
*/
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
{
return $this->channel->request(MessageType::PromoVoucher, [
'aktionsgutschein' => $promoCode,
'idreise' => $travelId,
'preis' => number_format($participantPrice, 2, ',', ''),
]);
}
/**
* Fetches the voucher designs on sale and the amount bounds.
*
* @throws ApiClientException
*/
public function getVoucherTemplates(): Notification|VoucherTemplates
{
return $this->channel->request(MessageType::VoucherTemplates);
}
/**
* Fetches the payment methods offered for voucher purchases.
*
* @return list<VoucherPaymentMethod>|Notification
*
* @throws ApiClientException
*/
public function getVoucherPaymentMethods(): array|Notification
{
return $this->channel->request(MessageType::VoucherPaymentMethods);
}
/**
* Renders the PDF preview of a voucher design with the entered data.
*
* @throws ApiClientException
*/
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
{
return $this->channel->request(MessageType::VoucherPreview, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto));
}
/**
* Dry run of a voucher purchase: full validation, nothing is issued.
*
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
* result must not be judged by Notification::isError().
*
* @throws ApiClientException
*/
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
}
/**
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
*
* Every HINWEIS is a rejection on this path, including 650.
*
* @throws ApiClientException
*/
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
}
/**
* @throws ApiClientException
*/
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
{
return $this->channel->request(MessageType::VoucherSale, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType));
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\Form\Model\VoucherPurchaseDto;
/**
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
*
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
*/
class VoucherSalePayloadBuilder
{
/**
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
*
* @return array<string, mixed>
*/
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
{
if (null === $dto->template || null === $dto->paymentMethod) {
throw new \LogicException('A voucher sale needs a template and a payment method.');
}
$communication = [
'telefonprivat' => $dto->phone,
'email' => $dto->email,
];
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
$communication['telefonmobil'] = $dto->mobile;
}
return [
'buchungsart' => $saleType,
'gutscheinart' => $dto->template->type,
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
'rechnungsempfänger' => [
'name' => $dto->lastName,
'vorname' => $dto->firstName,
'anrede' => $dto->salutation,
'titel' => $dto->title,
'geschlecht' => '',
'geburtsdatum' => '',
'anschrift' => $dto->address->toPayload(),
'kommunikation' => $communication,
],
'zahlung' => [
'@idzahlungsart' => $dto->paymentMethod->id,
'@bezeichnung' => $dto->paymentMethod->label,
'@art' => $dto->paymentMethod->type,
],
];
}
/**
* @return array<string, mixed>
*/
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
{
if (null === $dto->template) {
throw new \LogicException('A voucher preview needs a template.');
}
return [
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
];
}
}
@@ -2,7 +2,8 @@
namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider
{
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache)
public function __construct(private readonly ReferenceDataClient $referenceDataClient, private readonly CacheInterface $cache)
{
}
@@ -22,8 +23,8 @@ class CountryDataProvider
$item->expiresAfter(6 * 60 * 60);
return $this
->apiClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES)
->referenceDataClient
->getBaseData(MessageType::Countries)
->getItems()
;
});
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\BusProNet;
/**
* The `typ` of BusPro's `<satz>`: the command on a request, the kind of answer on a response.
*/
enum MessageType: string
{
// Only ever a response: BusPro's refusal or remark, whatever was asked.
case Notification = 'HINWEIS';
case CustomerData = 'KUNDENKONTO';
case Countries = 'STAMMLAENDER';
case MutableData = 'MOEGLICHEAENDERUNGEN';
case Availability = 'VERFUEGBARKEIT';
case AvailabilityExtended = 'VERFUEGBARKEIT2';
case AvailabilityHotel = 'VERFUEGBARKEITHOTEL';
case BookingUpdate = 'BUCHUNGAENDERUNG';
case Booking = 'BUCHUNG';
case Products = 'PRODUKTE';
case ProductData = 'PRODUKTDATEN';
case Agencies = 'AGENTUREN';
case PurchaseVoucher = 'GUTSCHEINPRUEFUNGEINLOESUNG';
case PromoVoucher = 'AKTIONSGUTSCHEIN';
// Selling Kaufgutscheine. Not to be confused with PurchaseVoucher, which redeems one in a booking.
case VoucherTemplates = 'GUTSCHEINVORLAGEN';
case VoucherPaymentMethods = 'GUTSCHEINZAHLUNGSARTEN';
case VoucherPreview = 'GUTSCHEINVORSCHAU';
case VoucherSale = 'GUTSCHEINBUCHUNG';
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
*
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
*/
final readonly class VoucherPaymentMethod
{
public const string TYPE_TRANSFER = 'UEBERW';
public function __construct(
public int $id,
public string $label,
public string $type,
) {
}
public function isTransfer(): bool
{
return self::TYPE_TRANSFER === $this->type;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
*/
final readonly class VoucherPreview
{
public function __construct(
public string $pdf,
) {
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
*
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
*
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
* voucher itself once the transfer has arrived.
*/
final readonly class VoucherSaleResponse
{
public const string SALE_POSSIBLE = 'möglich';
public const string SALE_PROCESSED = 'erfolgt';
public function __construct(
public string $status,
public ?int $voucherId = null,
public ?string $voucherNumber = null,
public ?string $redemptionCode = null,
public ?float $price = null,
public ?string $invoiceNumber = null,
public ?string $email = null,
) {
}
public function isInquiryValid(): bool
{
return self::SALE_POSSIBLE === $this->status;
}
public function isSaleSuccessful(): bool
{
return self::SALE_PROCESSED === $this->status;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
*
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
*/
final readonly class VoucherTemplate
{
public function __construct(
public int $id,
public string $type,
public string $label,
public ?string $note = null,
) {
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
*
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
* they were measured on the test system (10 and 5,000 €, both inclusive).
*/
final readonly class VoucherTemplates
{
/**
* @param list<VoucherTemplate> $templates
*/
public function __construct(
public int $minAmount,
public int $maxAmount,
public array $templates,
) {
}
public function find(int $id): ?VoucherTemplate
{
foreach ($this->templates as $template) {
if ($id === $template->id) {
return $template;
}
}
return null;
}
}
+23
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo
{
private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME;
}
/**
* Reads the marker from a storage, or null when it is missing or unreadable.
*
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
* callers only ever want to know which export they are looking at, and "unknown" is
* an answer they can act on.
*/
public static function fromStorage(FilesystemOperator $storage): ?self
{
try {
if (false === $storage->fileExists(self::INFO_FILENAME)) {
return null;
}
return self::fromString($storage->read(self::INFO_FILENAME));
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
public static function fromString(string $content): self
{
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
+5 -2
View File
@@ -79,6 +79,9 @@ trait TypeConversionTrait
* This method is used primarily to parse pickup-times since data is
* either provided with date and time or time only. In this case a
* date has to be provided which defaults to a travel's start date.
*
* Both shapes are read in the default date's zone, so a caller that needs plain clock
* readings can pass a UTC date and no daylight saving gap shifts the time.
*/
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided
$dateTime = Carbon::createFromFormat('H:i', $string);
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date
$dateTime->setDateFrom($defaultDate);
} else {
// date and time provided
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16));
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
}
return $dateTime->toDateTimeImmutable();
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Transport;
use App\Service\RequestIdGenerator;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Ids for BPN requests: the id of the current HTTP request, or a fresh one on the CLI, with a
* running number, so the log entries and dumps of one page load can be found together.
*/
class RequestIdSequence
{
private int $counter = 0;
public function __construct(
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
) {
}
public function next(): string
{
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
?? $this->requestIdGenerator->generateBaseId();
return $baseId.'_'.++$this->counter;
}
}
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
use Psr\Log\LoggerInterface;
/**
* BusPro's socket framing: one connection per request, each message prefixed with its length
* as a 10 byte header. Knows nothing about what the messages contain.
*/
class SocketTransport
{
private float $operationStartTime;
private int $selectedPort;
/** @param list<int|string> $ports */
public function __construct(
private readonly LoggerInterface $logger,
private readonly string $host,
private readonly array $ports,
private readonly int $connectionTimeout = 5,
private readonly int $streamTimeout = 30,
private readonly int $totalTimeout = 45,
private readonly int $maxRetries = 25,
) {
}
/**
* Sends $body over a fresh connection and returns the response payload. $logMessage is
* logged with $context and the chosen port once the connection is open.
*
* @param array<string, mixed> $context
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
public function exchange(string $body, string $logMessage, array $context = []): string
{
$socket = $this->connect();
$this->logger->info($logMessage, [...$context, 'port' => $this->selectedPort]);
$this->send($socket, $body);
try {
// the length header is consumed and verified by receive(), this is the payload
return $this->receive($socket);
} finally {
$this->disconnect($socket);
}
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect()
{
// Randomly select a port from the available ports for load balancing
$this->selectedPort = $this->ports[array_rand($this->ports)];
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) {
return @fsockopen(
$this->host,
$this->selectedPort,
$errNo,
$errStr,
$this->connectionTimeout
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->maxRetries > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $this->totalTimeout,
'port' => $this->selectedPort,
]);
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,
'port' => $this->selectedPort,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $this->streamTimeout);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
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);
}
/**
* Reads a single response message and returns its payload. The protocol prepends the
* payload length as a 10 byte header, so the announced length is compared against what
* actually arrived: a peer closing mid-stream would otherwise yield a silently
* truncated body that only surfaces later as an unexplained parse failure.
*
* @param resource $socket
*
* @throws ApiClientException
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket): string
{
$response = '';
$readAttempts = 0;
$totalTimeout = $this->totalTimeout;
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,
'port' => $this->selectedPort,
]);
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,
'port' => $this->selectedPort,
]);
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,
'port' => $this->selectedPort,
]);
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,
'port' => $this->selectedPort,
]);
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
}
return $this->extractPayload($response);
}
/**
* @throws ApiClientException
*/
private function extractPayload(string $response): string
{
if (10 > strlen($response)) {
$this->logger->error('Incomplete response header received from API', [
'bytes_received' => strlen($response),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
}
$header = substr($response, 0, 10);
$announcedLength = (int) trim($header);
$payload = substr($response, 10);
if (1 > $announcedLength) {
$this->logger->error('Empty response body announced by API', [
'header' => trim($header),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
if (strlen($payload) !== $announcedLength) {
$this->logger->error('Truncated response received from API', [
'bytes_received' => strlen($payload),
'announced_length' => $announcedLength,
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
}
return $payload;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\XmlDumpWriter;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
/**
* The XML protocol around one BusPro command: authentication, serialization, dumps, the
* busy-retry and parsing. What a command contains is up to the caller.
*/
class XmlChannel
{
/** @var array<string, mixed> */
private array $config;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
private readonly XmlDumpWriter $dumpWriter,
private readonly LoggerInterface $logger,
private readonly RequestIdSequence $requestIds,
private readonly SocketTransport $transport,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
/**
* Sends command $type with $fields and returns the parsed response.
*
* @param array<string, mixed> $fields
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) {
$requestId = $this->requestIds->next();
$dump = $debug || true === $this->config['debug'];
$xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump);
return $this->parseResponse($type, $xml, $parserArgs, $requestId);
}, $type->value);
}
/**
* Like request(), but returns the unparsed response.
*
* @param array<string, mixed> $fields
*
* @throws ApiClientException
*/
public function requestRaw(MessageType $type, array $fields = []): string
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data) {
$requestId = $this->requestIds->next();
$body = $this->serializeXml($data);
return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']);
});
}
/**
* Sends a recorded request as is, except for the key, which is regenerated for the current
* date. Returns the unparsed response.
*
* @throws ApiClientException
*/
public function replay(string $xml, bool $debug = false): string
{
return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug));
}
/**
* The authentication every request starts with, followed by the command's own fields.
*
* @param array<string, mixed> $fields
*
* @return array<string, mixed>
*/
private function envelope(MessageType $type, array $fields): array
{
return [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($type->value),
'satz' => ['@typ' => $type->value],
...$fields,
];
}
private function createKey(string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type);
}
/**
* 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'];
$typeContext = null !== $type ? ['type' => $type] : [];
$lastException = null;
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
return $operation();
} catch (ImmediateConnectionCloseException $e) {
$lastException = $e;
if ($attempt < $maxAttempts) {
$this->logger->warning('BPN server busy, retrying request', [
'attempt' => $attempt,
'maxAttempts' => $maxAttempts,
'retryDelay' => $retryDelay,
...$typeContext,
]);
sleep($retryDelay);
}
}
}
$this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]);
throw $lastException;
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doReplay(string $xml, bool $debug): string
{
$requestId = $this->requestIds->next();
$doc = new \DOMDocument();
if (false === @$doc->loadXML($xml)) {
throw new ApiClientException('Invalid XML provided');
}
$satzNode = $doc->getElementsByTagName('satz')->item(0);
if (null === $satzNode) {
throw new ApiClientException('Missing <satz> element in XML');
}
$type = $satzNode->getAttribute('typ');
if ('' === $type) {
throw new ApiClientException('Missing typ attribute on <satz> element');
}
$keyNode = $doc->getElementsByTagName('key')->item(0);
if (null === $keyNode) {
throw new ApiClientException('Missing <key> element in XML');
}
$keyNode->nodeValue = $this->createKey($type);
return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']);
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string
{
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body);
}
$response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]);
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response);
}
return $response;
}
/**
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
{
try {
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if ($dumpOnFailure) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml);
}
$this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId,
'type' => $type->value,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
}
}
/** @param array<string, mixed> $data */
private function serializeXml(array $data): string
{
return $this->serializer->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
]);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired([
'bpn_username',
'bpn_password',
]);
$optionsResolver->setDefaults([
'debug' => false,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
return $optionsResolver->resolve($options);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\XmlLoader;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException;
@@ -19,7 +19,7 @@ class AgencyLoader
public function __construct(
private readonly CacheInterface $cache,
private readonly ApiClient $apiClient,
private readonly ReferenceDataClient $referenceDataClient,
private readonly LoggerInterface $logger,
) {
}
@@ -34,7 +34,7 @@ class AgencyLoader
// Cache for 24 hours (agencies change infrequently)
$item->expiresAfter(24 * 60 * 60);
$result = $this->apiClient->getAgencies();
$result = $this->referenceDataClient->getAgencies();
if ($result instanceof Notification) {
$this->logger->error('Failed to fetch agencies from BPN API', [
+32 -22
View File
@@ -2,8 +2,8 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser
@@ -14,10 +14,11 @@ class ApiResponseParser extends AbstractParser
}
/**
* @param array<string, mixed> $additionalArgs
*
* @throws ResponseParserException
*/
/** @param array<string, mixed> $additionalArgs */
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
public function parseXmlString(MessageType $type, string $xml, array $additionalArgs = []): mixed
{
$crawler = XmlCrawlerFactory::create($xml);
@@ -25,17 +26,20 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
}
// Override type unless present in XML to catch error responses
$responseType = $this
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type;
// The type in the XML wins, to catch error responses. BusPro may send one we don't know.
$typeAttribute = $this->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ'));
$responseType = null === $typeAttribute ? $type : MessageType::tryFrom($typeAttribute);
if (null === $responseType) {
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $typeAttribute));
}
$resultNode = $crawler->filterXPath('//ergebnis');
try {
switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION:
case MessageType::Notification:
return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA:
case MessageType::CustomerData:
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
if (null === $subType || '' === $subType) {
throw new ResponseParserException('Unable to determine customer data subtype from XML response');
@@ -63,23 +67,23 @@ class ApiResponseParser extends AbstractParser
}
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
case MessageType::Countries:
return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA:
case MessageType::MutableData:
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
case MessageType::Availability:
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED:
case MessageType::AvailabilityExtended:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
case MessageType::AvailabilityHotel:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
case MessageType::BookingUpdate:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING:
case MessageType::Booking:
return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS:
case MessageType::Products:
return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
case MessageType::ProductData:
$travelNode = $crawler->filterXPath('//reise/termin');
if (0 === $travelNode->count()) {
@@ -87,17 +91,23 @@ class ApiResponseParser extends AbstractParser
}
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
case MessageType::Agencies:
return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER:
case MessageType::PurchaseVoucher:
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
case MessageType::PromoVoucher:
return (new PromoVoucherParser())->parse($resultNode);
case MessageType::VoucherTemplates:
return (new VoucherTemplatesParser())->parse($resultNode);
case MessageType::VoucherPaymentMethods:
return (new VoucherPaymentMethodsParser())->parse($resultNode);
case MessageType::VoucherPreview:
return (new VoucherPreviewParser())->parse($resultNode);
case MessageType::VoucherSale:
return (new VoucherSaleResponseParser())->parse($resultNode);
}
} catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
}
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
}
}
@@ -41,6 +41,7 @@ class CrmAttributesResponseParser
1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN,
1487 => Role::SUPERVISOR,
];
/**
+17 -6
View File
@@ -3,9 +3,9 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\File;
use PhpZip\Exception\ZipException;
use PhpZip\ZipFile;
use Symfony\Component\DomCrawler\Crawler;
use ZipStream\Exception as ZipException;
use ZipStream\ZipStream;
class DocumentsParser
{
@@ -28,17 +28,28 @@ class DocumentsParser
}
// In case of multiple documents create ZIP file
$stream = fopen('php://temp', 'w+b');
try {
$zip = new ZipFile();
// Zero headers trip up some unzip clients, and the data is in memory anyway
$zip = new ZipStream(
outputStream: $stream,
defaultEnableZeroHeader: false,
sendHttpHeaders: false,
);
$documents->each(function (Crawler $node) use (&$zip) {
$documents->each(function (Crawler $node) use ($zip) {
[$filename, $pdfData] = $this->getFileInfo($node);
$zip->addFromString($filename.'.pdf', $pdfData);
$zip->addFile($filename.'.pdf', $pdfData);
});
return new File('reisedokumente.zip', $zip->outputAsString(), 'application/zip');
$zip->finish();
rewind($stream);
return new File('reisedokumente.zip', stream_get_contents($stream), 'application/zip');
} catch (ZipException $e) {
return null;
} finally {
fclose($stream);
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use Symfony\Component\DomCrawler\Crawler;
/**
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
*
* Deliberately separate from TravelParser, which builds the booking path's models and
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
* hotel-nested services and levels boarding-point prices. The product export filters and
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
* and returns plain arrays in source order.
*
* @see \App\Service\ProductExportBuilder
*/
class ProductExportParser extends AbstractParser
{
/**
* Every selection group on the date, not only the `Leistungen *` ones.
*
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
*/
public function getSelectionGroups(Crawler $dateNode): array
{
return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [
'busProId' => (int) $group->attr('idbuspro'),
'label' => $group->attr('bezeichnung'),
'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [
'busProId' => (int) $selection->attr('idbuspro'),
'label' => $selection->attr('bezeichnung'),
]),
]);
}
/**
* Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`.
*
* The per-list `id` attribute is not read: it restarts at 1 in every list, so it
* identifies nothing once a date-level and a hotel-level list are merged.
*
* @return list<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
*/
public function getServices(Crawler $serviceNodes): array
{
return $serviceNodes->each(fn (Crawler $service): array => [
'busProId' => (int) $service->attr('idbuspro'),
'subType' => $service->attr('unterart'),
'label' => $this->getStringOrNullValue($this->children($service, 'text')),
'price' => $this->getFloatOrNullValue($this->children($service, 'preis')),
'status' => $this->getStringOrNullValue($this->children($service, 'status')),
'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')),
]);
}
/**
* The hotel's rooms, without the PDGS pseudo room.
*
* `available` is a count of rooms, not persons. Occupancy is read case-insensitively:
* the travel export writes `MinPax`/`MaxPax`.
*
* @return list<array<string, mixed>>
*/
public function getRooms(Crawler $hotelNode): array
{
$rooms = [];
$this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) {
$code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'));
if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) {
return;
}
$rooms[] = [
'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'),
'code' => $code,
'label' => $this->getAttrOrNullValue($room, 'zimmertext'),
'category' => $this->getAttrOrNullValue($room, 'kat'),
'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'),
'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'),
'nights' => $this->getIntAttrOrNullValue($room, 'naechte'),
'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')),
'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'),
'status' => $this->getAttrOrNullValue($room, 'status'),
'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'),
'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'),
];
});
return $rooms;
}
/**
* The struck-through comparison price, carried by the PDGS pseudo room.
*/
public function getPseudoPrice(Crawler $hotelNode): ?float
{
foreach ($this->children($hotelNode, 'zimmer/preis') as $room) {
$room = new Crawler($room);
if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) {
return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis'));
}
}
return null;
}
/**
* The guide of the first outbound bus that has one.
*
* Not TravelParser::getGuide(), which takes the first guide of any bus, return leg
* included.
*
* @return array{name: ?string, phone: ?string}|null
*/
public function getGuide(Crawler $dateNode): ?array
{
$guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter');
if (0 === $guides->count()) {
return null;
}
$guide = $guides->first();
return [
'name' => $this->getStringOrNullValue($this->children($guide, 'name')),
'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')),
];
}
/**
* A path below the node's own children, never deeper.
*
* DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds
* nothing and `.//x` searches every descendant -- which would pull a hotel's
* `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the
* node itself, whatever its name.
*/
public function children(Crawler $node, string $path): Crawler
{
return $node->filterXPath('*/'.$path);
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPaymentMethod;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINZAHLUNGSARTEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINZAHLUNGSARTEN"></satz>
* <zahlungsarten>
* <zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
* </zahlungsarten>
* </ergebnis>
*/
final class VoucherPaymentMethodsParser extends AbstractParser
{
/**
* @return list<VoucherPaymentMethod>
*/
public function parse(Crawler $node): array
{
$paymentMethods = [];
$node->filterXPath('//zahlungsarten/zahlungsart')->each(function (Crawler $methodNode) use (&$paymentMethods): void {
$paymentMethods[] = new VoucherPaymentMethod(
id: (int) $this->getRequiredAttrValue($methodNode, 'id', 'voucher payment method'),
label: $this->getRequiredAttrValue($methodNode, 'bezeichnung', 'voucher payment method'),
type: $this->getRequiredAttrValue($methodNode, 'art', 'voucher payment method'),
);
});
return $paymentMethods;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPreview;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORSCHAU responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORSCHAU"/>
* <gutscheinpdf>data:application/pdf;base64,JVBERi0xLjQ…</gutscheinpdf>
* </ergebnis>
*/
final class VoucherPreviewParser extends AbstractParser
{
private const string DATA_URI_PREFIX = 'data:application/pdf;base64,';
public function parse(Crawler $node): VoucherPreview
{
$dataUri = trim($this->getStringOrNullValue($node->filterXPath('//gutscheinpdf')) ?? '');
if (!str_starts_with($dataUri, self::DATA_URI_PREFIX)) {
throw new \InvalidArgumentException('Missing or malformed PDF data URI in voucher preview response.');
}
$pdf = base64_decode(substr($dataUri, \strlen(self::DATA_URI_PREFIX)), true);
if (false === $pdf || '' === $pdf) {
throw new \InvalidArgumentException('Undecodable PDF in voucher preview response.');
}
return new VoucherPreview($pdf);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherSaleResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINBUCHUNG responses that are not a HINWEIS.
*
* Dry run (buchungsart Anfrage, measured on the test system 2026-10-01):
* <ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>
*
* Purchase (buchungsart Buchung, as documented by the vendor):
* <ergebnis>
* <satz typ="GUTSCHEINBUCHUNG"/>
* <buchung>erfolgt</buchung>
* <idgutschein/><gutscheinnummer/><einloesecode/><preis/><rechnungsnummer/><email/>
* </ergebnis>
*/
final class VoucherSaleResponseParser extends AbstractParser
{
public function parse(Crawler $node): VoucherSaleResponse
{
$status = trim($this->getStringOrNullValue($node->filterXPath('//buchung')) ?? '');
if ('' === $status) {
throw new \InvalidArgumentException('Missing required status in voucher sale response.');
}
return new VoucherSaleResponse(
status: $status,
voucherId: $this->getIntOrNullValue($node->filterXPath('//idgutschein')),
voucherNumber: $this->trimmedOrNull($node->filterXPath('//gutscheinnummer')),
redemptionCode: $this->trimmedOrNull($node->filterXPath('//einloesecode')),
price: $this->getFloatOrNullValue($node->filterXPath('//preis')),
invoiceNumber: $this->trimmedOrNull($node->filterXPath('//rechnungsnummer')),
email: $this->trimmedOrNull($node->filterXPath('//email')),
);
}
private function trimmedOrNull(Crawler $node): ?string
{
$value = trim($this->getStringOrNullValue($node) ?? '');
return '' !== $value ? $value : null;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherTemplate;
use App\BusProNet\Model\VoucherTemplates;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORLAGEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORLAGEN"></satz>
* <gutscheinminbetrag>10</gutscheinminbetrag>
* <gutscheinmaxbetrag>5000</gutscheinmaxbetrag>
* <gutscheine>
* <gutschein idgutscheinvorlage="3">
* <gutscheinart>K</gutscheinart>
* <bezeichnung>Allgemein</bezeichnung>
* <hinweis></hinweis>
* </gutschein>
* </gutscheine>
* </ergebnis>
*/
final class VoucherTemplatesParser extends AbstractParser
{
public function parse(Crawler $node): VoucherTemplates
{
$minAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinminbetrag'));
$maxAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinmaxbetrag'));
if (null === $minAmount || '' === trim($minAmount) || null === $maxAmount || '' === trim($maxAmount)) {
throw new \InvalidArgumentException('Missing voucher amount bounds in voucher templates response.');
}
$templates = [];
$node->filterXPath('//gutscheine/gutschein')->each(function (Crawler $templateNode) use (&$templates): void {
$note = $this->getStringOrNullValue($templateNode->filterXPath('//hinweis'));
$templates[] = new VoucherTemplate(
id: (int) $this->getRequiredAttrValue($templateNode, 'idgutscheinvorlage', 'voucher template'),
type: trim($this->getStringOrNullValue($templateNode->filterXPath('//gutscheinart')) ?? ''),
label: trim($this->getStringOrNullValue($templateNode->filterXPath('//bezeichnung')) ?? ''),
note: null !== $note && '' !== trim($note) ? trim($note) : null,
);
});
return new VoucherTemplates(
// whole euros: the flow only offers whole-euro amounts, so the bounds are kept as such
minAmount: (int) ceil($this->stringToFloat($minAmount)),
maxAmount: (int) floor($this->stringToFloat($maxAmount)),
templates: $templates,
);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp;
/**
* The site identifiers of the TYPO3 content feed, offered to the model as the `site` filter.
* ep-events and unichamp are retired and not in the feed.
*/
final class Sites
{
public const IDENTIFIERS = ['ep-reisen', 'ser', 'ski-boarderweek', 'ski-wochenenden', 'snowzone'];
public const DESCRIPTION = 'Restrict to one website: ep-reisen (ep-reisen.de), ser (semesterende-skireisen.de), ski-boarderweek, ski-wochenenden, snowzone (snowzone.net).';
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use App\Repository\ContentDocumentRepository;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'get_document',
description: 'Read the full text of one search_content hit, by its url.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetDocumentTool
{
private const BODY_LIMIT = 20000;
public function __construct(
private readonly ContentDocumentRepository $repository,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'A url returned by search_content.', minLength: 1)]
string $url,
): CallToolResult {
return $this->runner->run('get_document', get_defined_vars(), function () use ($url): array {
$document = $this->repository->findOneIndexedByUrl(trim($url));
if (null === $document) {
throw new ToolCallException('No document at this url. Use a url returned by search_content.');
}
$body = $document->getBodyMd();
$truncated = mb_strlen($body) > self::BODY_LIMIT;
return [
'type' => ContentSearch::publicType($document->getType()),
'title' => $document->getTitle(),
'url' => $document->getUrl(),
'site' => $document->getSite(),
'breadcrumb' => $document->getBreadcrumb(),
'body' => $truncated ? mb_substr($body, 0, self::BODY_LIMIT) : $body,
'truncated' => $truncated,
];
});
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'get_trip',
description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetTripTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Trip code as returned by search_trips, or a date code (dateCode) for one date of a trip.', minLength: 1)]
string $code,
#[Schema(description: 'Narrows the dates to one hotel when a trip has several.')]
?string $hotelCode = null,
): CallToolResult {
return $this->runner->run('get_trip', get_defined_vars(), function () use ($code, $hotelCode): array {
$trip = $this->catalog->get(trim($code), $hotelCode);
if (null === $trip) {
throw new ToolCallException(sprintf('No trip or date with code "%s". Use search_trips to find trip codes.', $code));
}
return $trip;
});
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_content',
description: "Search the websites' editorial content: destinations, hotels, travel information, FAQs, terms, blog articles, general company information. Use for questions that are not about specific dates or prices. Returns excerpts; use get_document for the full text of a hit.",
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchContentTool
{
public function __construct(
private readonly ContentSearch $search,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'What to look for, in German keywords works best, e.g. "Gepäck Snowboard Bus".', minLength: 1)]
string $query,
#[Schema(description: 'Restrict to one kind of content. destination = region, country or town.', enum: ['page', 'product', 'hotel', 'destination', 'concept', 'news'])]
?string $type = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of hits, default 5, at most 8.', minimum: 1, maximum: 8)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_content', get_defined_vars(), function () use ($query, $type, $site, $limit): array {
if ('' === trim($query)) {
throw new ToolCallException('query must not be empty.');
}
return ['results' => $this->search->search($query, $type, $site, max(1, min(8, $limit ?? 5)))];
});
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TravelPeriods;
use App\Chatbot\TripCatalog;
use App\Enum\HotelType;
use App\Enum\RoomType;
use App\Model\Chatbot\TripCriteria;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_trips',
description: 'The website\'s travel search: find bookable trips by travel period, destination, concept, price, group size, accommodation, board, room type, duration and bus. Use for any question about which trips exist, when, where or how much. Returns one result per trip, hotel and travel date with the "ab" price per person in EUR, nights, what is included, room types and a link; get_trip gives the details of one. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchTripsTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly TravelPeriods $periods,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
?string $query = null,
#[Schema(description: 'A named travel period of the website search: Vorsaison, um Weihnachten, Silvester, 1 Januarwoche, Ostern, Nachsaison. Use instead of dateFrom/dateTo.')]
?string $period = null,
#[Schema(description: 'Earliest start date, YYYY-MM-DD. Trips must start on or after it.', format: 'date')]
?string $dateFrom = null,
#[Schema(description: 'Latest end date, YYYY-MM-DD. Trips must end on or before it.', format: 'date')]
?string $dateTo = null,
#[Schema(description: 'Country name, e.g. "Frankreich".')]
?string $country = null,
#[Schema(description: 'Region, ski area or town name, e.g. "Les 3 Vallées".')]
?string $region = null,
#[Schema(description: 'Trip concept code or name, e.g. Sportclub, Ski-Kurztrips, Semesterende-Skireisen.')]
?string $concept = null,
#[Schema(description: 'Lowest "ab" price per person in EUR.', minimum: 0)]
?float $priceMin = null,
#[Schema(description: 'Highest "ab" price per person in EUR.', minimum: 0)]
?float $priceMax = null,
#[Schema(description: 'Group size: only dates bookable for this many people with enough places left.', minimum: 1)]
?int $persons = null,
#[Schema(description: 'Accommodation type.', enum: ['Ferienwohnung', 'Sportclub', 'Apartment', 'Gästehaus', 'Pension'])]
?string $accommodation = null,
#[Schema(description: 'Included meals, matched against the board items by word: "Abendessen" (dinner; with "Frühstück" this is half board), "Frühstück", "Lunchpaket", "Vollpension", "Selbstversorger". "Halbpension" itself does not occur.')]
?string $board = null,
#[Schema(description: 'Room type by capacity: EZ (single), DZ (double), 3er-8er, 9er+, or "mit anderen" (a bed shared with other guests).', enum: ['EZ', 'DZ', '3er', '4er', '5er', '6er', '7er', '8er', '9er+', 'mit anderen'])]
?string $roomType = null,
#[Schema(description: 'Minimum number of nights. The website\'s "long" trips are 5 or more.', minimum: 0)]
?int $nightsMin = null,
#[Schema(description: 'Maximum number of nights. The website\'s "short" trips are 4 or fewer.', minimum: 0)]
?int $nightsMax = null,
#[Schema(description: 'true for dates with a bookable bus, false for dates without one.')]
?bool $bus = null,
#[Schema(description: 'true for day trips only, false to exclude them.')]
?bool $daytrip = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of results, default 10, at most 25.', minimum: 1, maximum: 25)]
?int $limit = null,
): CallToolResult {
$arguments = get_defined_vars();
return $this->runner->run('search_trips', $arguments, function () use ($query, $period, $dateFrom, $dateTo, $country, $region, $concept, $priceMin, $priceMax, $persons, $accommodation, $board, $roomType, $nightsMin, $nightsMax, $bus, $daytrip, $site, $limit): array {
[$from, $to] = $this->period($period, $dateFrom, $dateTo);
return $this->catalog->search(new TripCriteria(
query: $this->text($query),
dateFrom: $from,
dateTo: $to,
country: $this->text($country),
region: $this->text($region),
concept: $this->text($concept),
priceMin: $priceMin,
priceMax: $priceMax,
persons: $persons,
accommodation: null === $accommodation ? null : (HotelType::fromLabel($accommodation) ?? throw new ToolCallException('Unknown accommodation type.')),
board: $this->text($board),
roomType: null === $roomType ? null : (RoomType::tryFrom($roomType) ?? throw new ToolCallException('Unknown room type.')),
nightsMin: $nightsMin,
nightsMax: $nightsMax,
bus: $bus,
daytrip: $daytrip,
site: $site,
limit: max(1, min(25, $limit ?? 10)),
));
});
}
/**
* @return array{?\DateTimeImmutable, ?\DateTimeImmutable}
*/
private function period(?string $period, ?string $dateFrom, ?string $dateTo): array
{
if (null !== $this->text($period)) {
if (null !== $this->text($dateFrom) || null !== $this->text($dateTo)) {
throw new ToolCallException('Use either period or dateFrom/dateTo, not both.');
}
$range = $this->periods->find($period)
?? throw new ToolCallException(sprintf('Unknown or past period "%s". Current periods: %s.', $period, implode(', ', $this->periods->labels()) ?: 'none'));
return [$range['from'], $range['to']];
}
$from = $this->date($dateFrom, 'dateFrom');
$to = $this->date($dateTo, 'dateTo');
if (null !== $from && null !== $to && $from > $to) {
throw new ToolCallException('dateFrom must not be after dateTo.');
}
return [$from, $to];
}
private function date(?string $value, string $name): ?\DateTimeImmutable
{
if (null === $value || '' === trim($value)) {
return null;
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
if (false === $date || $date->format('Y-m-d') !== trim($value)) {
throw new ToolCallException(sprintf('%s must be a date in the form YYYY-MM-DD.', $name));
}
return $date;
}
private function text(?string $value): ?string
{
return null === $value || '' === trim($value) ? null : trim($value);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Result\CallToolResult;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\RateLimiter\RateLimiterFactory;
/**
* What every chatbot tool call goes through: the per-client rate limit, the call log, and
* the answer as compact JSON.
*
* The SDK would pretty-print an array result and repeat it as structured content; both
* cost the model tokens and neither helps n8n. The log records tool, arguments, result
* count, duration and the calling client (n8n, claude-desktop, …), never anything about
* the person chatting, which n8n does not send.
*/
class ToolCallRunner
{
public function __construct(
private readonly RateLimiterFactory $chatbotMcpLimiter,
private readonly Security $security,
private readonly LoggerInterface $chatbotLogger,
) {
}
/**
* @param array<string, mixed> $arguments as received, for the log
* @param callable(): array<string, mixed> $call
*
* @throws ToolCallException invalid input or rate limit; the model sees the message
*/
public function run(string $tool, array $arguments, callable $call): CallToolResult
{
$arguments = array_filter($arguments, static fn (mixed $value): bool => null !== $value);
$client = $this->security->getUser()?->getUserIdentifier() ?? 'anonymous';
$limit = $this->chatbotMcpLimiter->create($client)->consume();
if (!$limit->isAccepted()) {
$this->chatbotLogger->warning('MCP tool call rate-limited', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments]);
throw new ToolCallException(sprintf('Too many tool calls. Retry after %s.', $limit->getRetryAfter()->format('H:i:s')));
}
$started = hrtime(true);
try {
$result = $call();
} catch (ToolCallException $e) {
$this->chatbotLogger->info('MCP tool call rejected', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'error' => $e->getMessage()]);
throw $e;
} catch (\Throwable $e) {
$this->chatbotLogger->error('MCP tool call failed', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'exception' => $e]);
throw $e;
}
$this->chatbotLogger->info('MCP tool call', [
'client' => $client,
'tool' => $tool,
'arguments' => $arguments,
'resultCount' => is_array($result['results'] ?? null) ? count($result['results']) : 1,
'durationMs' => (int) ((hrtime(true) - $started) / 1_000_000),
]);
return new CallToolResult([new TextContent(json_encode($result, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE))]);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
use App\Repository\ContentDocumentRepository;
/**
* Full-text search over the mirrored website content.
*
* Ranked by relevance, weighted by type in the order people need them: what they book
* (products, hotels), where they go (destinations, concepts), general pages, then blog
* posts. The weights are multipliers on the relevance score, so a much better match of a
* lower type still wins.
*/
class ContentSearch
{
/**
* The tool's `type` values. `destination` covers region, country and city.
*/
public const TYPES = [
'page' => ['page'],
'product' => ['product'],
'hotel' => ['hotel'],
'destination' => ['region', 'country', 'city'],
'concept' => ['concept'],
'news' => ['news'],
];
private const TYPE_WEIGHTS = [
'product' => 1.4,
'hotel' => 1.4,
'region' => 1.2,
'country' => 1.2,
'city' => 1.2,
'concept' => 1.2,
'page' => 1.0,
'news' => 0.8,
];
public function __construct(private readonly ContentDocumentRepository $repository)
{
}
/**
* @param string|null $type a key of TYPES
*
* @return list<array{type: string, title: string, url: string, site: ?string, breadcrumb: ?string, excerpt: string}>
*/
public function search(string $query, ?string $type, ?string $site, int $limit): array
{
$booleanQuery = ContentSearchQuery::booleanQuery($query);
if (null === $booleanQuery) {
return [];
}
$rows = $this->repository->searchFulltext($booleanQuery, null === $type ? [] : self::TYPES[$type], $site, self::TYPE_WEIGHTS, $limit);
$terms = ContentSearchQuery::terms($query);
return array_map(static fn (array $row): array => [
'type' => self::publicType($row['type']),
'title' => $row['title'],
'url' => $row['url'],
'site' => $row['site'],
'breadcrumb' => $row['breadcrumb'],
'excerpt' => Excerpt::around($row['body_md'], $terms),
], $rows);
}
/**
* The tool's name for a stored type.
*/
public static function publicType(string $type): string
{
foreach (self::TYPES as $public => $types) {
if (in_array($type, $types, true)) {
return $public;
}
}
return $type;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
/**
* Turns free text into a MySQL boolean-mode full-text query.
*
* InnoDB has no German stemming, so every term becomes a prefix match (`skipass*` finds
* Skipässe no better, but finds Skipassversicherung). Terms are optional rather than
* required: a question rarely uses the page's own words for everything, and the relevance
* score already ranks documents matching more terms higher. German stopwords are dropped
* here, since the server's stopword list is English.
*/
final class ContentSearchQuery
{
/** InnoDB's default innodb_ft_min_token_size; shorter tokens are not indexed. */
private const MIN_TOKEN_LENGTH = 3;
private const MAX_TERMS = 12;
private const STOPWORDS = [
'aber', 'alle', 'allem', 'allen', 'aller', 'alles', 'als', 'also', 'am', 'an', 'ander', 'andere', 'anderen',
'auch', 'auf', 'aus', 'bei', 'beim', 'bin', 'bis', 'bist', 'da', 'damit', 'dann', 'das', 'dass', 'dein',
'deine', 'dem', 'den', 'denn', 'der', 'des', 'dessen', 'die', 'dies', 'diese', 'diesem', 'diesen', 'dieser',
'dieses', 'doch', 'dort', 'du', 'durch', 'ein', 'eine', 'einem', 'einen', 'einer', 'eines', 'einige', 'er',
'es', 'etwas', 'euch', 'euer', 'eure', 'für', 'gibt', 'gib', 'hab', 'habe', 'haben', 'hast', 'hat', 'hatte',
'hier', 'hin', 'ich', 'ihr', 'ihre', 'ihrem', 'ihren', 'ihrer', 'im', 'in', 'ins', 'ist', 'ja', 'jede',
'jedem', 'jeden', 'jeder', 'jetzt', 'kann', 'kannst', 'kein', 'keine', 'können', 'könnt', 'man', 'mein',
'meine', 'mich', 'mir', 'mit', 'muss', 'musst', 'nach', 'nicht', 'nichts', 'noch', 'nun', 'nur', 'ob', 'oder',
'ohne', 'sehr', 'sein', 'seine', 'sich', 'sie', 'sind', 'so', 'soll', 'sollte', 'über', 'um', 'und', 'uns',
'unser', 'unsere', 'unter', 'viel', 'vom', 'von', 'vor', 'war', 'waren', 'was', 'weil', 'welche', 'welchem',
'welchen', 'welcher', 'welches', 'wenn', 'wer', 'werde', 'werden', 'wie', 'wieder', 'will', 'wir', 'wird',
'wo', 'woher', 'wohin', 'zu', 'zum', 'zur', 'zwischen', 'bitte', 'gerne', 'euren', 'eurem', 'eurer',
];
/**
* The terms of a query: lower-cased, stopwords and short tokens dropped, unique.
*
* @return list<string>
*/
public static function terms(string $text): array
{
$tokens = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
$terms = [];
foreach ($tokens as $token) {
if (mb_strlen($token) < self::MIN_TOKEN_LENGTH || in_array($token, self::STOPWORDS, true)) {
continue;
}
$terms[$token] = true;
}
return array_slice(array_keys($terms), 0, self::MAX_TERMS);
}
/**
* The boolean-mode query, or null when nothing searchable is left.
*/
public static function booleanQuery(string $text): ?string
{
$terms = self::terms($text);
return [] === $terms ? null : implode(' ', array_map(static fn (string $term): string => $term.'*', $terms));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
/**
* Cuts a window of about LENGTH characters out of a document, around its first match.
*
* The excerpt keeps search results small; get_document fetches the rest.
*/
final class Excerpt
{
public const LENGTH = 600;
/**
* @param list<string> $terms lower-cased, matched as prefixes like the full-text search
*/
public static function around(string $text, array $terms, int $length = self::LENGTH): string
{
$text = trim((string) preg_replace('/\s+/u', ' ', $text));
$textLength = mb_strlen($text);
if ($textLength <= $length) {
return $text;
}
$position = null;
$lower = mb_strtolower($text);
foreach ($terms as $term) {
$found = mb_strpos($lower, $term);
if (false !== $found && (null === $position || $found < $position)) {
$position = $found;
}
}
// Start a third of the window before the match, so it is read in context.
$start = max(0, min(($position ?? 0) - intdiv($length, 3), $textLength - $length));
$excerpt = mb_substr($text, $start, $length);
if ($start > 0) {
$space = mb_strpos($excerpt, ' ');
$excerpt = '…'.(false !== $space && $space < 40 ? mb_substr($excerpt, $space + 1) : $excerpt);
}
if ($start + $length < $textLength) {
$space = mb_strrpos($excerpt, ' ');
$excerpt = (false !== $space && mb_strlen($excerpt) - $space < 40 ? mb_substr($excerpt, 0, $space) : $excerpt).'…';
}
return $excerpt;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Chatbot;
use Symfony\Component\Clock\ClockInterface;
/**
* The named travel periods of the website search ("Reisezeitraum"), from the
* `chatbot_travel_periods` parameter.
*
* Duplicated from the TYPO3 search configuration on purpose, and updated together with it
* each season. Periods may overlap. A period whose end has passed is no longer offered.
*/
class TravelPeriods
{
/**
* @param list<array{label: string, from: string, to: string}> $periods
*/
public function __construct(
private readonly array $periods,
private readonly ClockInterface $clock,
) {
}
/**
* The date range of a current period, matched case-insensitively, or null.
*
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null
*/
public function find(string $label): ?array
{
foreach ($this->current() as $period) {
if (0 === strcasecmp($period['label'], trim($label))) {
return ['from' => new \DateTimeImmutable($period['from']), 'to' => new \DateTimeImmutable($period['to'])];
}
}
return null;
}
/**
* @return list<string>
*/
public function labels(): array
{
return array_column($this->current(), 'label');
}
/**
* @return list<array{label: string, from: string, to: string}>
*/
private function current(): array
{
$today = $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
return array_values(array_filter($this->periods, static fn (array $period): bool => $period['to'] >= $today));
}
}
+658
View File
@@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
namespace App\Chatbot;
use App\BusProNet\Utility\DateCodeUtility;
use App\Chatbot\Search\ContentSearchQuery;
use App\Entity\ContentDocument;
use App\Enum\HotelType;
use App\Enum\RoomType;
use App\Model\Chatbot\TravelDateFacts;
use App\Model\Chatbot\TripCriteria;
use App\Model\Export;
use App\Repository\ContentDocumentRepository;
use App\Service\ProductExportBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
/**
* Trips as the chatbot sees them: TYPO3's editorial product joined to our dates, rooms,
* prices and pickups from the product export.
*
* The join is on the product code; several TYPO3 products may share one, and each is its
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
* `code` it is.
*
* search() mirrors the website's travel search, get() details one trip. A room is bookable
* unless its status is `Buchungsstop`; places and prices are derived per date x hotel by
* TravelDateFacts. Room counts never leave this class: the website does not show them, so
* the tools say only whether a date is freely bookable or on request.
*/
class TripCatalog
{
/** The feed's only language for now; trips are joined in it. */
public const LANGUAGE = 'de';
private const STATUS_FREE = 'Frei';
private const DATES_WITH_PICKUPS = 3;
private const BODY_LIMIT = 20000;
private const HOTEL_BODY_LIMIT = 8000;
private const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept'];
public function __construct(
private readonly ProductExportBuilder $exportBuilder,
private readonly ContentDocumentRepository $repository,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
}
/**
* The website's travel search: one result per product x hotel x travel date, filtered and
* priced as the search page does (see TravelDateFacts and TripCriteria), sorted by date,
* then price.
*
* @return array{results: list<array<string, mixed>>, total: int, dataAsOf: string}
*/
public function search(TripCriteria $criteria): array
{
$catalog = $this->load();
$today = $this->today();
$results = [];
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
$header = null;
foreach ($catalog['rowsByCode'][$this->normalize($product->getData()['code'] ?? null)] ?? [] as $row) {
if ($row['date']['from'] < $today) {
continue;
}
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
$hotelData = $hotel?->getData() ?? [];
$facts = TravelDateFacts::fromExportRow($row);
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
continue;
}
$header ??= $this->tripHeader($catalog, $product);
$results[] = [
'product' => $header['name'],
'code' => $header['code'],
'dateCode' => $row['date']['code'],
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
'destination' => $header['destination'],
'concept' => $header['concept'],
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'nights' => $facts->nights,
'priceFrom' => $facts->priceFrom,
'includes' => $facts->includes(),
'roomTypes' => array_map(static fn (RoomType $type): string => $type->value, $facts->roomTypes),
'availability' => $facts->availability,
'flags' => array_values(array_filter([
true === ($hotelData['new'] ?? null) ? 'neu' : null,
true === ($hotelData['earlybird'] ?? null) ? 'frühbucher' : null,
$header['daytrip'] ? 'tagesfahrt' : null,
])),
'url' => $header['url'],
'site' => $header['site'],
];
}
}
usort($results, static fn (array $a, array $b): int => [$a['dateFrom'], $a['priceFrom'] ?? \PHP_FLOAT_MAX, $a['product'], $a['hotel']]
<=> [$b['dateFrom'], $b['priceFrom'] ?? \PHP_FLOAT_MAX, $b['product'], $b['hotel']]);
return [
'results' => array_slice($results, 0, $criteria->limit),
'total' => count($results),
'dataAsOf' => $this->dataAsOf($catalog['export']),
];
}
/**
* Everything about one trip, or null for a code no published product carries.
*
* Takes a product code or a date code. A date code is what customers read on their
* booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to
* that date, shown even when it has already started.
*
* @return array<string, mixed>|null
*/
public function get(string $code, ?string $hotelCode = null): ?array
{
$catalog = $this->load();
$code = (new DateCodeUtility())->sanitize(trim($code));
$dateCode = null;
if (!isset($catalog['productsByCode'][$code])) {
foreach ($catalog['export']->rows as $row) {
if ($this->normalize($row['date']['code'] ?? null) === $code) {
$dateCode = $code;
$code = $this->normalize($row['product']['code'] ?? null);
break;
}
}
}
$products = $catalog['productsByCode'][$code] ?? [];
if ([] === $products) {
return null;
}
usort($products, static fn (ContentDocument $a, ContentDocument $b): int => [null === $a->getUrl(), $a->getUid()] <=> [null === $b->getUrl(), $b->getUid()]);
$product = $products[0];
$today = $this->today();
$rows = array_values(array_filter(
$catalog['rowsByCode'][$code] ?? [],
fn (array $row): bool => null === $dateCode
? $row['date']['from'] >= $today
: $this->normalize($row['date']['code'] ?? null) === $dateCode,
));
if (null !== $hotelCode && '' !== trim($hotelCode)) {
$wanted = $this->hotelCodesFor($catalog, $hotelCode);
$rows = array_values(array_filter($rows, fn (array $row): bool => in_array($this->normalize($row['hotel']['code']), $wanted, true)));
}
usort($rows, static fn (array $a, array $b): int => [$a['date']['from'], $a['hotel']['code']] <=> [$b['date']['from'], $b['hotel']['code']]);
$hotelCodes = array_values(array_unique(array_map(fn (array $row): string => $this->normalize($row['hotel']['code']), $rows)));
return $this->tripHeader($catalog, $product) + [
'alsoPublishedAt' => array_values(array_map(
static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()],
array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()),
)),
'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT),
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product),
'dates' => $this->dates($catalog, $rows),
'dataAsOf' => $this->dataAsOf($catalog['export']),
];
}
/**
* Logs the codes the join cannot place, on both sides. The same gaps the website's
* product import tolerates, worth a periodic look.
*/
public function logUnmatchedCodes(): void
{
$catalog = $this->load();
$exportProducts = array_keys($catalog['rowsByCode']);
$contentProducts = array_keys($catalog['productsByCode']);
$exportHotels = [];
foreach ($catalog['export']->rows as $row) {
$exportHotels[$this->normalize($row['hotel']['code'] ?? null)] = true;
}
unset($exportHotels['']);
$ambiguous = [];
foreach ($catalog['hotelsByCode'] as $hotelCode => $hotels) {
if (count($hotels) > 1) {
$ambiguous[$hotelCode] = array_map(static fn (ContentDocument $hotel): int => $hotel->getUid(), $hotels);
}
}
$context = [
'exportProductsWithoutContent' => array_values(array_diff($exportProducts, $contentProducts)),
'contentProductsWithoutDates' => array_values(array_diff($contentProducts, $exportProducts)),
'exportHotelsWithoutContent' => array_values(array_diff(array_keys($exportHotels), array_keys($catalog['hotelsByCode']))),
'ambiguousHotelCodes' => $ambiguous,
];
$this->logger->info('Content feed: unmatched codes', $context + [
'counts' => array_map('count', $context),
]);
}
/**
* @return array{
* export: Export,
* records: array<string, ContentDocument>,
* productsByCode: array<string, list<ContentDocument>>,
* hotelsByCode: array<string, list<ContentDocument>>,
* rowsByCode: array<string, list<array<string, mixed>>>,
* }
*/
private function load(): array
{
$export = $this->exportBuilder->build();
$records = $this->repository->findRecords(self::RECORD_TYPES, self::LANGUAGE);
$productsByCode = [];
$hotelsByCode = [];
foreach ($records as $record) {
$data = $record->getData() ?? [];
if ('product' === $record->getType() && '' !== ($code = $this->normalize($data['code'] ?? null))) {
$productsByCode[$code][] = $record;
}
if ('hotel' === $record->getType()) {
$codes = is_array($data['codes'] ?? null) ? $data['codes'] : [];
$codes[] = $data['code'] ?? null;
foreach (array_unique(array_map($this->normalize(...), $codes)) as $code) {
if ('' !== $code) {
$hotelsByCode[$code][] = $record;
}
}
}
}
// A code that is one hotel's own and another's alias resolves to the owner.
foreach ($hotelsByCode as $code => $hotels) {
usort($hotels, fn (ContentDocument $a, ContentDocument $b): int => [$this->normalize($a->getData()['code'] ?? null) !== $code, $a->getUid()] <=> [$this->normalize($b->getData()['code'] ?? null) !== $code, $b->getUid()]);
$hotelsByCode[$code] = $hotels;
}
$rowsByCode = [];
foreach ($export->rows as $row) {
$code = $this->normalize($row['product']['code'] ?? null);
if ('' !== $code) {
$rowsByCode[$code][] = $row;
}
}
return [
'export' => $export,
'records' => $records,
'productsByCode' => $productsByCode,
'hotelsByCode' => $hotelsByCode,
'rowsByCode' => $rowsByCode,
];
}
/**
* Published products narrowed by everything the content index can resolve: site, day
* trip, concept, destination and free text.
*
* @param array<string, mixed> $catalog
*
* @return list<ContentDocument>
*/
private function candidateProducts(array $catalog, TripCriteria $criteria): array
{
/** @var array<string, ContentDocument> $records */
$records = $catalog['records'];
$conceptUids = null === $criteria->concept ? null : $this->matchingUids($records, ['concept'], $criteria->concept, matchCode: true);
$countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true);
$regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region);
$cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region);
$queryUids = null === $criteria->query ? null : $this->productUidsMatchingText($records, $criteria->query);
$products = [];
foreach ($records as $record) {
if ('product' !== $record->getType() || null === $record->getUrl()) {
continue;
}
$data = $record->getData() ?? [];
// Closed group trips and staff trainings are not offered; get() still answers
// someone who names their code.
if (false === ($data['bookable'] ?? null)
|| $record->isHiddenFromSearch()
|| (null !== $criteria->site && $record->getSite() !== $criteria->site)
|| (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip)
|| (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true))
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))
|| (null !== $queryUids && !in_array($record->getUid(), $queryUids, true))) {
continue;
}
if (null !== $regionUids) {
$productRegions = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
if ([] === array_intersect($productRegions, $regionUids) && !in_array($data['cityUid'] ?? null, $cityUids ?? [], true)) {
continue;
}
}
$products[] = $record;
}
return $products;
}
/**
* Uids of the records of the given types whose name contains the text, or whose code
* equals it.
*
* @param array<string, ContentDocument> $records
* @param list<string> $types
*
* @return list<int>
*/
private function matchingUids(array $records, array $types, string $text, bool $matchCode = false): array
{
$needle = mb_strtolower(trim($text));
$uids = [];
foreach ($records as $record) {
if (!in_array($record->getType(), $types, true)) {
continue;
}
$name = mb_strtolower($record->getTitle());
$code = $this->normalize($record->getData()['code'] ?? null);
if (('' !== $name && (str_contains($name, $needle) || str_contains($needle, $name)))
|| ($matchCode && '' !== $code && $code === $this->normalize($text))) {
$uids[] = $record->getUid();
}
}
return $uids;
}
/**
* Products reached by a full-text match on themselves or on a hotel, destination or
* concept they belong to.
*
* @param array<string, ContentDocument> $records
*
* @return list<int>
*/
private function productUidsMatchingText(array $records, string $text): array
{
$booleanQuery = ContentSearchQuery::booleanQuery($text);
if (null === $booleanQuery) {
return array_values(array_map(static fn (ContentDocument $record): int => $record->getUid(), array_filter($records, static fn (ContentDocument $record): bool => 'product' === $record->getType())));
}
$matched = [];
foreach ($this->repository->matchRecords($booleanQuery, self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
}
$uids = [];
foreach ($records as $record) {
if ('product' !== $record->getType()) {
continue;
}
$data = $record->getData() ?? [];
$hotelUids = is_array($data['hotelUids'] ?? null) ? $data['hotelUids'] : [];
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
$hit = isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($hotelUids, array_keys($matched['hotel'] ?? []))
|| [] !== array_intersect($regionUids, array_keys($matched['region'] ?? []))
|| isset($matched['country'][$data['countryUid'] ?? -1])
|| isset($matched['city'][$data['cityUid'] ?? -1])
|| isset($matched['concept'][$data['conceptUid'] ?? -1]);
// Hotels also name their products, for products whose hotelUids miss them.
if (!$hit) {
foreach (array_keys($matched['hotel'] ?? []) as $hotelUid) {
$hotelProducts = ($records['hotel:'.$hotelUid] ?? null)?->getData()['productUids'] ?? [];
if (is_array($hotelProducts) && in_array($record->getUid(), $hotelProducts, true)) {
$hit = true;
break;
}
}
}
if ($hit) {
$uids[] = $record->getUid();
}
}
return $uids;
}
/**
* The search page's filters on one travel date at one hotel. `persons` checks both the
* page's condition (the smallest group a room takes) and that enough places are left.
*
* @param array<string, mixed> $row
*/
private function matches(array $row, TravelDateFacts $facts, ?HotelType $hotelType, TripCriteria $criteria): bool
{
if (!$facts->isBookable()) {
return false;
}
if ((null !== $criteria->dateFrom && $row['date']['from'] < $criteria->dateFrom->format('Y-m-d'))
|| (null !== $criteria->dateTo && $row['date']['to'] > $criteria->dateTo->format('Y-m-d'))) {
return false;
}
if ((null !== $criteria->priceMin || null !== $criteria->priceMax)
&& (null === $facts->priceFrom
|| (null !== $criteria->priceMin && $facts->priceFrom < $criteria->priceMin)
|| (null !== $criteria->priceMax && $facts->priceFrom > $criteria->priceMax))) {
return false;
}
if (null !== $criteria->persons && (($facts->minPax ?? 0) > $criteria->persons || $facts->places < $criteria->persons)) {
return false;
}
if (null !== $criteria->board) {
$wanted = mb_strtolower($criteria->board);
$boards = array_filter($facts->boards, static fn (string $board): bool => str_contains(mb_strtolower($board), $wanted));
if ([] === $boards) {
return false;
}
}
return (null === $criteria->accommodation || $hotelType === $criteria->accommodation)
&& (null === $criteria->roomType || $facts->hasRoomType($criteria->roomType))
&& (null === $criteria->nightsMin || $facts->nights >= $criteria->nightsMin)
&& (null === $criteria->nightsMax || $facts->nights <= $criteria->nightsMax)
&& (null === $criteria->bus || $facts->busAvailable === $criteria->bus);
}
/**
* @param array<string, mixed> $catalog
*
* @return array<string, mixed>
*/
private function tripHeader(array $catalog, ContentDocument $product): array
{
$data = $product->getData() ?? [];
$name = fn (string $type, mixed $uid): ?string => is_int($uid) ? ($catalog['records'][$type.':'.$uid] ?? null)?->getTitle() : null;
return [
'code' => $data['code'] ?? null,
'name' => $product->getTitle(),
'url' => $product->getUrl(),
'site' => $product->getSite(),
'destination' => array_filter([
'country' => $name('country', $data['countryUid'] ?? null),
'region' => $name('region', $data['regionUid'] ?? null),
'city' => $name('city', $data['cityUid'] ?? null),
]),
'concept' => $name('concept', $data['conceptUid'] ?? null),
'daytrip' => (bool) ($data['daytrip'] ?? false),
];
}
/**
* @param array<string, mixed> $catalog
* @param list<string> $hotelCodes normalized codes of the hotels with dates
*
* @return list<array<string, mixed>>
*/
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): array
{
$hotels = [];
foreach ($hotelCodes as $code) {
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
if (null !== $hotel) {
$hotels[$hotel->getUid()] = $hotel;
}
}
// A trip without upcoming dates still describes its hotels.
if ([] === $hotels) {
foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) {
$hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null;
if (null !== $hotel) {
$hotels[$hotel->getUid()] = $hotel;
}
}
}
return array_values(array_map(fn (ContentDocument $hotel): array => [
'code' => $hotel->getData()['code'] ?? null,
'name' => $hotel->getTitle(),
'url' => $hotel->getUrl(),
'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [],
'description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT),
], $hotels));
}
/**
* Upcoming dates with their bookable rooms per hotel. Pickups for the first few only:
* they rarely differ between dates and would otherwise dominate the answer.
*
* @param array<string, mixed> $catalog
* @param list<array<string, mixed>> $rows sorted by date
*
* @return list<array<string, mixed>>
*/
private function dates(array $catalog, array $rows): array
{
$dates = [];
foreach ($rows as $row) {
$dateId = $row['date']['busProId'];
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'])][0] ?? null;
$rooms = [];
foreach ($row['rooms'] ?? [] as $room) {
if (TravelDateFacts::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
$rooms[] = array_filter([
'room' => $room['label'] ?? null,
'board' => $room['boardCode'] ?? null,
'nights' => $room['nights'] ?? null,
'price' => $room['price'] ?? null,
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? TravelDateFacts::AVAILABILITY_FREE : TravelDateFacts::AVAILABILITY_ON_REQUEST,
], static fn (mixed $value): bool => null !== $value);
}
$dates[$dateId] ??= [
'dateCode' => $row['date']['code'],
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'hotels' => [],
'pickups' => $this->pickups($row),
];
$dates[$dateId]['hotels'][] = [
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotelCode' => $row['hotel']['code'],
'rooms' => $rooms,
'bookable' => [] !== $rooms,
];
}
$dates = array_values($dates);
foreach ($dates as $i => $date) {
if ($i >= self::DATES_WITH_PICKUPS) {
unset($dates[$i]['pickups']);
}
}
return $dates;
}
/**
* @param array<string, mixed> $row
*
* @return list<array<string, mixed>>
*/
private function pickups(array $row): array
{
$pickups = [];
foreach ($row['pickups'] ?? [] as $pickup) {
if (0 === ($pickup['busProId'] ?? 0) || null === ($pickup['city'] ?? null)) {
continue;
}
$pickups[] = array_filter([
'city' => $pickup['city'],
'place' => $pickup['street'] ?? null,
// A clock reading without an offset, as BusPro states it.
'departure' => is_string($pickup['datetime'] ?? null) ? str_replace('T', ' ', substr($pickup['datetime'], 0, 16)) : null,
'surcharge' => $pickup['price'] ?? null,
], static fn (mixed $value): bool => null !== $value);
}
return $pickups;
}
/**
* Normalized codes of the hotel a code names: all its own codes when TYPO3 knows the
* hotel, else just the code.
*
* @param array<string, mixed> $catalog
*
* @return list<string>
*/
private function hotelCodesFor(array $catalog, string $hotelCode): array
{
$hotel = $catalog['hotelsByCode'][$this->normalize($hotelCode)][0] ?? null;
if (null === $hotel) {
return [$this->normalize($hotelCode)];
}
$codes = is_array($hotel->getData()['codes'] ?? null) ? $hotel->getData()['codes'] : [];
$codes[] = $hotel->getData()['code'] ?? null;
return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes))));
}
private function dataAsOf(Export $export): string
{
return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c');
}
private function truncate(string $text, int $limit): string
{
if (mb_strlen($text) <= $limit) {
return $text;
}
return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]";
}
private function today(): string
{
return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
}
private function normalize(mixed $code): string
{
return is_string($code) ? strtoupper(trim($code)) : '';
}
}
+6 -4
View File
@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
@@ -78,7 +79,8 @@ class BookingRepairDraftCommand extends Command
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly TravelClient $travelClient,
private readonly Crypt $crypt,
private readonly TravelDataProvider $travelDataProvider,
private readonly BookingDataProcessor $bookingDataProcessor,
@@ -214,7 +216,7 @@ class BookingRepairDraftCommand extends Command
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
{
$result = $this->apiClient->getBooking(
$result = $this->bookingClient->getBooking(
(string) $user->getEmail(),
$this->crypt->decrypt((string) $user->getPassword()),
$bookingId,
@@ -300,7 +302,7 @@ class BookingRepairDraftCommand extends Command
}
}
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
$live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
if ($live instanceof Notification) {
$io->warning(sprintf(
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
@@ -29,7 +29,7 @@ class BpnFetchTravelCommand extends Command
];
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -56,7 +56,7 @@ class BpnFetchTravelCommand extends Command
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
try {
$xml = $this->apiClient->getTravelDataXml($travelId);
$xml = $this->travelClient->getTravelDataXml($travelId);
} catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage()));
$this->logger->error('Failed to fetch travel data from API', [
+3 -3
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Transport\XmlChannel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -21,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class BpnReplayCommand extends Command
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly XmlChannel $channel,
) {
parent::__construct();
}
@@ -77,7 +77,7 @@ class BpnReplayCommand extends Command
$io->info('Sending request to BPN API...');
try {
$response = $this->apiClient->sendRawXml($xml);
$response = $this->channel->replay($xml);
} catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage()));
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
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;
/**
* Removes local accounts that carry nothing worth keeping.
*
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
*
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
*
* An account is removed when all of the following hold:
*
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
* at all is the same state, spelled the way it was before the fallback landed;
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
* anybody had ruled on it;
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
*
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
*
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
* say out loud that the task destroys data.
*/
#[AsCommand(
name: 'app:cleanup:users',
description: 'Removes accounts that hold no role, no draft and no group booking reference',
)]
class CleanupUsersCommand extends Command
{
/**
* How many accounts are removed between two flushes. The unit of work holds every scheduled
* removal until then, and the first run has thousands of rows to get through, so it is a
* memory bound rather than a policy one.
*/
private const BATCH_SIZE = 500;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'inactive-for',
null,
InputOption::VALUE_REQUIRED,
'Only consider accounts without a login for this many months (default: no such limit)',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Maximum number of accounts to delete in one run (default: no limit)',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$dryRun = (bool) $input->getOption('dry-run');
$force = (bool) $input->getOption('force');
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
$io->error('--inactive-for and --limit have to be at least 1 when given.');
return Command::INVALID;
}
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
$candidates = array_values(array_filter(
$this->userRepository->findCleanupCandidates($inactiveSince),
static fn (User $user): bool => self::isDisposable($user),
));
// After the role filter, never before it: --limit caps the accounts actually deleted, and
// the candidate query cannot tell which ones those are.
if (null !== $limit) {
$candidates = \array_slice($candidates, 0, $limit);
}
if ([] === $candidates) {
$io->success('No accounts to remove.');
return Command::SUCCESS;
}
if (true === $dryRun || false === $force) {
$io->table(
['E-Mail', 'Letzter Login', 'Rollen'],
array_map(static fn (User $user): array => [
$user->getEmail(),
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
], $candidates),
);
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
if (true === $dryRun) {
return Command::SUCCESS;
}
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
// schedule entry missing --force turns into a mail rather than a task that quietly
// does nothing every week.
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
return Command::INVALID;
}
foreach ($candidates as $index => $user) {
// One line per account, before the removal rather than after: once the row is gone
// this is the only place left that records which account the uuid belonged to. Worth
// a log_entry row per deletion, including the several thousand the first run writes —
// they age out with the six-month log retention.
$this->logger->info('Deleting account without roles', [
'uuid' => $user->getUuid(),
'email' => $user->getEmail(),
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
'roles' => Role::assignedOnly($user->getRoles()),
]);
$this->entityManager->remove($user);
if (0 === ($index + 1) % self::BATCH_SIZE) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$count = \count($candidates);
$this->logger->info('Deleted accounts without roles', [
'count' => $count,
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
]);
$io->success(sprintf('Deleted %d account(s).', $count));
return Command::SUCCESS;
}
/**
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
* spelling accounts kept that have not signed in since the fallback landed.
*
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
* account carrying one stays until somebody has ruled on it.
*/
private static function isDisposable(User $user): bool
{
$roles = $user->getRoles();
return [] === Role::pendingOnly($roles)
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\ContentSyncStateRepository;
use Symfony\Component\Clock\ClockInterface;
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:content:check-freshness',
description: 'Fails when the last accepted content manifest is too old.',
)]
/**
* The health check of the content sync. A stalled sync otherwise shows only as a chatbot
* giving stale answers. Scheduled, a failure reaches the schedule's email_on_failure.
*/
class ContentCheckFreshnessCommand extends Command
{
public function __construct(
private readonly ContentSyncStateRepository $stateRepository,
private readonly ClockInterface $clock,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of the last good manifest, in minutes', '60');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$state = $this->stateRepository->get();
$generatedAt = $state->getManifestGeneratedAt();
$maxAge = (int) $input->getOption('max-age');
if (null === $generatedAt) {
$io->error('No content manifest has been accepted yet.');
return Command::FAILURE;
}
$ageMinutes = (int) floor(($this->clock->now()->getTimestamp() - $generatedAt->getTimestamp()) / 60);
if ($ageMinutes > $maxAge) {
$io->error(sprintf('The last good content manifest is %d minutes old (generated %s). Last run error: %s', $ageMinutes, $generatedAt->format('c'), $state->getLastError() ?? 'none'));
return Command::FAILURE;
}
$io->success(sprintf('The last good content manifest is %d minutes old.', $ageMinutes));
return Command::SUCCESS;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\ContentFeed\ContentSyncer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
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:content:sync',
description: 'Mirrors the TYPO3 content feed for the chatbot MCP server.',
)]
/**
* Console entrypoint for the content feed sync.
*
* Meant to run every 15 minutes, plus once a week with --refresh in the night window.
* Outside 01:00-05:00 Europe/Berlin a run fetches at most 50 pages; --ignore-window lifts
* that for a deliberate manual run, such as the first sync on staging.
*/
class ContentSyncCommand extends Command
{
use LockableTrait;
public function __construct(private readonly ContentSyncer $syncer)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('refresh', null, InputOption::VALUE_NONE, sprintf('Also refetch items stored more than %d days ago, oldest first', ContentSyncer::REFRESH_AFTER_DAYS))
->addOption('ignore-window', null, InputOption::VALUE_NONE, sprintf('Fetch every due page, not just %d, outside the night window', ContentSyncer::PAGES_OUTSIDE_WINDOW))
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (!$this->lock()) {
$io->warning('Another content sync is running.');
return Command::SUCCESS;
}
try {
$report = $this->syncer->run((bool) $input->getOption('refresh'), (bool) $input->getOption('ignore-window'));
} finally {
$this->release();
}
$io->definitionList(
['Added' => $report->added],
['Deleted' => $report->deleted],
['Records fetched' => $report->records],
['Pages fetched' => $report->pages],
['Failed' => $report->failed],
['Pages deferred' => $report->deferred],
);
if (null !== $report->pagePhaseEndedBy) {
$io->warning('Page phase ended early: '.$report->pagePhaseEndedBy);
}
if (null !== $report->stoppedBy) {
$io->error('Run stopped: '.$report->stoppedBy);
return Command::FAILURE;
}
$io->success('Content feed synced.');
return Command::SUCCESS;
}
}
+312
View File
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed;
use App\ContentFeed\Exception\ContentFeedStoppedException;
use App\ContentFeed\Exception\ContentFeedUnavailableException;
use App\ContentFeed\Exception\ContentRequestFailedException;
use App\ContentFeed\Exception\PagePhaseEndedException;
use App\Model\ContentFeed\ContentManifest;
use App\Model\ContentFeed\ManifestItem;
use App\Model\ContentFeed\PageFetchResult;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Reads the TYPO3 content feed: the manifest, records by uid, and rendered page fragments.
*
* The website is an old TYPO3 install on which every page fetch is a full frontend render,
* so the load rules here are a contract with its maintainers, not tuning:
*
* - one request at a time, never parallel;
* - at most one page fetch per second;
* - after a response slower than 2 s, wait 5 s; after three in a row, no more pages this run;
* - a 503 (the kill switch) or three consecutive 5xx responses or timeouts end the run.
*
* One instance serves one run: call startRun() first, it resets the counters.
*/
class ContentFeedClient
{
public const RECORDS_BATCH_SIZE = 100;
private const TIMEOUT_SECONDS = 10.0;
private const SLOW_SECONDS = 2.0;
private const SLOW_PAUSE_SECONDS = 5.0;
private const PAGE_INTERVAL_SECONDS = 1.0;
private const MAX_CONSECUTIVE_FAILURES = 3;
private const MAX_CONSECUTIVE_SLOW = 3;
private int $consecutiveFailures = 0;
private int $consecutiveSlow = 0;
private ?\DateTimeImmutable $notBefore = null;
private ?\DateTimeImmutable $lastPageAt = null;
/** @var list<string> */
private array $siteHosts = [];
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
private readonly string $baseUrl,
private readonly string $apiKey,
) {
}
public function startRun(): void
{
$this->consecutiveFailures = 0;
$this->consecutiveSlow = 0;
$this->notBefore = null;
$this->lastPageAt = null;
$this->siteHosts = [];
}
/**
* The whole manifest. Anything but a usable one stops the run: an empty TYPO3 is a bug,
* not a deletion.
*
* @throws ContentFeedStoppedException
*/
public function manifest(): ContentManifest
{
if ('' === $this->apiKey) {
throw new ContentFeedStoppedException('APP_CMS_API_KEY is not configured');
}
$response = $this->send($this->apiUrl('api/content/manifest'), []);
if (null === $response || 200 !== $response['status']) {
throw new ContentFeedStoppedException(sprintf('Manifest request failed (%s)', $response['status'] ?? 'timeout'));
}
try {
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new ContentFeedStoppedException('Manifest is not valid JSON', 0, $e);
}
if (!is_array($data) || !is_string($data['generatedAt'] ?? null) || !is_array($data['items'] ?? null) || [] === $data['items']) {
throw new ContentFeedStoppedException('Manifest has no generatedAt or no items');
}
$generatedAt = ManifestItem::localTime($data['generatedAt']);
if (null === $generatedAt) {
throw new ContentFeedStoppedException('Manifest generatedAt is unparseable');
}
$items = [];
foreach ($data['items'] as $raw) {
try {
$item = ManifestItem::fromArray(is_array($raw) ? $raw : []);
} catch (\InvalidArgumentException $e) {
// A malformed entry would otherwise read as a deletion of whatever it was.
throw new ContentFeedStoppedException('Manifest contains an unusable item: '.$e->getMessage(), 0, $e);
}
$items[$item->key()] = $item;
}
$hosts = [];
foreach (is_array($data['sites'] ?? null) ? $data['sites'] : [] as $site) {
$host = is_array($site) && is_string($site['base'] ?? null) ? parse_url($site['base'], \PHP_URL_HOST) : null;
if (is_string($host)) {
$hosts[] = strtolower($host);
}
}
$this->siteHosts = array_values(array_unique($hosts));
return new ContentManifest($generatedAt, $items, $this->siteHosts);
}
/**
* Records of one type by uid, keyed by uid. A requested uid that is absent is no
* longer eligible.
*
* @param list<int> $uids at most RECORDS_BATCH_SIZE
*
* @return array<int, array<string, mixed>>
*
* @throws ContentRequestFailedException this batch failed, the run goes on
* @throws ContentFeedStoppedException
*/
public function records(string $type, array $uids): array
{
if (count($uids) > self::RECORDS_BATCH_SIZE) {
throw new \InvalidArgumentException(sprintf('At most %d uids per records request', self::RECORDS_BATCH_SIZE));
}
$response = $this->send($this->apiUrl('api/content/records'), ['type' => $type, 'uids' => implode(',', $uids)]);
if (null === $response) {
throw new ContentRequestFailedException(sprintf('Records request for %s timed out', $type));
}
if (200 !== $response['status']) {
if ($response['status'] >= 500) {
throw new ContentRequestFailedException(sprintf('Records request for %s failed (%d)', $type, $response['status']));
}
// 403 and the like: misconfiguration, which the next request would hit too.
throw new ContentFeedStoppedException(sprintf('Records request for %s answered %d', $type, $response['status']));
}
try {
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new ContentRequestFailedException(sprintf('Records response for %s is not valid JSON', $type), 0, $e);
}
// Anything but the documented envelope fails the batch: read as an empty list, it
// would delete every record requested.
if (!is_array($data) || ($data['type'] ?? null) !== $type || !is_array($data['items'] ?? null) || !array_is_list($data['items'])) {
throw new ContentRequestFailedException(sprintf('Records response for %s has no items list', $type));
}
$records = [];
foreach ($data['items'] as $record) {
if (is_array($record) && is_int($record['uid'] ?? null)) {
$records[$record['uid']] = $record;
}
}
return $records;
}
/**
* The bare HTML fragment of one page, fetched at most once a second.
*
* @throws PagePhaseEndedException
* @throws ContentFeedStoppedException
*/
public function page(string $contentUrl): PageFetchResult
{
$host = parse_url($contentUrl, \PHP_URL_HOST);
// The key goes with every request, so it only ever goes to a host of the feed's own sites.
if (!is_string($host) || !in_array(strtolower($host), $this->siteHosts, true)) {
$this->logger->warning('Content feed: contentUrl outside the manifest sites, skipped', ['contentUrl' => $contentUrl]);
return PageFetchResult::failed(0);
}
if (null !== $this->lastPageAt) {
$this->waitUntil($this->lastPageAt->modify(sprintf('+%d ms', (int) (self::PAGE_INTERVAL_SECONDS * 1000))));
}
$this->lastPageAt = $this->clock->now();
$response = $this->send($contentUrl, [], countSlow: true);
if (null === $response) {
return PageFetchResult::transient();
}
if (200 === $response['status']) {
return PageFetchResult::ok($response['body']);
}
return PageFetchResult::failed($response['status']);
}
/**
* Sends one GET under the load rules. Null on a timeout or transport error.
*
* @param array<string, string> $query
*
* @return array{status: int, body: string}|null
*
* @throws ContentFeedStoppedException
* @throws PagePhaseEndedException
*/
private function send(string $url, array $query, bool $countSlow = false): ?array
{
if (null !== $this->notBefore) {
$this->waitUntil($this->notBefore);
$this->notBefore = null;
}
$result = null;
$duration = 0.0;
try {
$response = $this->httpClient->request('GET', $url, [
'query' => $query,
'headers' => ['X-Api-Key' => $this->apiKey],
'timeout' => self::TIMEOUT_SECONDS,
'max_duration' => self::TIMEOUT_SECONDS,
]);
$result = ['status' => $response->getStatusCode(), 'body' => $response->getContent(false)];
$duration = $this->duration($response);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('Content feed: request failed', ['url' => $url, 'error' => $e->getMessage()]);
$duration = self::TIMEOUT_SECONDS;
}
if (null !== $result && 503 === $result['status']) {
throw new ContentFeedUnavailableException($this->retryAfter($response));
}
if (null === $result || $result['status'] >= 500) {
if (++$this->consecutiveFailures >= self::MAX_CONSECUTIVE_FAILURES) {
throw new ContentFeedStoppedException(sprintf('%d consecutive 5xx responses or timeouts', self::MAX_CONSECUTIVE_FAILURES));
}
} else {
$this->consecutiveFailures = 0;
}
if ($duration > self::SLOW_SECONDS) {
$this->notBefore = $this->clock->now()->modify(sprintf('+%d ms', (int) (self::SLOW_PAUSE_SECONDS * 1000)));
if ($countSlow && ++$this->consecutiveSlow >= self::MAX_CONSECUTIVE_SLOW) {
$this->consecutiveSlow = 0;
throw new PagePhaseEndedException(sprintf('%d consecutive responses slower than %d s', self::MAX_CONSECUTIVE_SLOW, (int) self::SLOW_SECONDS));
}
} elseif ($countSlow) {
$this->consecutiveSlow = 0;
}
return $result;
}
private function duration(ResponseInterface $response): float
{
$total = $response->getInfo('total_time');
return is_float($total) || is_int($total) ? (float) $total : 0.0;
}
private function retryAfter(ResponseInterface $response): ?\DateTimeImmutable
{
$value = $response->getHeaders(false)['retry-after'][0] ?? null;
if (null === $value || '' === trim($value)) {
return null;
}
if (ctype_digit(trim($value))) {
return $this->clock->now()->modify(sprintf('+%d seconds', (int) trim($value)));
}
$date = date_create_immutable($value);
return false === $date ? null : $date;
}
private function waitUntil(\DateTimeImmutable $moment): void
{
$seconds = (float) $moment->format('U.u') - (float) $this->clock->now()->format('U.u');
if ($seconds > 0) {
$this->clock->sleep($seconds);
}
}
private function apiUrl(string $path): string
{
return rtrim($this->baseUrl, '/').'/'.$path;
}
}
+367
View File
@@ -0,0 +1,367 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed;
use App\Chatbot\TripCatalog;
use App\ContentFeed\Exception\ContentFeedStoppedException;
use App\ContentFeed\Exception\ContentFeedUnavailableException;
use App\ContentFeed\Exception\ContentRequestFailedException;
use App\ContentFeed\Exception\PagePhaseEndedException;
use App\Entity\ContentDocument;
use App\Enum\ContentFetchState;
use App\Model\ContentFeed\ContentManifest;
use App\Model\ContentFeed\ContentSyncReport;
use App\Model\ContentFeed\ManifestItem;
use App\Repository\ContentDocumentRepository;
use App\Repository\ContentSyncStateRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
/**
* Mirrors the TYPO3 content feed into content_document.
*
* A run reads the manifest, removes what it no longer lists, refetches the records and
* pages whose `modified` changed, and re-merges detail pages into their records. A failed
* manifest changes nothing. Bulk page work belongs in the night window: outside it, a run
* fetches at most PAGES_OUTSIDE_WINDOW pages and leaves the rest for the next one.
*
* With `refresh`, items whose stored copy is older than REFRESH_AFTER_DAYS are refetched
* regardless of `modified`, oldest first: the backstop for the changes TYPO3 cannot see,
* such as a referenced content element changing on another page.
*/
class ContentSyncer
{
public const PAGES_OUTSIDE_WINDOW = 50;
public const REFRESH_AFTER_DAYS = 7;
private const NIGHT_WINDOW_TIMEZONE = 'Europe/Berlin';
private const NIGHT_WINDOW_FROM_HOUR = 1;
private const NIGHT_WINDOW_TO_HOUR = 5;
private const FLUSH_EVERY_PAGES = 20;
public function __construct(
private readonly ContentFeedClient $client,
private readonly ContentDocumentRepository $documentRepository,
private readonly ContentSyncStateRepository $stateRepository,
private readonly DocumentBodyBuilder $bodyBuilder,
private readonly HtmlToMarkdownConverter $converter,
private readonly TripCatalog $tripCatalog,
private readonly EntityManagerInterface $entityManager,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
}
public function run(bool $refresh = false, bool $ignoreWindow = false): ContentSyncReport
{
$report = new ContentSyncReport();
$state = $this->stateRepository->get();
$this->entityManager->persist($state);
$now = $this->clock->now();
if (null !== $state->getPausedUntil() && $now < $state->getPausedUntil()) {
$report->stoppedBy = 'Feed switched off by TYPO3 until '.$state->getPausedUntil()->format('c');
return $report;
}
$state->setPausedUntil(null)->setLastRunAt($now);
$this->client->startRun();
try {
$manifest = $this->client->manifest();
$documents = $this->applyManifest($manifest, $report);
$state->setManifestGeneratedAt($manifest->generatedAt);
$this->entityManager->flush();
$staleBefore = $refresh ? $now->modify(sprintf('-%d days', self::REFRESH_AFTER_DAYS)) : null;
$this->syncRecords($documents, $staleBefore, $report);
$this->syncPages($documents, $staleBefore, $ignoreWindow || $this->inNightWindow($now), $report);
} catch (ContentFeedUnavailableException $e) {
$state->setPausedUntil($e->retryAfter);
$report->stoppedBy = $e->getMessage();
} catch (ContentFeedStoppedException $e) {
$report->stoppedBy = $e->getMessage();
}
if (isset($documents)) {
$this->merge();
}
$state->setLastError($report->stoppedBy ?? $report->pagePhaseEndedBy);
$this->entityManager->flush();
if (null === $report->stoppedBy) {
$this->logger->info('Content feed sync finished', $report->toLogContext());
} else {
$this->logger->warning('Content feed sync stopped', $report->toLogContext());
}
if ($report->isComplete() && $report->changedAnything()) {
$this->tripCatalog->logUnmatchedCodes();
}
return $report;
}
/**
* Deletes what the manifest no longer lists and brings every listed item's metadata up
* to date. Content is not touched here.
*
* @return array<string, array{document: ContentDocument, item: ManifestItem}>
*/
private function applyManifest(ContentManifest $manifest, ContentSyncReport $report): array
{
$stored = $this->documentRepository->findAllByKey();
foreach (array_diff_key($stored, $manifest->items) as $document) {
$this->entityManager->remove($document);
++$report->deleted;
}
$documents = [];
foreach ($manifest->items as $key => $item) {
$document = $stored[$key] ?? null;
if (null === $document) {
$document = new ContentDocument($item->type, $item->uid, $item->language, $item->modified);
$this->entityManager->persist($document);
++$report->added;
}
$document->setModified($item->modified)->setSite($item->site)->setUrl($item->url);
if ($document->isPage()) {
$document
->setTitle($item->title ?? '')
->setBreadcrumb([] === $item->breadcrumb ? null : implode(' › ', $item->breadcrumb))
->setData(['contentUrl' => $item->contentUrl, 'description' => $item->description, 'detailOf' => $item->detailOf]);
}
$documents[$key] = ['document' => $document, 'item' => $item];
}
return $documents;
}
/**
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
*/
private function syncRecords(array $documents, ?\DateTimeImmutable $staleBefore, ContentSyncReport $report): void
{
/** @var array<string, array<int, list<ContentDocument>>> $stale type => uid => documents; the records endpoint takes no language */
$stale = [];
foreach ($this->dueForFetch($documents, $staleBefore, pages: false) as $document) {
$stale[$document->getType()][$document->getUid()][] = $document;
}
foreach ($stale as $type => $byUid) {
foreach (array_chunk(array_keys($byUid), ContentFeedClient::RECORDS_BATCH_SIZE) as $uids) {
try {
$records = $this->client->records($type, $uids);
} catch (ContentRequestFailedException $e) {
$this->logger->warning('Content feed: records batch failed, retried next run', ['type' => $type, 'error' => $e->getMessage()]);
$report->failed += count($uids);
continue;
}
foreach ($uids as $uid) {
foreach ($byUid[$uid] as $document) {
$record = $records[$uid] ?? null;
if (null === $record) {
// Requested but absent: no longer eligible.
$this->entityManager->remove($document);
++$report->deleted;
continue;
}
$this->applyRecord($document, $record);
++$report->records;
}
}
$this->entityManager->flush();
}
}
}
/**
* @param array<string, mixed> $record
*/
private function applyRecord(ContentDocument $document, array $record): void
{
$title = $this->bodyBuilder->title($document->getType(), $record);
$document
->setData($record)
->setTitle('' !== $title ? $title : $document->getTitle())
// The merged pages are appended in merge().
->setBodyMd($this->bodyBuilder->build($document->getType(), $record))
->markFetched(ContentFetchState::Ok, $this->clock->now());
}
/**
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
*
* @throws ContentFeedStoppedException
*/
private function syncPages(array $documents, ?\DateTimeImmutable $staleBefore, bool $unlimited, ContentSyncReport $report): void
{
$due = $this->dueForFetch($documents, $staleBefore, pages: true);
if (!$unlimited && count($due) > self::PAGES_OUTSIDE_WINDOW) {
$report->deferred = count($due) - self::PAGES_OUTSIDE_WINDOW;
$due = array_slice($due, 0, self::PAGES_OUTSIDE_WINDOW);
}
$fetched = 0;
try {
foreach ($due as $document) {
$this->fetchPage($document, $report);
if (0 === ++$fetched % self::FLUSH_EVERY_PAGES) {
$this->entityManager->flush();
}
}
} catch (PagePhaseEndedException $e) {
$report->pagePhaseEndedBy = $e->getMessage();
$this->logger->warning('Content feed: page phase ended early', ['reason' => $e->getMessage()]);
} finally {
$this->entityManager->flush();
}
}
private function fetchPage(ContentDocument $document, ContentSyncReport $report): void
{
$contentUrl = $document->getData()['contentUrl'] ?? null;
if (!is_string($contentUrl)) {
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
++$report->failed;
return;
}
$result = $this->client->page($contentUrl);
if ($result->transient) {
++$report->failed;
return;
}
if (!$result->isOk()) {
$this->logger->notice('Content feed: page failed, not refetched until it changes', [
'uid' => $document->getUid(),
'contentUrl' => $contentUrl,
'status' => $result->failedStatus,
]);
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
++$report->failed;
return;
}
$markdown = $this->converter->convert($result->html, $document->getUrl() ?? $contentUrl);
$document->setBodyMd($markdown)->markFetched('' === $markdown ? ContentFetchState::Empty : ContentFetchState::Ok, $this->clock->now());
++$report->pages;
}
/**
* Items whose `modified` changed since their last fetch, plus, on a refresh, those
* fetched before `$staleBefore`, oldest first.
*
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
*
* @return list<ContentDocument>
*/
private function dueForFetch(array $documents, ?\DateTimeImmutable $staleBefore, bool $pages): array
{
$due = [];
foreach ($documents as ['document' => $document]) {
if ($document->isPage() !== $pages || !$this->entityManager->contains($document)) {
continue;
}
$outdated = null !== $staleBefore && (null === $document->getSyncedAt() || $document->getSyncedAt() < $staleBefore);
if ($document->isStale() || $outdated) {
$due[] = $document;
}
}
usort($due, static fn (ContentDocument $a, ContentDocument $b): int => ($a->getSyncedAt()?->getTimestamp() ?? 0) <=> ($b->getSyncedAt()?->getTimestamp() ?? 0));
return $due;
}
/**
* Appends every detail page to the record it is the detail page of, and keeps the page
* itself out of the index. Recomputed from scratch on every run, so a change or deletion
* on either side re-merges.
*/
private function merge(): void
{
$documents = $this->documentRepository->findAllByKey();
/** @var array<string, list<ContentDocument>> $pagesByRecord */
$pagesByRecord = [];
foreach ($documents as $page) {
if (!$page->isPage()) {
continue;
}
$target = null;
foreach ($page->getData()['detailOf'] ?? [] as $ref) {
$candidate = $documents[ContentDocument::key($ref['type'], $ref['uid'], $page->getLanguage())] ?? null;
if (null !== $candidate && ContentFetchState::Ok === $candidate->getFetchState()) {
$target = $candidate;
break;
}
}
$page->setMergedInto($target?->getRecordRef());
if (null !== $target && ContentFetchState::Ok === $page->getFetchState() && '' !== $page->getBodyMd()) {
$pagesByRecord[$target->getKey()][] = $page;
}
}
foreach ($documents as $record) {
if ($record->isPage() || ContentFetchState::Ok !== $record->getFetchState() || null === $record->getData()) {
continue;
}
$pages = $pagesByRecord[$record->getKey()] ?? [];
usort($pages, static fn (ContentDocument $a, ContentDocument $b): int => $a->getUid() <=> $b->getUid());
$body = $this->bodyBuilder->build($record->getType(), $record->getData());
foreach ($pages as $page) {
$body .= "\n\n".$page->getBodyMd();
}
$record->setBodyMd(trim($body));
// A record without a reachable page of its own is linkable through its detail page.
if (null === $record->getUrl() && [] !== $pages) {
$record->setUrl($pages[0]->getUrl())->setSite($record->getSite() ?? $pages[0]->getSite());
}
}
}
private function inNightWindow(\DateTimeImmutable $now): bool
{
$hour = (int) $now->setTimezone(new \DateTimeZone(self::NIGHT_WINDOW_TIMEZONE))->format('G');
return $hour >= self::NIGHT_WINDOW_FROM_HOUR && $hour < self::NIGHT_WINDOW_TO_HOUR;
}
}
+288
View File
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed;
/**
* Builds the searchable Markdown body and the title of a feed record.
*
* Every record follows the same pattern: the name as heading, the short introductory
* fields as paragraphs, then each `text` field as a section under a German heading, in a
* fixed order. Structured facts that people ask about (WLAN, Sauna, Pistenkilometer) are
* rendered as a list as well, so the full-text search finds them.
*/
class DocumentBodyBuilder
{
/**
* Introductory fields, rendered without a heading. Plain text in the feed.
*/
private const LEAD_FIELDS = ['subline', 'headline'];
/**
* `text` fields per type, in output order, with their section heading. A null heading
* renders the field as an introduction straight under the title.
*/
private const TEXT_SECTIONS = [
'product' => [
'teaser' => null,
'teaserLong' => 'Beschreibung',
'feature' => 'Highlights',
'conceptDescription' => 'Reisekonzept',
'boardDescription' => 'Verpflegung',
'includedServices' => 'Leistungen',
'additionalServices' => 'Zusatzleistungen',
'programmeDescription' => 'Programm',
'skiPassDescription' => 'Skipass',
'nonBookableInfo' => 'Hinweise zur Buchbarkeit',
],
'hotel' => [
'teaser' => null,
'description' => 'Beschreibung',
'features' => 'Ausstattung',
'roomTypes' => 'Zimmer',
'additionalInformation' => 'Weitere Informationen',
],
'region' => [
'teaser' => null,
'description' => 'Beschreibung',
'feature' => 'Highlights',
'skiArea' => 'Skigebiet',
'skiAreaExtended' => 'Skigebiet im Detail',
'skiPass' => 'Skipass',
'news' => 'Neuigkeiten',
],
'country' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
],
'city' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
'party' => 'Party',
'leisure' => 'Freizeit',
],
'concept' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
'popupText' => 'Kurz erklärt',
],
];
/**
* Facts per type, with their German label.
*/
private const FACTS = [
'hotel' => [
'bedsCount' => 'Betten',
'parkingCount' => 'Parkplätze',
'altitude' => 'Höhenlage',
'distanceBus' => 'Entfernung zur Bushaltestelle',
'distanceLift' => 'Entfernung zum Lift',
'shower' => 'Dusche/WC',
'partyroom' => 'Partyraum',
'gamesroom' => 'Spieleraum',
'sauna' => 'Sauna',
'wifi' => 'WLAN',
],
'region' => [
'length' => 'Pistenkilometer',
'height' => 'Höhe',
'lifts' => 'Lifte',
],
];
public function __construct(private readonly HtmlToMarkdownConverter $converter)
{
}
/**
* @param array<string, mixed> $record
*/
public function title(string $type, array $record): string
{
$title = 'news' === $type ? ($record['title'] ?? null) : ($record['name'] ?? null);
return is_string($title) ? trim($title) : '';
}
/**
* @param array<string, mixed> $record
*/
public function build(string $type, array $record): string
{
$baseUrl = is_string($record['url'] ?? null) ? $record['url'] : null;
$blocks = [];
$title = $this->title($type, $record);
if ('' !== $title) {
$blocks[] = '# '.$title;
}
if ('news' === $type) {
return $this->join(array_merge($blocks, $this->newsBlocks($record, $baseUrl)));
}
foreach (self::LEAD_FIELDS as $field) {
$value = $this->plain($record[$field] ?? null);
if ('' !== $value && $value !== $title) {
$blocks[] = $value;
}
}
if ('hotel' === $type) {
$address = $this->plain($record['address'] ?? null);
if ('' !== $address) {
$blocks[] = 'Adresse: '.$address;
}
}
$text = is_array($record['text'] ?? null) ? $record['text'] : [];
foreach (self::TEXT_SECTIONS[$type] ?? [] as $field => $heading) {
$markdown = $this->converter->convert(is_string($text[$field] ?? null) ? $text[$field] : null, $baseUrl);
if ('' === $markdown) {
continue;
}
$blocks[] = null === $heading ? $markdown : "## {$heading}\n\n{$markdown}";
}
$facts = $this->facts($type, is_array($record['facts'] ?? null) ? $record['facts'] : []);
if ('' !== $facts) {
$blocks[] = "## Fakten\n\n".$facts;
}
if ('region' === $type && is_string($record['webcam'] ?? null) && '' !== trim($record['webcam'])) {
$blocks[] = 'Webcam: '.trim($record['webcam']);
}
if ('product' === $type) {
$faqs = $this->faqs(is_array($record['faqs'] ?? null) ? $record['faqs'] : [], $baseUrl);
if ('' !== $faqs) {
$blocks[] = "## Häufige Fragen\n\n".$faqs;
}
}
return $this->join($blocks);
}
/**
* @param array<string, mixed> $record
*
* @return list<string>
*/
private function newsBlocks(array $record, ?string $baseUrl): array
{
$blocks = [];
if (is_string($record['datetime'] ?? null) && false !== ($date = date_create_immutable($record['datetime']))) {
$blocks[] = 'Veröffentlicht am '.$date->format('d.m.Y');
}
foreach (['teaser', 'bodytext'] as $field) {
$markdown = $this->converter->convert(is_string($record[$field] ?? null) ? $record[$field] : null, $baseUrl);
if ('' !== $markdown) {
$blocks[] = $markdown;
}
}
$tags = $this->labels($record['tags'] ?? null);
if ([] !== $tags) {
$blocks[] = 'Themen: '.implode(', ', $tags);
}
return $blocks;
}
/**
* @param array<string, mixed> $facts
*/
private function facts(string $type, array $facts): string
{
$lines = [];
foreach (self::FACTS[$type] ?? [] as $field => $label) {
$value = $facts[$field] ?? null;
$rendered = match (true) {
is_bool($value) => $value ? 'ja' : 'nein',
is_int($value), is_float($value) => (string) $value,
is_string($value) => $this->plain($value),
default => '',
};
if ('' !== $rendered) {
$lines[] = "- {$label}: {$rendered}";
}
}
return implode("\n", $lines);
}
/**
* @param array<mixed> $faqs
*/
private function faqs(array $faqs, ?string $baseUrl): string
{
$blocks = [];
foreach ($faqs as $faq) {
if (!is_array($faq)) {
continue;
}
$question = $this->plain($faq['question'] ?? null);
$answer = $this->converter->convert(is_string($faq['answer'] ?? null) ? $faq['answer'] : null, $baseUrl);
if ('' !== $question && '' !== $answer) {
$blocks[] = "### {$question}\n\n{$answer}";
}
}
return $this->join($blocks);
}
/**
* Tags and categories may arrive as strings or as objects with a title or name.
*
* @return list<string>
*/
private function labels(mixed $values): array
{
if (!is_array($values)) {
return [];
}
$labels = [];
foreach ($values as $value) {
$label = is_array($value) ? ($value['title'] ?? $value['name'] ?? null) : $value;
if (is_string($label) && '' !== trim($label)) {
$labels[] = trim($label);
}
}
return $labels;
}
/**
* A field that is plain text in the feed, defensively stripped of any markup.
*/
private function plain(mixed $value): string
{
if (!is_string($value)) {
return '';
}
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')));
}
/**
* @param list<string> $blocks
*/
private function join(array $blocks): string
{
return implode("\n\n", $blocks);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed\Exception;
/**
* Ends the whole sync run, keeping everything as it is. The next scheduled run tries again.
*
* Thrown for a manifest that cannot be trusted, three consecutive 5xx responses or
* timeouts, and anything else that means TYPO3 is not in a state to be read.
*/
class ContentFeedStoppedException extends \RuntimeException
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed\Exception;
/**
* TYPO3 answered 503: the feed has been switched off on its side.
*/
class ContentFeedUnavailableException extends ContentFeedStoppedException
{
public function __construct(public readonly ?\DateTimeImmutable $retryAfter)
{
parent::__construct('TYPO3 switched the content feed off (503)'.(null !== $retryAfter ? ', retry after '.$retryAfter->format('c') : ''));
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed\Exception;
/**
* One request failed without ending the run. The items it was for keep their stored
* version and are retried next run.
*/
class ContentRequestFailedException extends \RuntimeException
{
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed\Exception;
/**
* Three slow responses in a row: TYPO3 is under load, so no more pages this run. The rest
* of the run (bookkeeping, merging) still completes.
*/
class PagePhaseEndedException extends \RuntimeException
{
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed;
use League\HTMLToMarkdown\Converter\TableConverter;
use League\HTMLToMarkdown\HtmlConverter;
use Symfony\Component\DomCrawler\UriResolver;
/**
* Turns the CMS's HTML, record text fields and page fragments alike, into Markdown for the
* index and the tool output. LLMs read Markdown well; HTML noise only costs tokens.
*
* Embeds, media and forms are dropped with their content. Relative links are made
* absolute against the document's own URL, since the page fragment may carry them.
*/
class HtmlToMarkdownConverter
{
private const REMOVED_ELEMENTS = ['script', 'style', 'noscript', 'iframe', 'img', 'picture', 'svg', 'form'];
private readonly HtmlConverter $converter;
public function __construct()
{
$this->converter = new HtmlConverter([
'header_style' => 'atx',
'strip_tags' => true,
'strip_placeholder_links' => true,
'hard_break' => true,
'suppress_errors' => true,
'remove_nodes' => implode(' ', self::REMOVED_ELEMENTS),
]);
$this->converter->getEnvironment()->addConverter(new TableConverter());
}
/**
* @param string|null $baseUrl what relative links resolve against; left as they are without one
*/
public function convert(?string $html, ?string $baseUrl = null): string
{
if (null === $html || '' === trim($html)) {
return '';
}
$markdown = $this->converter->convert($this->prepare($html, $baseUrl));
return $this->collapseWhitespace($markdown);
}
private function prepare(string $html, ?string $baseUrl): string
{
$document = \Dom\HTMLDocument::createFromString('<!DOCTYPE html><html><body>'.$html.'</body></html>', \LIBXML_NOERROR, 'UTF-8');
$body = $document->body;
if (null === $body) {
return $html;
}
foreach ($body->querySelectorAll(implode(',', self::REMOVED_ELEMENTS)) as $element) {
$element->remove();
}
// Whitespace in text collapses as a browser would; the template's indentation would
// otherwise end up inside link labels and headings.
foreach ((new \Dom\XPath($document))->query('//body//text()[not(ancestor::pre)]') as $text) {
if (!$text instanceof \Dom\Text) {
continue;
}
$text->data = (string) preg_replace('/[ \t\r\n]+/', ' ', $text->data);
}
if (null !== $baseUrl) {
foreach ($body->querySelectorAll('a[href]') as $link) {
$href = trim((string) $link->getAttribute('href'));
if ('' === $href || preg_match('/^(mailto|tel|javascript):/i', $href)) {
continue;
}
$link->setAttribute('href', UriResolver::resolve($href, $baseUrl));
}
}
return $body->innerHTML;
}
private function collapseWhitespace(string $markdown): string
{
// The converter leaves text entity-encoded (`&amp;`), which only costs tokens.
$markdown = html_entity_decode($markdown, \ENT_QUOTES | \ENT_HTML5, 'UTF-8');
$markdown = str_replace(["\r\n", "\r", "\u{00A0}"], ["\n", "\n", ' '], $markdown);
// Labels padded by the collapsed whitespace: "[ Kontakt ](…)" -> "[Kontakt](…)".
$markdown = (string) preg_replace(['/\[ +/', '/ +\]\(/'], ['[', ']('], $markdown);
$lines = array_map(
// Inner runs only: leading spaces carry list nesting.
static fn (string $line): string => rtrim((string) preg_replace('/(?<=\S)[ \t]{2,}/', ' ', $line)),
explode("\n", $markdown),
);
return trim((string) preg_replace("/\n{3,}/", "\n\n", implode("\n", $lines)));
}
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Account;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
@@ -39,7 +39,7 @@ class PersonalDataController extends AbstractController
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param AccountClient $accountClient BusProNet account client
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
@@ -48,7 +48,7 @@ class PersonalDataController extends AbstractController
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly AccountClient $accountClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
@@ -100,7 +100,7 @@ class PersonalDataController extends AbstractController
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
try {
$updateResult = $this->apiClient->updatePersonalData($email, $password, $personalData);
$updateResult = $this->accountClient->updatePersonalData($email, $password, $personalData);
if ($updateResult instanceof Notification) {
$this->logger->error('Personal data update rejected by BusProNet', [
@@ -223,7 +223,7 @@ class PersonalDataController extends AbstractController
try {
$personalData = $this
->apiClient
->accountClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
@@ -12,6 +12,7 @@ use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -33,6 +34,7 @@ class EditController extends AbstractController
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly UserRepository $userRepo,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
) {
}
@@ -128,10 +130,7 @@ class EditController extends AbstractController
// The submitted changes stay on the entity but are never flushed, so the form
// comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
return $this->renderForm($booking, $form);
}
// Editing never moves the booking on and never mails the customer: every status
@@ -160,9 +159,22 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
}
return $this->renderForm($booking, $form);
}
/**
* The sidebar shows the stored price, not one computed from the submitted values: after a
* refused save the entity carries the unsaved changes, and the price there is labelled as the
* state of the last save.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function renderForm(AccommodationBooking $booking, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
]);
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** Changes the length and the lines of a split-off period; the main period absorbs the difference. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class EditPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/edit', name: 'app_admin_accommodationbooking_period_edit')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->updatePeriod($booking, $period, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde aktualisiert.');
$this->logger->info('Updated a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $period->getId(),
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to update a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $period->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => 'Zeitraum bearbeiten',
]);
}
}
@@ -21,12 +21,8 @@ class PdfController extends AbstractController
#[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')]
public function index(AccommodationBooking $booking): Response
{
if (false === $booking->isConfirmed()) {
$this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.');
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
// Offered in every status, so the office can check the document before it goes out
// with the confirmation email; it labels itself Angebot or Buchung accordingly.
try {
return $this->pdfGenerator->createDownloadResponse($booking);
} catch (\RuntimeException $e) {
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\HttpFoundation\Request;
/**
* For the modals opened from the edit form: they save on their own and send the browser back to
* it, rebuilt with the new price.
*/
trait RedirectsToEditFormTrait
{
/**
* The return url is forwarded still encoded, the way return_url() handed it over, so that
* the edit page keeps leading back to the list the booking was opened from.
*/
private function editUrl(AccommodationBooking $booking, Request $request): string
{
$parameters = ['id' => $booking->getId()];
$returnUrl = $request->query->getString('r');
if ('' !== $returnUrl) {
$parameters['r'] = $returnUrl;
}
return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** Gives a split-off period's nights back to the main period, which reprices the booking. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class RemovePeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/remove', name: 'app_admin_accommodationbooking_period_remove')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('remove_accommodation_booking_period_'.$period->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$periodId = $period->getId();
try {
$violations = $this->bookingService->removePeriod($booking, $period);
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to remove a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $periodId,
]);
$this->addFlash('error', ImplausibleBookingPriceException::FORM_ERROR_MESSAGE);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
// Removing only ever lengthens the main period, so this is reached only when the
// stay's dates were already inconsistent with the periods.
foreach ($violations as $violation) {
$this->addFlash('error', (string) $violation->getMessage());
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde entfernt.');
$this->logger->info('Removed a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $periodId,
]);
}
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
return $this->render('admin/accommodation_booking/modal_remove_period.html.twig', [
'booking' => $booking,
'period' => $period,
'csrf_token_id' => 'remove_accommodation_booking_period_'.$period->getId(),
]);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Splits nights off the start or the end of the stay, for a part of the group that arrives
* early or leaves late and is quoted as free-form lines instead of the booking's parameters.
* Each side can be split once; the period can be resized afterwards through its edit modal.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SplitPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route(
'/admin/accommodation-booking/{id}/period/split/{position}',
name: 'app_admin_accommodationbooking_period_split',
requirements: ['position' => 'leading|trailing'],
)]
public function index(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, Request $request): Response
{
// One period per side. The edit page only offers the free sides, so this catches a page
// that was open while somebody else split the same side.
if (!$booking->canSplitOffPeriod($position)) {
$this->addFlash('error', AccommodationBookingPeriodPosition::Leading === $position
? 'Am Anfang wurde bereits ein Zeitraum abgeteilt.'
: 'Am Ende wurde bereits ein Zeitraum abgeteilt.');
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
$dto = new AccommodationBookingPeriodDto(1, [new AccommodationBookingPeriodLineDto()]);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->splitOffPeriod($booking, $position, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde abgeteilt.');
$this->logger->info('Split off a period of accommodation booking', [
'id' => $booking->getId(),
'position' => $position->value,
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to split off a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->renderModal($booking, $position, $form);
}
/** @param FormInterface<AccommodationBookingPeriodDto> $form */
private function renderModal(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => AccommodationBookingPeriodPosition::Leading === $position
? 'Zeitraum am Anfang abteilen'
: 'Zeitraum am Ende abteilen',
]);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\ContactFormSubmission;
use App\BusProNet\Model\Notification;
@@ -21,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_API')]
class ContactFormController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
public function __construct(private readonly AccountClient $accountClient, private readonly LoggerInterface $logger)
{
}
@@ -31,7 +31,7 @@ class ContactFormController extends AbstractController
$personalData = PersonalData::fromContactFormSubmission($dto);
try {
$result = $this->apiClient->createAddress($personalData);
$result = $this->accountClient->createAddress($personalData);
if ($result instanceof Notification) {
$this->logger->info('Unable to submit contact form', [
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_PROFILE')]
class CrmAttributeController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
{
}
@@ -30,7 +30,7 @@ class CrmAttributeController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword());
try {
$data = $this->apiClient->getCrmAttributes($email, $password);
$data = $this->accountClient->getCrmAttributes($email, $password);
if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
+25 -3
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -29,6 +31,7 @@ class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
private readonly ProductExportBuilder $productExportBuilder,
) {
}
@@ -41,13 +44,31 @@ class ExportController extends AbstractController
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
ExportQuery $query = new ExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
return $this->paginate($this->travelExportBuilder->build(), $query);
}
/**
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
*
* The website's product import. Same grain, envelope and failure behaviour as travels();
* the rows carry rooms, prices, services and selection groups instead of the
* disposition subset, and nothing is dropped for the consumer.
*/
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
public function products(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ExportQuery $query = new ExportQuery(),
): JsonResponse {
return $this->paginate($this->productExportBuilder->build(), $query);
}
private function paginate(Export $export, ExportQuery $query): JsonResponse
{
$total = count($export->rows);
return $this->json([
@@ -59,6 +80,7 @@ class ExportController extends AbstractController
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
'generatedAt' => $export->generatedAt->format('c'),
],
]);
+7 -14
View File
@@ -2,6 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -16,23 +17,15 @@ class LastUpdateController extends AbstractController
{
}
/**
* A missing or unreadable marker answers `{"timestamp": null}`, never a 500: consumers
* poll this for change detection and treat null as "unknown".
*/
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
public function index(): JsonResponse
{
if (false === $this->xmlExport->has('uebertragung.info')) {
$lastUploadAt = null;
} else {
$file = $this->xmlExport->read('uebertragung.info');
$lines = explode("\n", $file);
$line = trim($lines[0]);
$info = XmlExportInfo::fromStorage($this->xmlExport);
if (true === empty($line)) {
$lastUploadAt = null;
} else {
$lastUploadAt = \DateTime::createFromFormat('d.m.Y H:i:s', $line);
}
}
return $this->json(['timestamp' => $lastUploadAt->format('Y-m-d H:i:s')]);
return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,7 +17,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class ProductController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly CacheInterface $cache,
) {
}
@@ -29,7 +29,7 @@ class ProductController extends AbstractController
$products = $this->cache->get('api_products', function (ItemInterface $item) {
$item->expiresAfter(3600); // 1 hour cache
return $this->apiClient->getProducts();
return $this->travelClient->getProducts();
});
return $this->json($products);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DateCodeUtility;
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TravelController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly TravelDataProvider $travelDataService,
) {
}
@@ -89,7 +89,7 @@ class TravelController extends AbstractController
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
): JsonResponse {
try {
$result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo);
$result = $this->travelClient->getHotelAvailability($dateId, $hotelId, $dateTo);
return $this->json($result);
} catch (ApiClientException $e) {
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_EMAIL')]
class UserinfoController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
{
}
@@ -48,7 +48,7 @@ class UserinfoController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword());
try {
$data = $this->apiClient->getPersonalData($email, $password);
$data = $this->accountClient->getPersonalData($email, $password);
if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
@@ -11,9 +11,11 @@ use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException;
use App\Htmx\HxTrait;
use App\Model\BookingQueryParams;
use App\Model\PurchaseFlow;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly AgencyLoader $agencyLoader,
private readonly BookingSummaryAssembler $summaryDataService,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) {
}
@@ -103,6 +106,7 @@ class IndexController extends AbstractController
// Create fresh booking session with the provided parameters
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Booking);
// Warm the CMS cache early so data is available on the login page
$this->summaryDataService->getCmsDataForProduct(
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Notification;
@@ -40,7 +40,7 @@ class Step3Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory,
private readonly BookingPriceCalculator $priceCalculator,
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger,
) {
@@ -76,7 +76,7 @@ class Step3Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// Validate booking data with API by submitting an inquiry booking
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
$inquiryResponse = $this->bookingClient->createBookingInquiry($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission)
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -37,7 +37,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly CacheInterface $cache,
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService,
@@ -80,7 +80,7 @@ class Step4Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// Submit final booking (already validated in Step 3)
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
$bookingResponse = $this->bookingClient->createBooking($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission)
@@ -2,7 +2,7 @@
namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -20,7 +20,7 @@ use function Symfony\Component\String\u;
class DownloadController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
) {
@@ -60,7 +60,7 @@ class DownloadController extends AbstractController
try {
$file = $this
->apiClient
->bookingClient
->getDocuments($email, $password, $id, $type);
} catch (ApiClientException $e) {
$file = null;
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more