Author SHA1 Message Date
fromme 77960fe81f chore: update project dependencies 2026-11-09 18:15:36 +01:00
fromme 9ce42de4c1 refactor: drop edit-mode room assignment code 2026-11-09 18:01:25 +01:00
fromme d50995a21c fix: pass BusPro room assignments through on edit 2026-11-09 18:00:47 +01:00
fromme 897a1ac7f9 fix: show booked room count in edit summary 2026-11-09 18:00:47 +01:00
fromme 2705401393 feat: add bus-only travel mailing lists 2026-11-09 15:53:53 +01:00
fromme f5894a6bef chore: update project dependencies 2026-11-04 16:50:54 +01:00
fromme e7df5ac03a feat: use a Mailjet sub-account for travel lists 2026-11-04 16:21:15 +01:00
fromme c16f368293 feat: create Mailjet lists for travel mailings 2026-11-04 15:32:50 +01:00
fromme d63fa10a56 fix: show warning flashes in the admin layout 2026-11-04 15:32:49 +01:00
fromme e8fa71175d chore: remove obsolete booking draft repair command 2026-11-04 10:14:10 +01:00
fromme 09610889f3 fix: map live service status to export wording 2026-11-04 10:12:34 +01:00
fromme d8fe9a3875 fix: stop counting held seats in edit usage 2026-11-04 10:10:37 +01:00
fromme e9766dbdce fix: strip invalid XML characters from responses 2026-11-03 15:07:47 +01:00
fromme b047465339 chore: update project dependencies 2026-10-29 15:52:06 +01:00
fromme a60bf68345 docs: document contingent hotel code contract 2026-10-29 15:34:27 +01:00
fromme 97c6330a9a feat: sync contingents for TYPO3 calendar hotels 2026-10-29 15:05:04 +01:00
fromme ec36b61275 feat: add raw contingent availability endpoint 2026-10-29 11:19:31 +01:00
fromme 7c8dbeef48 fix: validate newly entered phone numbers 2026-10-28 13:48:51 +01:00
fromme e293bd3b1c chore: update project dependencies 2026-10-26 16:42:20 +01:00
fromme a7b845751b chore: update project dependencies 2026-10-23 16:22:50 +02:00
fromme 234f3a0bd2 fix: submit BusPro nationality token for applicant 2026-10-23 16:20:37 +02:00
fromme f794d882ca chore: disable unused ssh-agent in ddev web container 2026-10-23 16:11:53 +02:00
fromme 05e348e9b7 chore: upgrade htmx from 1.9 to 2.0 2026-10-20 14:59:54 +02:00
fromme 654214cf37 chore: decide on npm install scripts 2026-10-20 14:58:13 +02:00
fromme 8a6f73bd9b chore: bump node to 24 2026-10-20 14:53:53 +02:00
fromme a6a8783319 fix: show scrollbars only when an inner area overflows 2026-10-20 14:43:10 +02:00
fromme 1d20524678 chore: raise browser targets to tailwind 4's floor 2026-10-20 14:41:43 +02:00
fromme 88f6807399 chore: upgrade tailwind css from 3.4 to 4.3 2026-10-20 14:41:25 +02:00
fromme 91270fdfc3 chore: apply frontend dependency quick fixes 2026-10-20 14:35:56 +02:00
fromme e8d1b69407 chore: update project dependencies 2026-10-20 13:41:57 +02:00
fromme 3428408db5 chore: upgrade to webpack encore 7 2026-10-20 10:06:35 +02:00
fromme 32083c7966 fix: clear inquiry status on room reselection 2026-10-20 09:41:33 +02:00
fromme d4296f30a5 fix: list boarding surcharges per leg and count them once in the summary 2026-10-19 10:38:17 +02:00
fromme 57b86a8fbc chore: remove boarding point price levelling 2026-10-19 10:29:12 +02:00
fromme 3f941bb4bf chore: update message to use informal language 2026-10-19 10:09:41 +02:00
fromme 60de688859 chore: update project dependencies 2026-10-19 09:20:10 +02:00
fromme 69eba9767a docs: committed bookings accept participants without email on the test system 2026-10-19 09:18:22 +02:00
fromme 1d9c852113 feat: make email optional for under-16 participants except applicant 2026-10-19 09:18:22 +02:00
fromme b958d1a371 feat: offer non-travelling applicant choice only on selected travels 2026-10-19 09:18:22 +02:00
fromme 5b75938c73 fix: applicant split in edit mode, drop create-mode agency checks 2026-10-19 09:18:22 +02:00
fromme 1ff293f41f feat: split booking between participants and applicant
addresses #869f2q1vu
2026-10-19 09:18:22 +02:00
fromme 2d49212b08 chore: update project dependencies 2026-10-18 15:27:48 +02:00
fromme b67f25606a fix: use correct config key for sbw subsite 2026-10-17 15:02:46 +02:00
fromme 0ee8fafbe1 fix: name missing fields the user cannot edit on personal data page 2026-10-16 17:46:05 +02:00
fromme 6d2db1b258 feat: add the product code to the travel export 2026-10-15 17:26:12 +02:00
fromme 6f6bf3695c feat: itemised price breakdown on the accommodation booking edit page 2026-10-14 17:16:31 +02:00
fromme a5f8d75caa feat: show and sort accommodation bookings by timestamp of last update 2026-10-14 17:06:50 +02:00
fromme ff722b5cef feat: show the travel name above the mailing list 2026-10-14 14:05:42 +02:00
fromme d305774bee chore: update project dependencies 2026-10-14 12:43:27 +02:00
fromme 1a733a6ba6 feat: open the logs to every administrative role 2026-10-14 12:41:57 +02:00
fromme ade30c6b06 refactor: single admin menu gated per entry by the controller's role 2026-10-14 12:36:35 +02:00
fromme e796dd01ee feat: admin view listing a travel's mailing recipients from bpn-connect 2026-10-14 12:32:23 +02:00
fromme 63ffe0b0b4 chore: update project dependencies 2026-10-13 16:49:34 +02:00
fromme 25edb3f337 feat: show group or contact name in the offer headline 2026-10-13 10:14:55 +02:00
fromme fe40e1807e feat: chatbot trip search finds trips by trip name, hotel name and code 2026-10-13 09:29:27 +02:00
fromme 26d4140c18 feat: trips-only chatbot MCP endpoint without editorial website text 2026-10-12 14:43:46 +02:00
fromme 9f805aaec7 chore: update project dependencies 2026-10-12 10:09:21 +02:00
fromme 849a19b67a fix: exclude cancelled participants' services from price breakdown 2026-10-09 12:33:43 +02:00
fromme 553ec901f3 fix: omit zero-amount surcharges from the booking summary 2026-10-09 12:22:32 +02:00
fromme 0afa4f4855 feat: chatbot prefers structured trip fields over editorial text 2026-10-09 10:09:42 +02:00
fromme 3cb1548f2f fix: give chatbot pickups per-leg surcharges including the return leg 2026-10-09 08:38:57 +02:00
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
429 changed files with 26701 additions and 7992 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
# app's CSS, so the JS isn't transpiled for them either.
defaults and fully supports css-cascade-layers
not chrome < 111
+3 -2
View File
@@ -1,7 +1,7 @@
name: myep name: myep
type: php type: php
docroot: public docroot: public
php_version: "8.4" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: additional_hostnames:
@@ -17,8 +17,9 @@ use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [ ] web_environment: [ ]
nodejs_version: "22" nodejs_version: "24"
corepack_enable: false corepack_enable: false
omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
+13 -8
View File
@@ -44,6 +44,9 @@ MAILJET_API_KEY=
MAILJET_API_SECRET= MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_DEFAULT_LIST_ID= MAILJET_DEFAULT_LIST_ID=
# Sub-account for the travel mailing lists
MAILJET_TRAVEL_MAILING_API_KEY=
MAILJET_TRAVEL_MAILING_API_SECRET=
# Development default for HTTP Basic Auth password; override in production secrets. # Development default for HTTP Basic Auth password; override in production secrets.
MAILJET_WEBHOOK_BASIC_PASSWORD=secret MAILJET_WEBHOOK_BASIC_PASSWORD=secret
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
@@ -82,6 +85,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_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_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/" APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
# 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 endpoints (/mcp, /mcp/trips), 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_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_BASE_URL="https://my.ep-reisen.de" APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]" APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
@@ -92,14 +101,10 @@ APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Compensates a BusPro export defect: a <zustieg_rueck> can omit its preis although the booking # Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg. # script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
# A boarding point costs the same in both directions, so the stated price is applied to both. # Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
# Off = report the export verbatim. Provisional until the vendor fixes the export; see APP_HTMX_BOOST=true
# docs/buspro-database-reference.md section A7.
# Flipping this needs `app:bpn:xml-cache-invalidate` and `app:bpn:refresh-travel-snapshot`,
# because the cached travels and TravelSnapshot rows carry the parsed price.
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
# Emails # Emails
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
+1
View File
@@ -0,0 +1 @@
24
@@ -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() { connect() {
this.itemsCountValue = this.fieldTargets.length 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() { addItem() {
const index = this.fieldTargets.length + 1 const index = this.nextIndex++
const prototype = JSON.parse(this.prototypeValue) const prototype = JSON.parse(this.prototypeValue)
const newField = prototype.replace(/__name__/g, index) const newField = prototype.replace(/__name__/g, index)
this.fieldsTarget.insertAdjacentHTML('beforeend', newField) 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')
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ function handleHistoryRestore() {
function handleTimeout() { function handleTimeout() {
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout // Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
// and handles the counter. This handler only shows the alert. // and handles the counter. This handler only shows the alert.
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.') alert('Die Anfrage hat zu lange gedauert. Bitte versuche es erneut. Falls das Problem weiterhin besteht, kontaktiere bitte unseren Support.')
} }
function handleBeforeHistorySave() { function handleBeforeHistorySave() {
+9 -9
View File
@@ -1,9 +1,9 @@
@import "components/button.css"; @import "./components/button.css";
@import "components/forms.css"; @import "./components/forms.css";
@import "components/gallery.css"; @import "./components/gallery.css";
@import "components/pagination.css"; @import "./components/pagination.css";
@import "components/price_calendar.css"; @import "./components/price_calendar.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/typography.css"; @import "./components/typography.css";
@import "components/rte-content.css"; @import "./components/rte-content.css";
+10 -10
View File
@@ -1,10 +1,10 @@
@import "components/autocomplete.css"; @import "./components/autocomplete.css";
@import "components/badge.css"; @import "./components/badge.css";
@import "components/button.css"; @import "./components/button.css";
@import "components/datatable.css"; @import "./components/datatable.css";
@import "components/datepicker.css"; @import "./components/datepicker.css";
@import "components/forms.css"; @import "./components/forms.css";
@import "components/pagination.css"; @import "./components/pagination.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/typography.css"; @import "./components/typography.css";
+66
View File
@@ -0,0 +1,66 @@
/* Shared by app.css and admin.css, imported right after tailwindcss */
@source "../../templates";
@source "../../src";
@source "../../assets";
@source inline("rotate-90");
@plugin "@tailwindcss/forms";
@theme {
--font-sans: Lato, sans-serif;
--color-primary: #3d8ccb;
--color-secondary: #f9c700;
--color-primary-bg: #ebf3fa;
--color-primary-light: #0070e0;
--color-primary-medium: #165883;
--color-primary-dark: #18527b;
--color-pink: #fa1a8c;
--color-sbw-primary: #B3820F;
--color-sbw-secondary: #E91C26;
--color-sbw-primary-dark: #8D7207;
--color-sbw-overlay: #c20000;
--color-snz-primary: #82c3c6;
--color-snz-secondary: #f9c33d;
--color-snz-primary-dark: #82c3c6;
--color-ser-primary: #006fb6;
--color-ser-secondary: #DE2C1B;
--color-ser-primary-light: #04A4CC;
--background-image-badge-gradient: linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%);
--background-image-brand-gradient: linear-gradient(to top right, #009fff, #165883, #18527b);
}
/*
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
* and buttons had a pointer cursor.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/*
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
* Safari keeps its own overlay scrollbars.
*/
@layer base {
body * {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-300) transparent;
}
}
+4 -6
View File
@@ -1,7 +1,5 @@
@layer utilities { /* Reserve the scrollbar gutter so content does not shift width when a
/* Reserve the scrollbar gutter so content does not shift width when a scrollbar appears (e.g. when a collapsible section is opened). */
scrollbar appears (e.g. when a collapsible section is opened). */ @utility scroll-stable {
.scroll-stable { scrollbar-gutter: stable;
scrollbar-gutter: stable;
}
} }
+4 -7
View File
@@ -1,7 +1,4 @@
@import "tailwindcss/base"; @import "tailwindcss" source(none);
@import "_base.css"; @import "./_theme.css";
@import "./_base.css" layer(base);
@import "tailwindcss/components"; @import "./_components_admin.css" layer(components);
@import "_components_admin.css";
@import "tailwindcss/utilities";
+5 -8
View File
@@ -1,8 +1,5 @@
@import "tailwindcss/base"; @import "tailwindcss" source(none);
@import "_base.css"; @import "./_theme.css";
@import "./_base.css" layer(base);
@import "tailwindcss/components"; @import "./_components.css" layer(components);
@import "_components.css"; @import "./_utilities.css";
@import "tailwindcss/utilities";
@import "_utilities.css";
+11 -5
View File
@@ -1,16 +1,22 @@
.button { .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; /* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
@apply uppercase leading-none text-center text-sm md:text-base; 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 transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark; @apply inline-flex items-center justify-center space-x-2 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-hidden focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply rounded-md; @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 * --spacing(2) + 2px);
} }
.button--small { .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 * --spacing(1) + 2px);
} }
.button--primary { .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 text-white;
@apply hover:bg-none hover:bg-pink/80; @apply hover:bg-none hover:bg-pink/80;
} }
+1 -1
View File
@@ -3,5 +3,5 @@
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn, [data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn,
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover { [data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover {
background-color: theme('colors.secondary'); background-color: var(--color-secondary);
} }
+4 -4
View File
@@ -1,5 +1,5 @@
.pc-day { .pc-day {
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer; @apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded-sm text-white cursor-pointer;
} }
.pc-day--out-of-month { .pc-day--out-of-month {
@@ -11,11 +11,11 @@
} }
.pc-day[data-status="blocked-to-ok"] { .pc-day[data-status="blocked-to-ok"] {
background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%); background: linear-gradient(to bottom right, var(--color-gray-400) 50%, var(--color-emerald-500) 50%);
} }
.pc-day[data-status="checkout-only"] { .pc-day[data-status="checkout-only"] {
background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%); background: linear-gradient(to bottom right, var(--color-emerald-500) 50%, var(--color-gray-400) 50%);
} }
.pc-day[data-status="past"] { .pc-day[data-status="past"] {
@@ -33,7 +33,7 @@
.pc-day.pc-day--pending-start, .pc-day.pc-day--pending-start,
.pc-day.pc-day--selected-start, .pc-day.pc-day--selected-start,
.pc-day.pc-day--selected-end { .pc-day.pc-day--selected-end {
@apply bg-none outline-none bg-secondary text-gray-700; @apply bg-none outline-hidden bg-secondary text-gray-700;
} }
.pc-day.pc-day--in-range { .pc-day.pc-day--in-range {
+5 -5
View File
@@ -1,25 +1,25 @@
@import "toastify-js/src/toastify.css"; @import "toastify-js/src/toastify.css";
.toastify { .toastify {
@apply shadow-lg rounded; @apply shadow-lg rounded-sm;
} }
.toastify--success { .toastify--success {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.emerald.600'); background: var(--color-emerald-600);
} }
.toastify--warning { .toastify--warning {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.600'); background: var(--color-red-600);
} }
.toastify--error { .toastify--error {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.700'); background: var(--color-red-700);
} }
.toastify--info { .toastify--info {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.primary'); background: var(--color-primary);
} }
+20 -20
View File
@@ -1,71 +1,71 @@
@import "tippy.js/dist/tippy.css"; @import "tippy.js/dist/tippy.css";
.tippy-box { .tippy-box {
background-color: theme('colors.primary'); background-color: var(--color-primary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.primary'); border-top-color: var(--color-primary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.primary'); border-bottom-color: var(--color-primary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.primary'); border-left-color: var(--color-primary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.primary'); border-right-color: var(--color-primary);
} }
.sbw { .sbw {
.tippy-box { .tippy-box {
background-color: theme('colors.sbw.secondary'); background-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.sbw.secondary'); border-top-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.sbw.secondary'); border-bottom-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.sbw.secondary'); border-left-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.sbw.secondary'); border-right-color: var(--color-sbw-secondary);
} }
} }
.snz { .snz {
.tippy-box { .tippy-box {
background-color: theme('colors.snz.primary-dark'); background-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.snz.primary-dark'); border-top-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.snz.primary-dark'); border-bottom-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.snz.primary-dark'); border-left-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.snz.primary-dark'); border-right-color: var(--color-snz-primary-dark);
} }
} }
.ser { .ser {
.tippy-box { .tippy-box {
background-color: theme('colors.ser.secondary'); background-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.ser.secondary'); border-top-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.ser.secondary'); border-bottom-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.ser.secondary'); border-left-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.ser.secondary'); border-right-color: var(--color-ser-secondary);
} }
} }
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", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.4", "php": ">=8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-dom": "*", "ext-dom": "*",
"ext-iconv": "*", "ext-iconv": "*",
@@ -21,8 +21,9 @@
"knplabs/knp-paginator-bundle": "^6.10", "knplabs/knp-paginator-bundle": "^6.10",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/html-to-markdown": "^5.1",
"league/oauth2-server-bundle": "^1.0", "league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0", "maennchen/zipstream-php": "^3.2",
"nesbot/carbon": "^3.8", "nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4", "phpoffice/phpspreadsheet": "^5.4",
@@ -43,12 +44,14 @@
"symfony/intl": "7.4.*", "symfony/intl": "7.4.*",
"symfony/lock": "7.4.*", "symfony/lock": "7.4.*",
"symfony/mailer": "7.4.*", "symfony/mailer": "7.4.*",
"symfony/mcp-bundle": "^0.14.1",
"symfony/mime": "7.4.*", "symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.4.*", "symfony/notifier": "7.4.*",
"symfony/process": "7.4.*", "symfony/process": "7.4.*",
"symfony/property-access": "7.4.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*", "symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*", "symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*", "symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*", "symfony/serializer": "7.4.*",
@@ -77,7 +80,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.4.24" "php": "8.5.0"
} }
}, },
"autoload": { "autoload": {
Generated
+1816 -857
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\MenuBundle\KnpMenuBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::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: php_errors:
log: true 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: http_client:
scoped_clients: scoped_clients:
typo3.client: typo3.client:
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%' base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
headers: headers:
accept: 'text/json' accept: 'text/json'
X-Api-Key: '%env(APP_CMS_API_KEY)%'
when@test: when@test:
framework: 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
+30
View File
@@ -0,0 +1,30 @@
# 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. The structured trip fields of search_trips and get_trip (dates, rooms, prices, pickups, inclusions) come from the booking system and override anything editorial text says about them: product descriptions, FAQs and search_content/get_document results can be out of date.'
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\']
# The same trip tools without the website's editorial text: no search_content and
# get_document, and a get_trip that leaves out the product and hotel page texts.
# Same tokens as /mcp; a client picks the variant by the path it is pointed at.
chatbot_trips:
name: 'MyE&P chatbot (trips only)'
version: '1.0.0'
instructions: 'Answers questions about the trips of E&P Reisen and its brands: dates, rooms, prices, availability, pickups and inclusions, all from the booking system. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. Website texts such as descriptions, FAQs and terms are not available here: for anything the tools do not answer, refer the user to the url of the trip.'
http:
path: /mcp/trips
allowed_hosts: false
registry:
tools:
- 'App\Chatbot\Mcp\Tool\SearchTripsTool'
- 'App\Chatbot\Mcp\TripsOnly\'
+15 -2
View File
@@ -5,6 +5,7 @@ monolog:
- core - core
- auth - auth
- admin - admin
- chatbot
when@dev: when@dev:
monolog: monolog:
@@ -33,11 +34,16 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.admin.log" path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: debug level: debug
channels: ["admin"] channels: ["admin"]
chatbot:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
level: debug
channels: ["chatbot"]
main: main:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
channels: ["!event", "!bpn", "!core", "!auth", "!admin"] channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
# uncomment to get logging in your browser # uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration # you may have to allow bigger header sizes in your Web server configuration
#firephp: #firephp:
@@ -96,13 +102,20 @@ when@prod: &prod_config
path: "%kernel.logs_dir%/%kernel.environment%.admin.log" path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: info level: info
channels: ["admin"] 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: main:
type: fingers_crossed type: fingers_crossed
action_level: error action_level: error
handler: nested handler: nested
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks
channels: ["!bpn", "!core", "!auth", "!admin"] channels: ["!bpn", "!core", "!auth", "!admin", "!chatbot"]
nested: nested:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.framework.log" path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
+15 -3
View File
@@ -15,11 +15,16 @@ security:
mailjet: mailjet:
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%' password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
roles: [ ROLE_MAILJET_WEBHOOK ] 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_hierarchy:
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be # ROLE_CUSTOMER_EXPERT gates the customer-service surfaces (booking drafts, travel
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators # mailing). Inheriting it here is what keeps them open to administrators: the booking
# after the #[IsGranted] attributes moved to the narrower role. # drafts used to be ROLE_ADMIN only before their #[IsGranted] moved to the narrower role.
ROLE_ADMIN: ROLE_ADMIN:
- ROLE_GROUPS_ADMIN - ROLE_GROUPS_ADMIN
- ROLE_CUSTOMER_EXPERT - ROLE_CUSTOMER_EXPERT
@@ -45,6 +50,12 @@ security:
provider: mailjet_webhook_provider provider: mailjet_webhook_provider
http_basic: http_basic:
realm: 'Mailjet Webhook' realm: 'Mailjet Webhook'
mcp:
pattern: ^/mcp(/trips)?$
stateless: true
provider: mcp_provider
access_token:
token_handler: App\Security\McpAccessTokenHandler
main: main:
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
@@ -58,6 +69,7 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
- { path: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
+1
View File
@@ -5,6 +5,7 @@ twig:
'%kernel.project_dir%/assets/images': images '%kernel.project_dir%/assets/images': images
globals: globals:
htmx_change_trigger: 'change delay:100ms' htmx_change_trigger: 'change delay:100ms'
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%' website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%' customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%' legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
+16
View File
@@ -29,6 +29,10 @@ zenstruck_schedule:
frequency: "45 1 * * *" frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests" description: "Removes expired pending newsletter double opt-in requests"
- task: app:cleanup:travel-mailing-lists
frequency: "50 1 * * *"
description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
- task: league:oauth2-server:clear-expired-tokens - task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *" frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly" description: "Removes expired oauth2 tokens hourly"
@@ -67,3 +71,15 @@ when@prod:
- task: app:bpn:sync-contingents - task: app:bpn:sync-contingents
frequency: "5 20-23,0-7 * * *" frequency: "5 20-23,0-7 * * *"
description: "Syncs the full contingent horizon hourly outside peak hours" 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
+90 -23
View File
@@ -16,6 +16,26 @@ parameters:
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%' 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 list ids and their labels
mailjet_lists: mailjet_lists:
@@ -128,18 +148,22 @@ services:
- '../src/Model' - '../src/Model'
- '../src/Kernel.php' - '../src/Kernel.php'
App\BusProNet\ApiClient: App\BusProNet\Transport\XmlChannel:
arguments: arguments:
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
$options: $options:
bpn_username: '%env(APP_BPN_USER)%' bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%' bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
debug: '%env(bool:APP_BPN_DEBUG)%' debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%' App\BusProNet\Transport\SocketTransport:
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%' 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: App\Twig\AppRuntime:
arguments: arguments:
@@ -157,14 +181,6 @@ services:
method: createMainMenu method: createMainMenu
alias: admin_main alias: admin_main
App\Menu\GroupsMenuBuilder:
arguments:
$factory: '@knp_menu.factory'
tags:
- name: knp_menu.menu_builder
method: createMainMenu
alias: groups_main
App\Service\BpnXmlSyncManager: App\Service\BpnXmlSyncManager:
arguments: arguments:
$xmlSource: '@xml_source.storage' $xmlSource: '@xml_source.storage'
@@ -185,10 +201,6 @@ services:
arguments: arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%' $retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\BusProNet\XmlParser\TravelParser:
arguments:
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
App\Service\TravelDataProvider: App\Service\TravelDataProvider:
arguments: arguments:
$logger: '@monolog.logger.core' $logger: '@monolog.logger.core'
@@ -228,17 +240,44 @@ services:
$cache: '@bpn.cache' $cache: '@bpn.cache'
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator: App\Service\VoucherValidator:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
$logger: '@monolog.logger.bpn' $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 # Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~ App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~ App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~ App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~ App\Form\Service\ParticipantVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services # Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry: App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -264,8 +303,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler' - '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler' - '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules # Booking Status Rules
@@ -294,10 +332,22 @@ services:
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider' - '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider' - '@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: App\Service\CmsDataProvider:
arguments: arguments:
$httpClient: '@typo3.client' $httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
App\Logger\RequestIdProcessor: App\Logger\RequestIdProcessor:
tags: tags:
@@ -373,6 +423,9 @@ services:
App\BpnConnect\ContingentsClient: App\BpnConnect\ContingentsClient:
parent: App\BpnConnect\AbstractApiClient parent: App\BpnConnect\AbstractApiClient
App\BpnConnect\TravelsClient:
parent: App\BpnConnect\AbstractApiClient
App\ClickUp\ApiClient: App\ClickUp\ApiClient:
arguments: arguments:
$apiToken: '%env(default::CLICKUP_API_TOKEN)%' $apiToken: '%env(default::CLICKUP_API_TOKEN)%'
@@ -388,6 +441,20 @@ services:
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%' $defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%' $contactMetadataFields: '%mailjet_contact_metadata_fields%'
# Travel mailing lists live in a Mailjet sub-account, which has its own key pair and keeps
# its contacts and lists apart from the newsletter on the main account.
app.mailjet.travel_mailing_client:
class: App\Mailjet\ApiClient
arguments:
$apiKey: '%env(default::MAILJET_TRAVEL_MAILING_API_KEY)%'
$apiSecret: '%env(default::MAILJET_TRAVEL_MAILING_API_SECRET)%'
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
App\Service\TravelMailingListManager:
arguments:
$mailjet: '@app.mailjet.travel_mailing_client'
App\EventListener\MaintenanceModeListener: App\EventListener\MaintenanceModeListener:
arguments: arguments:
$options: $options:
+1 -1
View File
@@ -5,7 +5,7 @@ parameters:
theme: base theme: base
gtm_id: GTM-56LWRF gtm_id: GTM-56LWRF
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
myswb.ddev.site: mysbw.ddev.site:
theme: sbw theme: sbw
gtm_id: GTM-MS2C8CH gtm_id: GTM-MS2C8CH
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
+2 -2
View File
@@ -40,6 +40,7 @@ $rsyncOptions = [
'.env.dev.local.php', '.env.dev.local.php',
'.env.test', '.env.test',
'.env.test.local', '.env.test.local',
'.browserslistrc',
'.gitignore', '.gitignore',
'.nvmrc', '.nvmrc',
'.php-cs-fixer.cache', '.php-cs-fixer.cache',
@@ -58,8 +59,7 @@ $rsyncOptions = [
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'ray.php', 'ray.php',
'tailwind.config.js', 'webpack.config.mjs',
'webpack.config.js',
'bin/.phpunit', 'bin/.phpunit',
'temp/', 'temp/',
'tests/', 'tests/',
+248 -22
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. - 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. - 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. 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 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` the whole export is a single page today. 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`. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`. - Send the token as `Authorization: Bearer <access_token>`.
@@ -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**. 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. 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` #### `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 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. 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 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. arbitrary string, so an export hung off that collection would share a namespace with every date code.
@@ -170,6 +172,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
{ {
"busProId": 12794, "busProId": 12794,
"code": "PCJSER261226", "code": "PCJSER261226",
"productCode": "PCJSER",
"product": "Semesterende - Portes du Soleil - Joliment", "product": "Semesterende - Portes du Soleil - Joliment",
"dateFrom": "2026-12-26", "dateFrom": "2026-12-26",
"dateTo": "2026-12-30", "dateTo": "2026-12-30",
@@ -188,7 +191,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"city": "Essen", "city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)", "street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15", "time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00" "datetime": "2026-12-25T20:15:00"
} }
] ]
} }
@@ -200,6 +203,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"totalPages": 1, "totalPages": 1,
"skipped": 0, "skipped": 0,
"pickupsWithoutMasterData": 0, "pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00" "generatedAt": "2026-09-29T12:10:06+02:00"
} }
} }
@@ -209,8 +213,13 @@ 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 is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null. empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData` **`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the recognise dates of the same product. Do not derive it from the date `code` by cutting off the date: some
products run dates under a different code (`DWWMP` runs `DWWMW…`), and group travels carry each group's own
code on the date. It is `null` for the few travels that have no code upstream.
**`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)`. 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 - `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -222,12 +231,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. **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 - `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.** `YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved - Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an `02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for `Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
bare-time sources too, and carries that resolution. 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 - A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together. null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some - Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -238,6 +252,159 @@ 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. 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. 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,
"returnLeg": true,
"returnPrice": 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. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and
`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled
against the other. A point served on the return leg only is not emitted. 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 ### Travels
@@ -340,21 +507,74 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel. `502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
Each entry, ordered by date:
```json
{"date": "2027-01-30", "status": "BLOCKED"}
```
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
Two differences from `calendar`:
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
#### `GET /api/contingents/status` — scope `api`
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
```json
{
"hotelCode": "DGS",
"name": "Sportclub Spinabad",
"sources": ["accommodation", "typo3"],
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
"pricedFrom": "2027-06-01",
"pricedTo": "2027-09-30",
"syncedAt": "2026-10-29T10:15:00+01:00",
"lastError": null,
"failureCount": 0,
"available": true
}
```
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
--- ---
### Operating the snapshot (deployment) ### Operating the snapshot (deployment)
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability. The snapshot is filled only by the scheduler, so on an empty database (a new environment, or after the snapshot tables were truncated) every hotel returns `502` until the first successful sync — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
Rollout order: Bringing up an environment:
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`. 1. **Confirm `BPN_CONNECT_API_KEY` is valid for the environment.** If it is rejected the sync cannot populate anything and the endpoints stay at `502`.
2. Deploy; `database:migrate` creates the empty tables. 2. Deploy; `database:migrate` creates the tables.
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period. 3. Run `bin/console app:bpn:sync-contingents -v` on the host promptly, ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response. 4. Verify with `GET /api/contingents/status`: every row `available: true`, `failureCount` 0.
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally. Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
**TYPO3's codes must be BusPro calendar codes.** The sync sends every code to bpn-connect as is. A TYPO3 hotel whose code is a CMS code rather than the BusPro hotel code (group houses often have both, e.g. calendar code `DKS`, CMS code `DKSGRP`) is answered with a `404` by bpn-connect: that code fails on every run, logs two errors each time, and shows up in `contingents/status` with `lastError` set and `available: false`. The task itself keeps succeeding, since only a total failure fails it. The fix belongs in TYPO3, by pointing the product at a hotel whose code is the BusPro code; MyE&P does not translate CMS codes. `contingents/status` is the place to spot such codes, and `accommodation.cmsCode` there shows both codes side by side for group houses.
### Freshness and propagation ### Freshness and propagation
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side. There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
@@ -398,14 +618,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"totalDiscountLabel": null, "totalDiscountLabel": null,
"totalPrice": 123456, "totalPrice": 123456,
"pricingCurrency": "EUR", "pricingCurrency": "EUR",
"pricingVersion": 3, "pricingVersion": 2,
"priceBreakdown": {} "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. `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. `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 +635,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
| field | meaning | | 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 | | `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` | | `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 | | `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
+97 -14
View File
@@ -25,7 +25,7 @@ Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
key = md5( user + password + date("YYYYMMDD") + 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. the key before sending, so a hand-built payload can carry a placeholder.
## Satz types ## Satz types
@@ -39,7 +39,7 @@ the key before sending, so a hand-built payload can carry a placeholder.
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`. `VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section. `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`. extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
Its contract is observed, not specified. Its contract is observed, not specified.
@@ -167,11 +167,20 @@ fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` recor
`telefonprivat` and no `nationalitaet`. `telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a **Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`. above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is is stricter than what BusPro applies to ordinary participants.
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
the placeholder records and is stricter than what BusPro applies to ordinary participants. **The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
`teilnehmer` needed one once a room was present, children included: without it the answer was
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
Anmelder) all pass with an unchanged price. A committed `Buchung` (status `A`) with an email-less child
goes through as well, both with a travelling Anmelder and with one who does not travel. BusPro now accepts a participant without an email at any age,
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
without an email can be booked there.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking **The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is for their children) returns `möglich` with the same price as the control where the Anmelder is
@@ -201,14 +210,14 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
## How this app maps onto the contract ## How this app maps onto the contract
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the `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 | | 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 | | `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room | | `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant | | `anmelder` | yes | yes | Create builds it from participant[0], or from `BookingDto::$applicant` when the applicant does not travel; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` | | `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | | | `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | | `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
@@ -253,7 +262,7 @@ booking before relying on it.
### Gap: edit never dry-runs ### Gap: edit never dry-runs
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends 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 `Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
price *before* mutating a live booking. price *before* mutating a live booking.
@@ -312,6 +321,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 `ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
`anzahlung` or errors `1011`-`1018` follow. `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 ## Error codes
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
@@ -320,7 +390,7 @@ runtime.
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message > **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
> carrier whose entire content is substituted. `Notification::isError()` > carrier whose entire content is substituted. `Notification::isError()`
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on > (`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"*. > real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO. > `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
@@ -359,8 +429,21 @@ zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum 995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm / **Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein 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 ## Applying this: the teamer placeholder booking
BIN
View File
Binary file not shown.
+6 -10
View File
@@ -225,16 +225,12 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId); WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
``` ```
**V 2026-09-28 — the XML export can omit the return-leg price.** Since one price row is **V 2026-10-02 — an unpriced return leg is free.** The export can state a `preis` on the
surfaced twice, an asymmetric pair is an export defect: on products 701 and 2435 a outbound `<zustieg>` but none on the matching `<zustieg_rueck>` (seen on products 701 and
`<zustieg_rueck>` carries no `preis` although the engine still bills the return 2435). The booking engine bills a return `Abfahrtsortzuschlag` only where the return entry
`Abfahrtsortzuschlag`, so reading the export verbatim undercharges by one leg. Measured over states a price, so the export is read verbatim, per leg. A missing return price is a catalog
all exports, a stated return price never differs from its outbound counterpart (510 pairs, omission for the office to fix, not something to compensate locally: pricing the return leg
zero counter-examples). `TravelParser` therefore levels a boarding point's price across both from the outbound one makes the booking inquiry fail with a price mismatch.
directions, switchable through `APP_BPN_LEVEL_BOARDING_POINT_PRICES` while the defect is open
with the vendor. Flipping the flag needs `app:bpn:xml-cache-invalidate` and
`app:bpn:refresh-travel-snapshot`. A point appearing **only** in `zustiege_rueck` without a
price cannot be priced from our side — that is a catalog job.
**N — the departure surcharge is computed.** Booking responses carry a position **N — the departure surcharge is computed.** Booking responses carry a position
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag` `art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
@@ -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 — socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
the document that drives the portal's entire authorization model. 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. `satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
Response side: `CrmAttributesResponseParser` Response side: `CrmAttributesResponseParser`
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into (`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
@@ -24,6 +24,7 @@ Symfony roles:
| `1478` | `ROLE_GROUPS_ADMIN` | | `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` | | `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) | | `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}` | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` | | nothing matched | `ROLE_CUSTOMER` |
@@ -45,7 +46,7 @@ filtered before anything is stored:
| --- | --- | --- | | --- | --- | --- |
| `ROLE_TEAMER` | granted | granted | | `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated | | `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` - **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 (`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, `dbo.AgenturSelektionenKunde` is a bare two-column link table,
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The `(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
portal authenticates against BusPro as an **agency** account (`bpn_username` in 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 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. 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: Escalation path, cheapest first:
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the 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 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 from a response captured after the insert, the staleness is on the BusProNet host and
an application-pool/service restart there is the test. 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. > per-endpoint migration verdicts and the `frei` discovery SQL.
This complements `travel-queries.md`. That doc replaced the **XML export** (static This complements `travel-queries.md`. That doc replaced the **XML export** (static
catalog). This one covers the **live socket API** methods on catalog). This one covers the **live socket API** methods of the clients in
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL `src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
query in the Go proxy. query in the Go proxy.
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free" **Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
+191
View File
@@ -0,0 +1,191 @@
# 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. `POST /mcp/trips` is the same
server without the editorial content, see [Trips only](#trips-only-mcptrips).
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 trip `code` and `hotelCode`, "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 |
### Trips only: `/mcp/trips`
A second server (`chatbot_trips` in `config/packages/mcp.yaml`) for clients that must not answer from
editorial website text, which can contradict the booking system, most visibly on prices. A client picks
the variant by the path it is pointed at; tokens, rate limit and log are those of `/mcp`.
- Only `search_trips` and `get_trip`. `search_content` and `get_document` do not exist here.
- `get_trip` (`App\Chatbot\Mcp\TripsOnly\GetTripTool`) leaves out the product `description`, which
includes the FAQs, and each hotel's `description`.
- What remains from TYPO3 is structured: trip and hotel names, `url`, `site`, destination, concept and
the hotel `facts` (beds, altitude, distances, sauna, wifi, …; no prices). Which trips exist is still
decided by TYPO3's products and their `searchable` flag, and the free-text `query` of `search_trips`
still falls back to the page texts to find a trip, without returning any of it.
- The server instructions tell the model to refer to the trip's `url` for anything the tools don't answer.
To give a client the editorial content back, point it at `/mcp` again.
Editorial text (product descriptions, FAQs, pages) is maintained by hand in TYPO3 and can lag behind BusPro, e.g. an
old price or a changed inclusion. The server instructions and the tool descriptions therefore tell the model that the
structured trip fields of `search_trips` and `get_trip` override whatever the text says about dates, prices, pickups
and inclusions.
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.
- BusPro has hotel names of its own (`hotel.xml`, joined on the hotel id). They are internal wording
("NEU Sportclub Lederer Wochenenden und Kurztrips", "Vacanceole Résidence L'Edelweiss"), so a hotel is
shown under its TYPO3 title, and under BusPro's name only when TYPO3 has no hotel for its code.
`query` searches both. Trips have no name in BusPro's export, only a code; their names are TYPO3's.
- 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.
- Pickup surcharges are per person and per leg, so each pickup carries `surchargeOutbound`, `surchargeReturn` and
their sum `surchargeRoundTrip`. A single figure was read by the model as the whole price. A leg listed without a
price is `0`, because BusPro bills only priced legs; `surchargeReturn` is absent only where the point is not
served on the way back.
- 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.
- **`query`:** the site has no free text. A code or a name is taken at its word, so that naming a trip or
hotel returns that one and not every page mentioning it. The first of these that finds anything decides
(`TripCatalog::resolveQuery()`, `App\Chatbot\Search\NameQuery`):
1. a product code, a hotel code or alias, or a date code;
2. names holding every word of the query, compared without accents, case and German stopwords, each
word as a prefix: "Lederer" is "Sportclub Lederer", "Chaviere" is "Val Chavière". Names are those of
products, hotels (TYPO3's and BusPro's), cities, regions, countries and concepts;
3. names the query spells out among other words ("Hotel Chamois d'Or in Val Thorens"), where the most
specific kind wins: product or hotel, then city, region, country or concept;
4. the page texts of those records, every term required, then any term.
A matching hotel selects its own travel dates, not all hotels of its trips. A destination or concept
selects its trips. Matches of one step are combined, so "Österreich Sportclub" is everything in Austria
plus every Sportclub; `country` and `concept` narrow instead.
- **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` and `/mcp/trips`, 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');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261029120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
// written, until a later migration drops it; that keeps this step reversible.
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
}
public function down(Schema $schema): void
{
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('DROP TABLE calendar_hotel');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261104142531 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds travel_mailing_list to track the Mailjet contact lists created per travel until their deletion.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE travel_mailing_list');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261109074654 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
// Every existing list holds all participants and is named by its travel code alone.
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
}
public function down(Schema $schema): void
{
// Fails while a travel has two lists; delete the bus-only one first.
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
}
}
+1810 -3097
View File
File diff suppressed because it is too large. Load diff
+14 -12
View File
@@ -1,31 +1,29 @@
{ {
"name": "myep", "name": "myep",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^8.0.7",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^8.0.7",
"@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/webpack-encore": "^6.0.0", "@symfony/webpack-encore": "^7.2.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/postcss": "^4.3.3",
"autoprefixer": "^10.4.20", "babel-plugin-polyfill-corejs3": "^1.0.0",
"core-js": "^3.38.0", "core-js": "^3.51.0",
"cypress": "^15.5.0", "cypress": "^15.5.0",
"file-loader": "^6.2.0", "htmx.org": "^2.0.11",
"htmx.org": "^1.9.12", "lightningcss": "^1.33.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"regenerator-runtime": "^0.13.9", "tailwindcss": "^4.3.3",
"tailwindcss": "^3.4.15",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^7.2.3"
}, },
"license": "WTFPL", "license": "WTFPL",
"private": true, "private": true,
"scripts": { "scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev", "dev": "encore dev",
"watch": "encore dev --watch", "watch": "encore dev --watch",
"build": "encore production --progress", "build": "encore production --progress",
@@ -37,5 +35,9 @@
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"stimulus-use": "^0.52.3", "stimulus-use": "^0.52.3",
"vanilla-calendar-pro": "^3.1.0" "vanilla-calendar-pro": "^3.1.0"
},
"allowScripts": {
"cypress": true,
"core-js": false
} }
} }
+1 -3
View File
@@ -1,7 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-import': {}, '@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
} }
} }
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
readonly class TravelMailingRecipient
{
public function __construct(
public string $email,
public string $firstName,
public string $lastName,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
use App\Enum\BusLeg;
readonly class TravelMailingResponse
{
/**
* @param TravelMailingRecipient[] $data
*/
public function __construct(
public string $travelCode,
public array $data,
public ?BusLeg $busLeg = null,
) {
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\Model\TravelMailingResponse;
use App\Enum\BusLeg;
class TravelsClient extends AbstractApiClient
{
private const string MAILING_PATH = '/api/v1/travels/mailing';
/**
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
* apart from a travel without confirmed participants.
*
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
*/
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
{
$query = ['travelCode' => $travelCode];
if (null !== $busLeg) {
$query['busLeg'] = $busLeg->value;
}
$payload = $this->request(self::MAILING_PATH, $query);
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
// must not end up on a list meant for bus passengers only.
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
if ($appliedBusLeg !== $busLeg?->value) {
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
'travel_code' => $travelCode,
'requested_bus_leg' => $busLeg?->value,
'applied_bus_leg' => $appliedBusLeg,
]);
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
}
$recipients = array_map(
static fn (array $item) => new TravelMailingRecipient(
email: $item['email'],
firstName: $item['first_name'] ?? '',
lastName: $item['last_name'] ?? '',
),
$payload['data'] ?? [],
);
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
}
}
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));
}
}
@@ -49,8 +49,9 @@ class BookingDataProcessor
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references * - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the * - For normal bookings, participant[0]'s missing address/dimensions are filled from the
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>) * applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not * - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
* the first participant, so copying applicant data into participant[0] would be incorrect * bookings (the applicant is a customer) and split bookings (the applicant does not
* travel), where it would write one person's address onto another
*/ */
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
{ {
@@ -74,32 +75,6 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant); $participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index; $participantData->index = $index;
// For normal bookings, applicant === participant[0]: copy address and body dimensions
// from applicant when BPN returns full data only in <anmelder> and minimal data in
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
// a customer (a different entity from the first traveler).
if (0 === $index && false === $isInternalAgencyBooking) {
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
// Extract service selections from booking and assign to participant DTO // Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking $participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
@@ -178,14 +153,57 @@ class BookingDataProcessor
$roomSelection->price = $room->price; $roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1; $roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection; $dto->roomSelections[] = $roomSelection;
} }
} }
// Runs once the participants are in place, because the decision needs the assembled DTO.
// The explicit flag covers internal agency bookings, whose applicant is a customer; the
// DTO predicate covers a split booking, whose participant[0] is a different person.
if (false === $isInternalAgencyBooking && $dto->isFirstParticipantApplicant()) {
$this->fillFirstParticipantFromApplicant($dto, $booking);
}
return $dto; return $dto;
} }
/**
* Fills gaps in participant[0] from the applicant, who is the same person.
*
* BPN routinely returns the full record in <anmelder> and only a minimal one in
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
* split booking or an internal agency booking this would write somebody else's address
* onto a traveller at the next edit submit.
*/
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
{
$participantData = $dto->getParticipant(0);
if (null === $participantData) {
return;
}
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
/** /**
* Enriches participant service selections with data from travel model. * Enriches participant service selections with data from travel model.
* *
@@ -322,6 +340,7 @@ class BookingDataProcessor
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData); $this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
} }
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
$this->serviceProcessor->removeUnusedServices($bookingData); $this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData( $this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants, $formData->participants,
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
*/ */
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
{ {
$firstParticipant = $bookingDto->participants[0]; // The applicant is participant[0] on an ordinary booking, and a person of their own
// when they do not travel along.
$applicantSource = $bookingDto->applicantTravels
? $bookingDto->participants[0]
: ($bookingDto->applicant ?? $bookingDto->participants[0]);
$payload = [ $payload = [
'buchungsart' => $bookingType, 'buchungsart' => $bookingType,
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
'idagentur' => $bookingDto->agencyId, 'idagentur' => $bookingDto->agencyId,
]; ];
// Add applicant (first participant data)
$payload['anmelder'] = [
'name' => $firstParticipant->lastName,
'vorname' => $firstParticipant->firstName,
'geschlecht' => $firstParticipant->gender ?? '',
'nationalitaet' => $firstParticipant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
if (null !== $firstParticipant->dateOfBirth) {
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
}
// Add address for applicant
if (null !== $firstParticipant->address) {
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
}
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation'] = [];
if (null !== $firstParticipant->email) {
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
}
if (null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
}
}
// Determine if this is an inquiry booking for voucher handling // Determine if this is an inquiry booking for voucher handling
$isInquiryBooking = 'A' === $bookingDto->bookingStatus; $isInquiryBooking = 'A' === $bookingDto->bookingStatus;
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
// Add participants // Add participants
$payload['teilnehmerliste']['teilnehmer'] = []; $payload['teilnehmerliste']['teilnehmer'] = [];
foreach ($bookingDto->participants as $index => $participant) { foreach ($bookingDto->participants as $index => $participant) {
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
'name' => $participant->lastName,
'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '',
'nationalitaet' => $participant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$participantData = [
if (null !== $participant->dateOfBirth) { '@id' => $index + 1,
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); 'status' => self::DEFAULT_PARTICIPANT_STATUS,
} ] + $this->buildPersonalDataPayload($participant, true);
// Add address (always include structure, even if empty)
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
// Add contact info for all participants
if (null !== $participant->email || null !== $participant->mobile) {
$participantData['kommunikation'] = [];
if (null !== $participant->email) {
$participantData['kommunikation']['email'] = $participant->email;
}
if (null !== $participant->mobile) {
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
}
}
// Add body dimensions // Add body dimensions
if (null !== $participant->height) { if (null !== $participant->height) {
@@ -434,6 +383,57 @@ class BookingPayloadBuilder
return $payload; return $payload;
} }
/**
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
*
* @param ParticipantDto $person The applicant or participant to map
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
* even when empty; the anmelder only when set
*
* @return array<string, mixed>
*/
private function buildPersonalDataPayload(ParticipantDto $person, bool $alwaysIncludeAddress = false): array
{
$data = [
'name' => $person->lastName,
'vorname' => $person->firstName,
'geschlecht' => $person->gender ?? '',
'nationalitaet' => $person->nationality ?? '',
];
if (null !== $person->dateOfBirth) {
$data['geburtsdatum'] = $person->dateOfBirth->format('d.m.Y');
}
if (null !== $person->address) {
$data['anschrift'] = $person->address->toPayload();
} elseif ($alwaysIncludeAddress) {
$data['anschrift'] = [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
}
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
if (null !== $email || null !== $person->mobile) {
$data['kommunikation'] = [];
if (null !== $email) {
$data['kommunikation']['email'] = $email;
}
if (null !== $person->mobile) {
$data['kommunikation']['telefonmobil'] = $person->mobile;
}
}
return $data;
}
/** /**
* Adds services from a mapping to the payload. * Adds services from a mapping to the payload.
* *
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
* Processes participant service selections for booking updates. * Processes participant service selections for booking updates.
* *
* Handles the assignment and mapping of services (additional, transportation, * Handles the assignment and mapping of services (additional, transportation,
* pickup, room, insurance) from participant DTOs to the booking model during * pickup, insurance) from participant DTOs to the booking model during the
* the update request workflow. * update request workflow. Rooms are not taken from the DTOs: they cannot be
* changed in edit mode, so the booking's own room assignments are sent back.
*/ */
class ParticipantServiceProcessor class ParticipantServiceProcessor
{ {
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
* Resets all existing participant-to-service mappings to start with a clean slate. * Resets all existing participant-to-service mappings to start with a clean slate.
* *
* This ensures that service assignments are rebuilt from scratch based on current form selections. * This ensures that service assignments are rebuilt from scratch based on current form selections.
* Includes resetting room mappings to allow room reassignments during edit. * Room mappings are left alone, see removeCanceledParticipantsFromRooms().
* *
* @param Booking $bookingData The booking data object containing services to reset * @param Booking $bookingData The booking data object containing services to reset
*/ */
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
...$bookingData->transportationServices, ...$bookingData->transportationServices,
...$bookingData->pickups, ...$bookingData->pickups,
...$bookingData->dropOffs, ...$bookingData->dropOffs,
...$bookingData->rooms,
...$bookingData->insurances, ...$bookingData->insurances,
]; ];
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
* Processes all services for a single participant. * Processes all services for a single participant.
* *
* This orchestrator method handles the complete service assignment workflow for one participant, * This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, pickup locations, and room assignments. * including additional services, transportation services, pickup locations, and insurance.
* *
* @param ParticipantDto $participant The participant data from the form * @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update * @param Booking $bookingData The booking data object to update
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
$this->processTransportationServices($participant, $bookingData, $travelData); $this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData); $this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData); $this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData); $this->processInsurance($participant, $bookingData, $travelData);
} }
@@ -271,27 +270,23 @@ class ParticipantServiceProcessor
} }
/** /**
* Processes room assignment for a participant. * Removes canceled participants from the booking's room assignments.
* *
* Maps the participant to their assigned room. Allows room reassignment during * Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
* booking edits while maintaining the constraint that participants can only be * them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
* assigned to room types that have already been booked. * session would undo any reassignment made in BusPro while the edit was open, and send a
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
* other service.
* *
* @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update
* @param Booking $bookingData The booking data object to update
*/ */
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
{ {
if (null === $participant->assignedRoomId) {
return;
}
// Find the room in the existing booking by ID
foreach ($bookingData->rooms as $room) { foreach ($bookingData->rooms as $room) {
if ($room->id === $participant->assignedRoomId) { $room->mapping = array_values(array_filter(
$room->mapping[] = $participant->index; $room->mapping,
break; fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
} ));
} }
} }
@@ -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; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient; use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider 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); $item->expiresAfter(6 * 60 * 60);
return $this return $this
->apiClient ->referenceDataClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES) ->getBaseData(MessageType::Countries)
->getItems() ->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';
}
+33 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
@@ -15,17 +16,47 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class Communication class Communication
{ {
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $phone = null; public ?string $phone = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $mobile = null; public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] // The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])] public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_identity'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_identity'])]
public ?string $email = null; public ?string $email = null;
public bool $newsletter = false; public bool $newsletter = false;
public function rememberLoadedNumbers(): void
{
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
/** /**
* Converts the communication data to API payload format. * Converts the communication data to API payload format.
* *
+5 -3
View File
@@ -23,10 +23,12 @@ class PersonalData
public bool $mutable = false; public bool $mutable = false;
public ?string $status = null; public ?string $status = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] // personal_identity holds what the user cannot edit in MyE&P (name and the login e-mail), so the
// personal-data form can validate everything else while the completeness check still sees it.
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
public ?string $name = null; public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
public ?string $firstName = ''; public ?string $firstName = '';
public ?string $salutation = null; public ?string $salutation = null;
@@ -53,7 +55,7 @@ class PersonalData
#[Assert\Valid(groups: ['personal_data'])] #[Assert\Valid(groups: ['personal_data'])]
public Address $address; public Address $address;
#[Assert\Valid(groups: ['personal_data'])] #[Assert\Valid(groups: ['personal_data', 'personal_identity'])]
public Communication $communication; public Communication $communication;
/** @var list<string> */ /** @var list<string> */
+15
View File
@@ -20,6 +20,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Travel class Travel
{ {
use SortByPriceTrait; use SortByPriceTrait;
/**
* CRM selection that offers the "I am not travelling myself" choice on a travel date.
*/
private const NON_TRAVELLING_APPLICANT_SELECTION_ID = 1488;
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null; public ?int $id = null;
@@ -240,6 +246,15 @@ class Travel
return false; return false;
} }
/**
* Whether a booking for this travel date may be placed by an applicant who does not travel
* along, such as a parent booking for their children.
*/
public function allowsNonTravellingApplicant(): bool
{
return $this->hasSelectionId(self::NON_TRAVELLING_APPLICANT_SELECTION_ID);
}
/** /**
* Retrieves rooms filtered by their IDs. * Retrieves rooms filtered by their IDs.
* *
@@ -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; namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo final readonly class XmlExportInfo
{ {
private const INFO_FILENAME = 'uebertragung.info'; private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME; 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 public static function fromString(string $content): self
{ {
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252'); $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 * 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 * 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. * 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 protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{ {
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try { try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) { if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided // time only provided
$dateTime = Carbon::createFromFormat('H:i', $string); $dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date // assign default date
$dateTime->setDateFrom($defaultDate); $dateTime->setDateFrom($defaultDate);
} else { } else {
// date and time provided // 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(); 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);
}
}
+271
View File
@@ -0,0 +1,271 @@
<?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\Utility\XmlCharacterFilter;
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
{
// The dump keeps the raw response; only the parser sees the filtered one.
$filtered = XmlCharacterFilter::strip($xml, $removed);
if ($removed > 0) {
$this->logger->warning('Removed characters not allowed in XML from API response', [
'request_id' => $requestId,
'type' => $type->value,
'removed' => $removed,
]);
}
try {
return $this->responseParser->parseXmlString($type, $filtered, $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);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
/**
* Removes characters XML 1.0 does not allow from a BusPro response before it is parsed.
*
* BusPro escapes whatever a field holds into a character reference without checking it,
* so a NUL-padded value comes out as `&#x0;`. libxml rejects that as fatal and the whole
* response fails to parse, not just the field. The same applies to the raw control bytes.
*/
final class XmlCharacterFilter
{
/**
* @param-out int $count the number of characters removed
*/
public static function strip(string $xml, ?int &$count = null): string
{
$count = 0;
$xml = preg_replace_callback(
'/&#(x[0-9a-f]+|[0-9]+);/i',
static function (array $match) use (&$count): string {
$reference = $match[1];
$codePoint = 'x' === strtolower($reference[0]) ? hexdec(substr($reference, 1)) : (int) $reference;
if (self::isAllowed($codePoint)) {
return $match[0];
}
++$count;
return '';
},
$xml,
);
// Control bytes never occur inside a UTF-8 multibyte sequence, so a byte-wise match is safe.
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml, -1, $rawCount);
$count += $rawCount;
return $xml;
}
/**
* The Char production of XML 1.0.
*/
private static function isAllowed(int|float $codePoint): bool
{
return 0x9 === $codePoint
|| 0xA === $codePoint
|| 0xD === $codePoint
|| ($codePoint >= 0x20 && $codePoint <= 0xD7FF)
|| ($codePoint >= 0xE000 && $codePoint <= 0xFFFD)
|| ($codePoint >= 0x10000 && $codePoint <= 0x10FFFF);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\XmlLoader; namespace App\BusProNet\XmlLoader;
use App\BusProNet\ApiClient; use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\Model\Agency; use App\BusProNet\Model\Agency;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
@@ -19,7 +19,7 @@ class AgencyLoader
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly ApiClient $apiClient, private readonly ReferenceDataClient $referenceDataClient,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -34,7 +34,7 @@ class AgencyLoader
// Cache for 24 hours (agencies change infrequently) // Cache for 24 hours (agencies change infrequently)
$item->expiresAfter(24 * 60 * 60); $item->expiresAfter(24 * 60 * 60);
$result = $this->apiClient->getAgencies(); $result = $this->referenceDataClient->getAgencies();
if ($result instanceof Notification) { if ($result instanceof Notification) {
$this->logger->error('Failed to fetch agencies from BPN API', [ $this->logger->error('Failed to fetch agencies from BPN API', [
+4 -4
View File
@@ -307,10 +307,10 @@ class TravelLoader extends AbstractLoader
$availability = $serviceAvailabilities[$service->id]; $availability = $serviceAvailabilities[$service->id];
$service->available = $availability->available; $service->available = $availability->available;
// The live status is the only signal that a contingent has run out: BusPro moves a // The live status is fresher than the nightly export's. The parser has already mapped it
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the // onto the export vocabulary. Only overwrite when the response actually carries one, so a
// response actually carries one, so a response that omits the attribute does not wipe // response that omits the attribute does not wipe the value parsed from the XML export.
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService(). // Mirrors TravelSnapshotManager::applyExtendedToService().
if (null !== $availability->status && '' !== trim($availability->status)) { if (null !== $availability->status && '' !== trim($availability->status)) {
$service->status = $availability->status; $service->status = $availability->status;
} }
@@ -2,6 +2,7 @@
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use App\BusProNet\Traits\TypeConversionTrait; use App\BusProNet\Traits\TypeConversionTrait;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
@@ -9,6 +10,19 @@ abstract class AbstractParser
{ {
use TypeConversionTrait; use TypeConversionTrait;
/**
* Live availability wording for a Leistung, mapped onto the XML export's.
*
* The availability endpoints answer "buchbar" or "ausgebucht", while every availability rule
* compares against the export's "Frei" / "Anfrage" / "Buchungsstop". "ausgebucht" also covers
* a Leistung that would still take an inquiry (anfragemoeglich="J"); that is not offered
* either, so it maps to the booking stop.
*/
private const LIVE_SERVICE_STATUS = [
'buchbar' => Constants::STATUS_AVAILABLE,
'ausgebucht' => Constants::STATUS_BLOCKED,
];
protected function getStringOrNullValue(Crawler $node): ?string protected function getStringOrNullValue(Crawler $node): ?string
{ {
return 0 < $node->count() ? $node->text() : null; return 0 < $node->count() ? $node->text() : null;
@@ -87,4 +101,16 @@ abstract class AbstractParser
return $value; return $value;
} }
/**
* Translates a live Leistung status into the export vocabulary; anything else passes through.
*/
protected function toServiceStatus(?string $liveStatus): ?string
{
if (null === $liveStatus) {
return null;
}
return self::LIVE_SERVICE_STATUS[strtolower(trim($liveStatus))] ?? $liveStatus;
}
} }
+32 -22
View File
@@ -2,8 +2,8 @@
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser class ApiResponseParser extends AbstractParser
@@ -14,10 +14,11 @@ class ApiResponseParser extends AbstractParser
} }
/** /**
* @param array<string, mixed> $additionalArgs
*
* @throws ResponseParserException * @throws ResponseParserException
*/ */
/** @param array<string, mixed> $additionalArgs */ public function parseXmlString(MessageType $type, string $xml, array $additionalArgs = []): mixed
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
{ {
$crawler = XmlCrawlerFactory::create($xml); $crawler = XmlCrawlerFactory::create($xml);
@@ -25,17 +26,20 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml)); throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
} }
// Override type unless present in XML to catch error responses // The type in the XML wins, to catch error responses. BusPro may send one we don't know.
$responseType = $this $typeAttribute = $this->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ'));
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type; $responseType = null === $typeAttribute ? $type : MessageType::tryFrom($typeAttribute);
if (null === $responseType) {
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $typeAttribute));
}
$resultNode = $crawler->filterXPath('//ergebnis'); $resultNode = $crawler->filterXPath('//ergebnis');
try { try {
switch ($responseType) { switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION: case MessageType::Notification:
return (new NotificationParser())->parse($crawler); return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA: case MessageType::CustomerData:
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art')); $subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
if (null === $subType || '' === $subType) { if (null === $subType || '' === $subType) {
throw new ResponseParserException('Unable to determine customer data subtype from XML response'); 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)); throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
case ApiClient::TYPE_BASE_DATA_COUNTRIES: case MessageType::Countries:
return (new CountriesParser())->parse($resultNode); return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA: case MessageType::MutableData:
return (new MutableDataParser())->parse($resultNode); return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY: case MessageType::Availability:
return (new AvailabilitiesParser())->parseServices($resultNode); return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED: case MessageType::AvailabilityExtended:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode); return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL: case MessageType::AvailabilityHotel:
return (new AvailabilitiesParser())->parseRooms($resultNode); return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE: case MessageType::BookingUpdate:
return (new BookingUpdateParser())->parse($resultNode); return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING: case MessageType::Booking:
return (new BookingResponseParser())->parse($resultNode); return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS: case MessageType::Products:
return (new ProductsParser())->parse($resultNode); return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA: case MessageType::ProductData:
$travelNode = $crawler->filterXPath('//reise/termin'); $travelNode = $crawler->filterXPath('//reise/termin');
if (0 === $travelNode->count()) { if (0 === $travelNode->count()) {
@@ -87,17 +91,23 @@ class ApiResponseParser extends AbstractParser
} }
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs); return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES: case MessageType::Agencies:
return (new AgencyParser())->parse($resultNode); return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER: case MessageType::PurchaseVoucher:
return (new PurchaseVoucherParser())->parse($resultNode); return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER: case MessageType::PromoVoucher:
return (new PromoVoucherParser())->parse($resultNode); 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) { } catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e); throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
} }
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
} }
} }
@@ -19,7 +19,7 @@ class AvailabilitiesParser extends AbstractParser
->each(function (Crawler $node) use (&$availabilities) { ->each(function (Crawler $node) use (&$availabilities) {
$availability = new Availability(); $availability = new Availability();
$availability->serviceId = (int) $node->attr('id'); $availability->serviceId = (int) $node->attr('id');
$availability->status = $node->attr('status'); $availability->status = $this->toServiceStatus($node->attr('status'));
$availability->available = (int) $node->attr('frei'); $availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis')); $availability->price = $this->stringToFloat($node->attr('preis'));
@@ -41,6 +41,7 @@ class CrmAttributesResponseParser
1478 => Role::GROUPS_ADMIN, 1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT, 1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN, 1484 => Role::TEAM_ADMIN,
1487 => Role::SUPERVISOR,
]; ];
/** /**
+17 -6
View File
@@ -3,9 +3,9 @@
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\File; use App\BusProNet\Model\File;
use PhpZip\Exception\ZipException;
use PhpZip\ZipFile;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use ZipStream\Exception as ZipException;
use ZipStream\ZipStream;
class DocumentsParser class DocumentsParser
{ {
@@ -28,17 +28,28 @@ class DocumentsParser
} }
// In case of multiple documents create ZIP file // In case of multiple documents create ZIP file
$stream = fopen('php://temp', 'w+b');
try { 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); [$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) { } catch (ZipException $e) {
return null; return null;
} finally {
fclose($stream);
} }
} }
@@ -30,7 +30,7 @@ class ExtendedAvailabilitiesParser extends AbstractParser
$availability = new ExtendedAvailability(); $availability = new ExtendedAvailability();
$availability->serviceId = $serviceId; $availability->serviceId = $serviceId;
$availability->status = $node->attr('status'); $availability->status = $this->toServiceStatus($node->attr('status'));
$availability->available = (int) $node->attr('frei'); $availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis')); $availability->price = $this->stringToFloat($node->attr('preis'));
$availability->dateFrom = $this->stringToDate($node->attr('termin')); $availability->dateFrom = $this->stringToDate($node->attr('termin'));
@@ -50,6 +50,7 @@ class PersonalDataParser extends AbstractParser
$communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil')); $communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil'));
$communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email')); $communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email'));
$communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter')); $communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter'));
$communication->rememberLoadedNumbers();
$personalData->communication = $communication; $personalData->communication = $communication;
} }
@@ -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;
}
}
-58
View File
@@ -29,20 +29,8 @@ class TravelParser extends AbstractParser
Constants::PSEUDO_PRICE_ROOM_CODE, Constants::PSEUDO_PRICE_ROOM_CODE,
]; ];
/**
* `$levelBoardingPointPrices` carries no default on purpose: a hand-built parser would
* otherwise silently ignore APP_BPN_LEVEL_BOARDING_POINT_PRICES and price one travel tier
* differently from the others. The registry gave up its own default along with it, so that
* there is one way to build this class — through the container, or explicitly in a test.
*
* @param bool $levelBoardingPointPrices Whether to compensate the BusPro export defect that
* omits a boarding point's return-leg price. The
* container wires it from
* APP_BPN_LEVEL_BOARDING_POINT_PRICES.
*/
public function __construct( public function __construct(
private readonly AgeConstraintParserRegistry $ageConstraintRegistry, private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
private readonly bool $levelBoardingPointPrices,
) { ) {
} }
@@ -99,11 +87,6 @@ class TravelParser extends AbstractParser
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true); $travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false); $travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
// A boarding point costs the same in both directions
if ($this->levelBoardingPointPrices) {
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
}
$travel->guide = $this->getGuide($node); $travel->guide = $this->getGuide($node);
return $travel; return $travel;
@@ -466,47 +449,6 @@ class TravelParser extends AbstractParser
} }
} }
/**
* Levels the price of a boarding point across both travel directions.
*
* A boarding point is either free or costs the same in both directions:
* `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
* BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
* asymmetric pair is therefore always an export defect — on some products a
* `<zustieg_rueck>` carries no `preis` although the engine still charges the return
* leg — and the stated price is the one that applies to both legs.
*
* Where both directions state a price they agree in practice; the outbound one wins
* to keep the outcome deterministic. Points without a counterpart are left as they are.
*
* Compensating the defect locally is provisional, so it is switchable: with
* `$levelBoardingPointPrices` off the export is reported verbatim and the affected
* travels undercharge the return leg again. See
* `docs/buspro-database-reference.md` section A7.
*
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID (modified in place)
*/
private function applyBoardingPointPriceLevelling(array $pickups, array $dropOffs): void
{
foreach ($dropOffs as $pickupId => $dropOff) {
if (false === isset($pickups[$pickupId])) {
continue;
}
$pickup = $pickups[$pickupId];
$price = $pickup->price ?? $dropOff->price;
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
$dropOff->price = $price;
$dropOff->priceOutbound = $price;
$dropOff->priceInbound = $price;
}
}
/** /**
* Adjusts time-only pickup entries for overnight bus routes. * Adjusts time-only pickup entries for overnight bus routes.
* *
@@ -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. Editorial text can be out of date: take dates, prices, pickups and inclusions from search_trips and get_trip instead.',
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. Pickup surcharges are per person and per direction: surchargeOutbound for the way there, surchargeReturn for the way back, surchargeRoundTrip for both; quote the round trip unless the user travels one way only. 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. description and FAQs are editorial website text and can be out of date: where they state a date, price, pickup or inclusion that differs from the structured fields, the structured fields are right.',
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. Editorial text can be out of date: take dates, prices, pickups and inclusions from search_trips and get_trip instead.",
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, by its code and, for one hotel, its hotelCode. 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: 'A trip, hotel, destination or concept name, or a trip, hotel or date code, e.g. "Sportclub Lederer", "Chamois d\'Or", "Val Thorens", "SSLMP". A hotel name returns only that hotel. Use the name alone, without a question around it. Words that name nothing are looked up in the page texts, e.g. "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);
}
}
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff. Show more