Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77960fe81f | ||
|
|
9ce42de4c1 | ||
|
|
d50995a21c | ||
|
|
897a1ac7f9 | ||
|
|
2705401393 | ||
|
|
f5894a6bef | ||
|
|
e7df5ac03a | ||
|
|
c16f368293 | ||
|
|
d63fa10a56 | ||
|
|
e8fa71175d | ||
|
|
09610889f3 | ||
|
|
d8fe9a3875 | ||
|
|
e9766dbdce | ||
|
|
b047465339 | ||
|
|
a60bf68345 | ||
|
|
97c6330a9a | ||
|
|
ec36b61275 | ||
|
|
7c8dbeef48 | ||
|
|
e293bd3b1c | ||
|
|
a7b845751b | ||
|
|
234f3a0bd2 | ||
|
|
f794d882ca | ||
|
|
05e348e9b7 | ||
|
|
654214cf37 | ||
|
|
8a6f73bd9b | ||
|
|
a6a8783319 | ||
|
|
1d20524678 | ||
|
|
88f6807399 | ||
|
|
91270fdfc3 | ||
|
|
e8d1b69407 | ||
|
|
3428408db5 | ||
|
|
32083c7966 | ||
|
|
d4296f30a5 | ||
|
|
57b86a8fbc | ||
|
|
3f941bb4bf | ||
|
|
60de688859 | ||
|
|
69eba9767a | ||
|
|
1d9c852113 | ||
|
|
b958d1a371 | ||
|
|
5b75938c73 | ||
|
|
1ff293f41f | ||
|
|
2d49212b08 | ||
|
|
b67f25606a | ||
|
|
0ee8fafbe1 | ||
|
|
6d2db1b258 | ||
|
|
6f6bf3695c | ||
|
|
a5f8d75caa | ||
|
|
ff722b5cef | ||
|
|
d305774bee | ||
|
|
1a733a6ba6 | ||
|
|
ade30c6b06 | ||
|
|
e796dd01ee | ||
|
|
63ffe0b0b4 | ||
|
|
25edb3f337 | ||
|
|
fe40e1807e | ||
|
|
26d4140c18 | ||
|
|
9f805aaec7 | ||
|
|
849a19b67a | ||
|
|
553ec901f3 | ||
|
|
0afa4f4855 | ||
|
|
3cb1548f2f | ||
|
|
ac5ca7b18b | ||
|
|
4ab2714bde | ||
|
|
bfdedefae3 | ||
|
|
3af41c06ab | ||
|
|
754360605c | ||
|
|
1b4384e3f3 | ||
|
|
ff7ddf7a9e | ||
|
|
2d876152aa | ||
|
|
24498e6fd1 | ||
|
|
de5d80c2df | ||
|
|
18d3edcd53 | ||
|
|
56af0f921b | ||
|
|
a14c41de4e | ||
|
|
9a8879dfbe | ||
|
|
21d756a2e3 | ||
|
|
93512b8345 | ||
|
|
5f95492a7f | ||
|
|
627b190fcb | ||
|
|
e42f836d9b | ||
|
|
b755a6f43e | ||
|
|
0a55544ca8 | ||
|
|
3c81c79819 | ||
|
|
e51664df6c | ||
|
|
f3dd122678 | ||
|
|
a628e96d06 | ||
|
|
2bb0148e49 | ||
|
|
289741d5d4 | ||
|
|
f070f4117c | ||
|
|
72ef3ff105 | ||
|
|
1301df80c2 | ||
|
|
56deb1e24b | ||
|
|
ec10ca3623 | ||
|
|
67c0c2fb0c | ||
|
|
eb45693257 | ||
|
|
5fdee68ccd | ||
|
|
7001bc0cf2 | ||
|
|
972839286c | ||
|
|
ede8fd840a | ||
|
|
6c700ea18b | ||
|
|
fe18ebf479 | ||
|
|
1d42565a84 | ||
|
|
272c811f4f | ||
|
|
c2c55bc8b2 | ||
|
|
8a6ce7606f | ||
|
|
7ec21c1cac | ||
|
|
f951242465 | ||
|
|
7dadb6806a | ||
|
|
6b539f43f3 | ||
|
|
1845441bc2 | ||
|
|
33c31d088c |
No files matched your search
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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";
|
|
||||||
@@ -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";
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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],
|
||||||
];
|
];
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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\'
|
||||||
@@ -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,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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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)%'
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mcp:
|
||||||
|
resource: .
|
||||||
|
type: mcp
|
||||||
+90
-23
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1810
-3097
File diff suppressed because it is too large.
Load diff
+14
-12
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-4
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
;
|
;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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> */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `�`. 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user