Compare commits
68
Commits
2d876152aa
...
master
| 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 |
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
|
||||||
+2
-1
@@ -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,8 +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; the real value per stage goes in .env.local
|
# 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=
|
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]"
|
||||||
@@ -94,15 +101,6 @@ 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
|
|
||||||
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg.
|
|
||||||
# A boarding point costs the same in both directions, so the stated price is applied to both.
|
|
||||||
# Off = report the export verbatim. Provisional until the vendor fixes the export; see
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
|
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
|
||||||
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
|
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
|
||||||
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
|
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
|
||||||
|
|||||||
+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";
|
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
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. */
|
The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
|
||||||
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
|
@apply 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 uppercase text-center text-sm/6 md:text-base/6;
|
||||||
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
@apply 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 */
|
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
|
||||||
min-height: calc(1lh + 2 * theme('spacing.2') + 2px);
|
min-height: calc(1lh + 2 * --spacing(2) + 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--small {
|
.button--small {
|
||||||
@apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
|
@apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
|
||||||
min-height: calc(1lh + 2 * theme('spacing.1') + 2px);
|
min-height: calc(1lh + 2 * --spacing(1) + 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--primary {
|
.button--primary {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"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",
|
||||||
"maennchen/zipstream-php": "^3.2",
|
"maennchen/zipstream-php": "^3.2",
|
||||||
"nesbot/carbon": "^3.8",
|
"nesbot/carbon": "^3.8",
|
||||||
@@ -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.*",
|
||||||
|
|||||||
Generated
+1704
-696
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,6 +16,13 @@ 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:
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+41
-13
@@ -26,6 +26,17 @@ parameters:
|
|||||||
LENA20: '2027-03-04'
|
LENA20: '2027-03-04'
|
||||||
BOTSCHAFTER2627: '2027-04-30'
|
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:
|
||||||
10321569: 'E&P Newsletter'
|
10321569: 'E&P Newsletter'
|
||||||
@@ -170,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'
|
||||||
@@ -198,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'
|
||||||
@@ -333,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: '%env(APP_CMS_API_KEY)%'
|
|
||||||
|
|
||||||
App\Logger\RequestIdProcessor:
|
App\Logger\RequestIdProcessor:
|
||||||
tags:
|
tags:
|
||||||
@@ -412,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)%'
|
||||||
@@ -427,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/',
|
||||||
|
|||||||
@@ -172,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",
|
||||||
@@ -212,6 +213,11 @@ 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.
|
||||||
|
|
||||||
|
**`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
|
||||||
|
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`,
|
**`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
|
`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)`.
|
||||||
@@ -318,6 +324,8 @@ returns it in one page.
|
|||||||
"city": "Frankfurt",
|
"city": "Frankfurt",
|
||||||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||||
"price": 5.9,
|
"price": 5.9,
|
||||||
|
"returnLeg": true,
|
||||||
|
"returnPrice": 5.9,
|
||||||
"datetime": "2026-12-03T23:30:00"
|
"datetime": "2026-12-03T23:30:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -389,7 +397,9 @@ such as for arriving by car.
|
|||||||
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
`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
|
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
|
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
|
||||||
none. It is not levelled against the return leg. An id absent from
|
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
|
`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.
|
of `0` is emitted as-is but not counted.
|
||||||
|
|
||||||
@@ -497,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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -208,7 +217,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
|||||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
|
| `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 | |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\BusProNet\Exception\ApiClientException;
|
|||||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
use App\BusProNet\Exception\ResponseParserException;
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
use App\BusProNet\MessageType;
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Utility\XmlCharacterFilter;
|
||||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
use App\Service\XmlDumpWriter;
|
use App\Service\XmlDumpWriter;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -208,8 +209,18 @@ class XmlChannel
|
|||||||
*/
|
*/
|
||||||
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
|
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 {
|
try {
|
||||||
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
|
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
|
||||||
} catch (ResponseParserException $e) {
|
} catch (ResponseParserException $e) {
|
||||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
// 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.
|
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Mcp;
|
||||||
|
|
||||||
|
use Mcp\Exception\ToolCallException;
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every chatbot tool call goes through: the per-client rate limit, the call log, and
|
||||||
|
* the answer as compact JSON.
|
||||||
|
*
|
||||||
|
* The SDK would pretty-print an array result and repeat it as structured content; both
|
||||||
|
* cost the model tokens and neither helps n8n. The log records tool, arguments, result
|
||||||
|
* count, duration and the calling client (n8n, claude-desktop, …), never anything about
|
||||||
|
* the person chatting, which n8n does not send.
|
||||||
|
*/
|
||||||
|
class ToolCallRunner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RateLimiterFactory $chatbotMcpLimiter,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly LoggerInterface $chatbotLogger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments as received, for the log
|
||||||
|
* @param callable(): array<string, mixed> $call
|
||||||
|
*
|
||||||
|
* @throws ToolCallException invalid input or rate limit; the model sees the message
|
||||||
|
*/
|
||||||
|
public function run(string $tool, array $arguments, callable $call): CallToolResult
|
||||||
|
{
|
||||||
|
$arguments = array_filter($arguments, static fn (mixed $value): bool => null !== $value);
|
||||||
|
$client = $this->security->getUser()?->getUserIdentifier() ?? 'anonymous';
|
||||||
|
$limit = $this->chatbotMcpLimiter->create($client)->consume();
|
||||||
|
|
||||||
|
if (!$limit->isAccepted()) {
|
||||||
|
$this->chatbotLogger->warning('MCP tool call rate-limited', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments]);
|
||||||
|
|
||||||
|
throw new ToolCallException(sprintf('Too many tool calls. Retry after %s.', $limit->getRetryAfter()->format('H:i:s')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = hrtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $call();
|
||||||
|
} catch (ToolCallException $e) {
|
||||||
|
$this->chatbotLogger->info('MCP tool call rejected', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->chatbotLogger->error('MCP tool call failed', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'exception' => $e]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->chatbotLogger->info('MCP tool call', [
|
||||||
|
'client' => $client,
|
||||||
|
'tool' => $tool,
|
||||||
|
'arguments' => $arguments,
|
||||||
|
'resultCount' => is_array($result['results'] ?? null) ? count($result['results']) : 1,
|
||||||
|
'durationMs' => (int) ((hrtime(true) - $started) / 1_000_000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new CallToolResult([new TextContent(json_encode($result, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Mcp\TripsOnly;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_trip of the trips-only server (/mcp/trips): the same trip without the product and
|
||||||
|
* hotel page texts, whose prices and inclusions can contradict the booking system.
|
||||||
|
*/
|
||||||
|
#[McpTool(
|
||||||
|
name: 'get_trip',
|
||||||
|
description: 'Full details of one trip: destination, hotels with their facts, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times. 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. For anything this does not answer, refer the user to the trip\'s url.',
|
||||||
|
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, editorial: false);
|
||||||
|
|
||||||
|
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,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Search;
|
||||||
|
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search over the mirrored website content.
|
||||||
|
*
|
||||||
|
* Ranked by relevance, weighted by type in the order people need them: what they book
|
||||||
|
* (products, hotels), where they go (destinations, concepts), general pages, then blog
|
||||||
|
* posts. The weights are multipliers on the relevance score, so a much better match of a
|
||||||
|
* lower type still wins.
|
||||||
|
*/
|
||||||
|
class ContentSearch
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The tool's `type` values. `destination` covers region, country and city.
|
||||||
|
*/
|
||||||
|
public const TYPES = [
|
||||||
|
'page' => ['page'],
|
||||||
|
'product' => ['product'],
|
||||||
|
'hotel' => ['hotel'],
|
||||||
|
'destination' => ['region', 'country', 'city'],
|
||||||
|
'concept' => ['concept'],
|
||||||
|
'news' => ['news'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const TYPE_WEIGHTS = [
|
||||||
|
'product' => 1.4,
|
||||||
|
'hotel' => 1.4,
|
||||||
|
'region' => 1.2,
|
||||||
|
'country' => 1.2,
|
||||||
|
'city' => 1.2,
|
||||||
|
'concept' => 1.2,
|
||||||
|
'page' => 1.0,
|
||||||
|
'news' => 0.8,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly ContentDocumentRepository $repository)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $type a key of TYPES
|
||||||
|
*
|
||||||
|
* @return list<array{type: string, title: string, url: string, site: ?string, breadcrumb: ?string, excerpt: string}>
|
||||||
|
*/
|
||||||
|
public function search(string $query, ?string $type, ?string $site, int $limit): array
|
||||||
|
{
|
||||||
|
$booleanQuery = ContentSearchQuery::booleanQuery($query);
|
||||||
|
|
||||||
|
if (null === $booleanQuery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->repository->searchFulltext($booleanQuery, null === $type ? [] : self::TYPES[$type], $site, self::TYPE_WEIGHTS, $limit);
|
||||||
|
$terms = ContentSearchQuery::terms($query);
|
||||||
|
|
||||||
|
return array_map(static fn (array $row): array => [
|
||||||
|
'type' => self::publicType($row['type']),
|
||||||
|
'title' => $row['title'],
|
||||||
|
'url' => $row['url'],
|
||||||
|
'site' => $row['site'],
|
||||||
|
'breadcrumb' => $row['breadcrumb'],
|
||||||
|
'excerpt' => Excerpt::around($row['body_md'], $terms),
|
||||||
|
], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool's name for a stored type.
|
||||||
|
*/
|
||||||
|
public static function publicType(string $type): string
|
||||||
|
{
|
||||||
|
foreach (self::TYPES as $public => $types) {
|
||||||
|
if (in_array($type, $types, true)) {
|
||||||
|
return $public;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Search;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns free text into a MySQL boolean-mode full-text query.
|
||||||
|
*
|
||||||
|
* InnoDB has no German stemming, so every term becomes a prefix match (`skipass*` finds
|
||||||
|
* Skipässe no better, but finds Skipassversicherung). Terms are optional rather than
|
||||||
|
* required: a question rarely uses the page's own words for everything, and the relevance
|
||||||
|
* score already ranks documents matching more terms higher. German stopwords are dropped
|
||||||
|
* here, since the server's stopword list is English.
|
||||||
|
*/
|
||||||
|
final class ContentSearchQuery
|
||||||
|
{
|
||||||
|
/** InnoDB's default innodb_ft_min_token_size; shorter tokens are not indexed. */
|
||||||
|
private const MIN_TOKEN_LENGTH = 3;
|
||||||
|
private const MAX_TERMS = 12;
|
||||||
|
|
||||||
|
public const STOPWORDS = [
|
||||||
|
'aber', 'alle', 'allem', 'allen', 'aller', 'alles', 'als', 'also', 'am', 'an', 'ander', 'andere', 'anderen',
|
||||||
|
'auch', 'auf', 'aus', 'bei', 'beim', 'bin', 'bis', 'bist', 'da', 'damit', 'dann', 'das', 'dass', 'dein',
|
||||||
|
'deine', 'dem', 'den', 'denn', 'der', 'des', 'dessen', 'die', 'dies', 'diese', 'diesem', 'diesen', 'dieser',
|
||||||
|
'dieses', 'doch', 'dort', 'du', 'durch', 'ein', 'eine', 'einem', 'einen', 'einer', 'eines', 'einige', 'er',
|
||||||
|
'es', 'etwas', 'euch', 'euer', 'eure', 'für', 'gibt', 'gib', 'hab', 'habe', 'haben', 'hast', 'hat', 'hatte',
|
||||||
|
'hier', 'hin', 'ich', 'ihr', 'ihre', 'ihrem', 'ihren', 'ihrer', 'im', 'in', 'ins', 'ist', 'ja', 'jede',
|
||||||
|
'jedem', 'jeden', 'jeder', 'jetzt', 'kann', 'kannst', 'kein', 'keine', 'können', 'könnt', 'man', 'mein',
|
||||||
|
'meine', 'mich', 'mir', 'mit', 'muss', 'musst', 'nach', 'nicht', 'nichts', 'noch', 'nun', 'nur', 'ob', 'oder',
|
||||||
|
'ohne', 'sehr', 'sein', 'seine', 'sich', 'sie', 'sind', 'so', 'soll', 'sollte', 'über', 'um', 'und', 'uns',
|
||||||
|
'unser', 'unsere', 'unter', 'viel', 'vom', 'von', 'vor', 'war', 'waren', 'was', 'weil', 'welche', 'welchem',
|
||||||
|
'welchen', 'welcher', 'welches', 'wenn', 'wer', 'werde', 'werden', 'wie', 'wieder', 'will', 'wir', 'wird',
|
||||||
|
'wo', 'woher', 'wohin', 'zu', 'zum', 'zur', 'zwischen', 'bitte', 'gerne', 'euren', 'eurem', 'eurer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terms of a query: lower-cased, stopwords and short tokens dropped, unique.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function terms(string $text): array
|
||||||
|
{
|
||||||
|
$tokens = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$terms = [];
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (mb_strlen($token) < self::MIN_TOKEN_LENGTH || in_array($token, self::STOPWORDS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$terms[$token] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice(array_keys($terms), 0, self::MAX_TERMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boolean-mode query, or null when nothing searchable is left. With $allTerms a
|
||||||
|
* document has to match every term, which a name or a phrase wants and a question
|
||||||
|
* does not.
|
||||||
|
*/
|
||||||
|
public static function booleanQuery(string $text, bool $allTerms = false): ?string
|
||||||
|
{
|
||||||
|
$terms = self::terms($text);
|
||||||
|
|
||||||
|
return [] === $terms ? null : implode(' ', array_map(static fn (string $term): string => ($allTerms ? '+' : '').$term.'*', $terms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Search;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts a window of about LENGTH characters out of a document, around its first match.
|
||||||
|
*
|
||||||
|
* The excerpt keeps search results small; get_document fetches the rest.
|
||||||
|
*/
|
||||||
|
final class Excerpt
|
||||||
|
{
|
||||||
|
public const LENGTH = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $terms lower-cased, matched as prefixes like the full-text search
|
||||||
|
*/
|
||||||
|
public static function around(string $text, array $terms, int $length = self::LENGTH): string
|
||||||
|
{
|
||||||
|
$text = trim((string) preg_replace('/\s+/u', ' ', $text));
|
||||||
|
$textLength = mb_strlen($text);
|
||||||
|
|
||||||
|
if ($textLength <= $length) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = null;
|
||||||
|
$lower = mb_strtolower($text);
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$found = mb_strpos($lower, $term);
|
||||||
|
if (false !== $found && (null === $position || $found < $position)) {
|
||||||
|
$position = $found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a third of the window before the match, so it is read in context.
|
||||||
|
$start = max(0, min(($position ?? 0) - intdiv($length, 3), $textLength - $length));
|
||||||
|
$excerpt = mb_substr($text, $start, $length);
|
||||||
|
|
||||||
|
if ($start > 0) {
|
||||||
|
$space = mb_strpos($excerpt, ' ');
|
||||||
|
$excerpt = '…'.(false !== $space && $space < 40 ? mb_substr($excerpt, $space + 1) : $excerpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start + $length < $textLength) {
|
||||||
|
$space = mb_strrpos($excerpt, ' ');
|
||||||
|
$excerpt = (false !== $space && mb_strlen($excerpt) - $space < 40 ? mb_substr($excerpt, 0, $space) : $excerpt).'…';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $excerpt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot\Search;
|
||||||
|
|
||||||
|
use Symfony\Component\String\UnicodeString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares free text with the names of trips, hotels, destinations and concepts.
|
||||||
|
*
|
||||||
|
* Names are compared word by word, without accents and case ("Chaviere" is "Chavière"),
|
||||||
|
* and without German stopwords. Unlike the full-text index, two-letter words count: they
|
||||||
|
* tell "Chamois d'Or" from "Chalets Chamois".
|
||||||
|
*/
|
||||||
|
final class NameQuery
|
||||||
|
{
|
||||||
|
private const MIN_TOKEN_LENGTH = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function tokens(string $text): array
|
||||||
|
{
|
||||||
|
$words = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$tokens = [];
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
if (in_array($word, ContentSearchQuery::STOPWORDS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (new UnicodeString($word))->ascii()->lower()->toString();
|
||||||
|
|
||||||
|
if (strlen($token) >= self::MIN_TOKEN_LENGTH || ctype_digit($token)) {
|
||||||
|
$tokens[$token] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(strval(...), array_keys($tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every word of the query begins a word of the name: "Arolles" is within
|
||||||
|
* "3* Hotel Les Arolles (inkl. VP)".
|
||||||
|
*
|
||||||
|
* @param list<string> $query
|
||||||
|
* @param list<string> $name
|
||||||
|
*/
|
||||||
|
public static function within(array $query, array $name): bool
|
||||||
|
{
|
||||||
|
if ([] === $query || [] === $name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($query as $word) {
|
||||||
|
if ([] === array_filter($name, static fn (string $nameWord): bool => str_starts_with($nameWord, $word))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query spells the name out among other words: "Hotel Chamois d'Or in Val Thorens"
|
||||||
|
* contains "Chamois d'Or".
|
||||||
|
*
|
||||||
|
* @param list<string> $query
|
||||||
|
* @param list<string> $name
|
||||||
|
*/
|
||||||
|
public static function contains(array $query, array $name): bool
|
||||||
|
{
|
||||||
|
return [] !== $name && [] === array_diff($name, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot;
|
||||||
|
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The named travel periods of the website search ("Reisezeitraum"), from the
|
||||||
|
* `chatbot_travel_periods` parameter.
|
||||||
|
*
|
||||||
|
* Duplicated from the TYPO3 search configuration on purpose, and updated together with it
|
||||||
|
* each season. Periods may overlap. A period whose end has passed is no longer offered.
|
||||||
|
*/
|
||||||
|
class TravelPeriods
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{label: string, from: string, to: string}> $periods
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $periods,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date range of a current period, matched case-insensitively, or null.
|
||||||
|
*
|
||||||
|
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null
|
||||||
|
*/
|
||||||
|
public function find(string $label): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->current() as $period) {
|
||||||
|
if (0 === strcasecmp($period['label'], trim($label))) {
|
||||||
|
return ['from' => new \DateTimeImmutable($period['from']), 'to' => new \DateTimeImmutable($period['to'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function labels(): array
|
||||||
|
{
|
||||||
|
return array_column($this->current(), 'label');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{label: string, from: string, to: string}>
|
||||||
|
*/
|
||||||
|
private function current(): array
|
||||||
|
{
|
||||||
|
$today = $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
|
||||||
|
|
||||||
|
return array_values(array_filter($this->periods, static fn (array $period): bool => $period['to'] >= $today));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Chatbot;
|
||||||
|
|
||||||
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\Chatbot\Search\ContentSearchQuery;
|
||||||
|
use App\Chatbot\Search\NameQuery;
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Enum\HotelType;
|
||||||
|
use App\Enum\RoomType;
|
||||||
|
use App\Model\Chatbot\TravelDateFacts;
|
||||||
|
use App\Model\Chatbot\TripCriteria;
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use App\Service\ProductExportBuilder;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trips as the chatbot sees them: TYPO3's editorial product joined to our dates, rooms,
|
||||||
|
* prices and pickups from the product export.
|
||||||
|
*
|
||||||
|
* The join is on the product code; several TYPO3 products may share one, and each is its
|
||||||
|
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
|
||||||
|
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
|
||||||
|
* `code` it is. BusPro's own hotel names (hotel.xml) are internal wording, so they are
|
||||||
|
* only searched, and shown just where TYPO3 has no hotel for a code.
|
||||||
|
*
|
||||||
|
* search() mirrors the website's travel search, get() details one trip. Free text is read
|
||||||
|
* as a code or a name before it is looked up in the page texts, see resolveQuery(). A room is bookable
|
||||||
|
* unless its status is `Buchungsstop`; places and prices are derived per date x hotel by
|
||||||
|
* TravelDateFacts. Room counts never leave this class: the website does not show them, so
|
||||||
|
* the tools say only whether a date is freely bookable or on request.
|
||||||
|
*/
|
||||||
|
class TripCatalog
|
||||||
|
{
|
||||||
|
/** The feed's only language for now; trips are joined in it. */
|
||||||
|
public const LANGUAGE = 'de';
|
||||||
|
|
||||||
|
private const STATUS_FREE = 'Frei';
|
||||||
|
|
||||||
|
private const DATES_WITH_PICKUPS = 3;
|
||||||
|
private const BODY_LIMIT = 20000;
|
||||||
|
private const HOTEL_BODY_LIMIT = 8000;
|
||||||
|
|
||||||
|
private const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductExportBuilder $exportBuilder,
|
||||||
|
private readonly ContentDocumentRepository $repository,
|
||||||
|
private readonly HotelLoader $hotelLoader,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website's travel search: one result per product x hotel x travel date, filtered and
|
||||||
|
* priced as the search page does (see TravelDateFacts and TripCriteria), sorted by date,
|
||||||
|
* then price.
|
||||||
|
*
|
||||||
|
* @return array{results: list<array<string, mixed>>, total: int, dataAsOf: string}
|
||||||
|
*/
|
||||||
|
public function search(TripCriteria $criteria): array
|
||||||
|
{
|
||||||
|
$catalog = $this->load();
|
||||||
|
$today = $this->today();
|
||||||
|
$named = null === $criteria->query ? null : $this->resolveQuery($catalog, $criteria->query);
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
|
||||||
|
$header = null;
|
||||||
|
|
||||||
|
foreach ($catalog['rowsByCode'][$this->normalize($product->getData()['code'] ?? null)] ?? [] as $row) {
|
||||||
|
if ($row['date']['from'] < $today) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
|
||||||
|
$hotelData = $hotel?->getData() ?? [];
|
||||||
|
|
||||||
|
if (null !== $named
|
||||||
|
&& !isset($named['products'][$product->getUid()])
|
||||||
|
&& !isset($named['hotels'][$hotel?->getUid() ?? 0])
|
||||||
|
&& !isset($named['busProHotels'][$row['hotel']['busProId'] ?? 0])
|
||||||
|
&& (null === $named['dateCode'] || $named['dateCode'] !== $this->normalize($row['date']['code'] ?? null))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$facts = TravelDateFacts::fromExportRow($row);
|
||||||
|
|
||||||
|
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header ??= $this->tripHeader($catalog, $product);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'product' => $header['name'],
|
||||||
|
'code' => $header['code'],
|
||||||
|
'dateCode' => $row['date']['code'],
|
||||||
|
'hotel' => $this->hotelName($catalog, $hotel, $row),
|
||||||
|
'hotelCode' => $row['hotel']['code'],
|
||||||
|
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
|
||||||
|
'destination' => $header['destination'],
|
||||||
|
'concept' => $header['concept'],
|
||||||
|
'dateFrom' => $row['date']['from'],
|
||||||
|
'dateTo' => $row['date']['to'],
|
||||||
|
'nights' => $facts->nights,
|
||||||
|
'priceFrom' => $facts->priceFrom,
|
||||||
|
'includes' => $facts->includes(),
|
||||||
|
'roomTypes' => array_map(static fn (RoomType $type): string => $type->value, $facts->roomTypes),
|
||||||
|
'availability' => $facts->availability,
|
||||||
|
'flags' => array_values(array_filter([
|
||||||
|
true === ($hotelData['new'] ?? null) ? 'neu' : null,
|
||||||
|
true === ($hotelData['earlybird'] ?? null) ? 'frühbucher' : null,
|
||||||
|
$header['daytrip'] ? 'tagesfahrt' : null,
|
||||||
|
])),
|
||||||
|
'url' => $header['url'],
|
||||||
|
'site' => $header['site'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($results, static fn (array $a, array $b): int => [$a['dateFrom'], $a['priceFrom'] ?? \PHP_FLOAT_MAX, $a['product'], $a['hotel']]
|
||||||
|
<=> [$b['dateFrom'], $b['priceFrom'] ?? \PHP_FLOAT_MAX, $b['product'], $b['hotel']]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'results' => array_slice($results, 0, $criteria->limit),
|
||||||
|
'total' => count($results),
|
||||||
|
'dataAsOf' => $this->dataAsOf($catalog['export']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about one trip, or null for a code no published product carries.
|
||||||
|
*
|
||||||
|
* Takes a product code or a date code. A date code is what customers read on their
|
||||||
|
* booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to
|
||||||
|
* that date, shown even when it has already started.
|
||||||
|
*
|
||||||
|
* Without $editorial the product and hotel page texts are left out, and only what is
|
||||||
|
* structured remains: names, links, hotel facts and everything from the booking system.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function get(string $code, ?string $hotelCode = null, bool $editorial = true): ?array
|
||||||
|
{
|
||||||
|
$catalog = $this->load();
|
||||||
|
$code = (new DateCodeUtility())->sanitize(trim($code));
|
||||||
|
$dateCode = null;
|
||||||
|
|
||||||
|
if (!isset($catalog['productsByCode'][$code])) {
|
||||||
|
foreach ($catalog['export']->rows as $row) {
|
||||||
|
if ($this->normalize($row['date']['code'] ?? null) === $code) {
|
||||||
|
$dateCode = $code;
|
||||||
|
$code = $this->normalize($row['product']['code'] ?? null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $catalog['productsByCode'][$code] ?? [];
|
||||||
|
|
||||||
|
if ([] === $products) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($products, static fn (ContentDocument $a, ContentDocument $b): int => [null === $a->getUrl(), $a->getUid()] <=> [null === $b->getUrl(), $b->getUid()]);
|
||||||
|
$product = $products[0];
|
||||||
|
$today = $this->today();
|
||||||
|
|
||||||
|
$rows = array_values(array_filter(
|
||||||
|
$catalog['rowsByCode'][$code] ?? [],
|
||||||
|
fn (array $row): bool => null === $dateCode
|
||||||
|
? $row['date']['from'] >= $today
|
||||||
|
: $this->normalize($row['date']['code'] ?? null) === $dateCode,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (null !== $hotelCode && '' !== trim($hotelCode)) {
|
||||||
|
$wanted = $this->hotelCodesFor($catalog, $hotelCode);
|
||||||
|
$rows = array_values(array_filter($rows, fn (array $row): bool => in_array($this->normalize($row['hotel']['code']), $wanted, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => [$a['date']['from'], $a['hotel']['code']] <=> [$b['date']['from'], $b['hotel']['code']]);
|
||||||
|
|
||||||
|
$hotelCodes = array_values(array_unique(array_map(fn (array $row): string => $this->normalize($row['hotel']['code']), $rows)));
|
||||||
|
|
||||||
|
return $this->tripHeader($catalog, $product) + [
|
||||||
|
'alsoPublishedAt' => array_values(array_map(
|
||||||
|
static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()],
|
||||||
|
array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()),
|
||||||
|
)),
|
||||||
|
] + ($editorial ? ['description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT)] : []) + [
|
||||||
|
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product, $editorial),
|
||||||
|
'dates' => $this->dates($catalog, $rows),
|
||||||
|
'dataAsOf' => $this->dataAsOf($catalog['export']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the codes the join cannot place, on both sides. The same gaps the website's
|
||||||
|
* product import tolerates, worth a periodic look.
|
||||||
|
*/
|
||||||
|
public function logUnmatchedCodes(): void
|
||||||
|
{
|
||||||
|
$catalog = $this->load();
|
||||||
|
|
||||||
|
$exportProducts = array_keys($catalog['rowsByCode']);
|
||||||
|
$contentProducts = array_keys($catalog['productsByCode']);
|
||||||
|
|
||||||
|
$exportHotels = [];
|
||||||
|
foreach ($catalog['export']->rows as $row) {
|
||||||
|
$exportHotels[$this->normalize($row['hotel']['code'] ?? null)] = true;
|
||||||
|
}
|
||||||
|
unset($exportHotels['']);
|
||||||
|
|
||||||
|
$ambiguous = [];
|
||||||
|
foreach ($catalog['hotelsByCode'] as $hotelCode => $hotels) {
|
||||||
|
if (count($hotels) > 1) {
|
||||||
|
$ambiguous[$hotelCode] = array_map(static fn (ContentDocument $hotel): int => $hotel->getUid(), $hotels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'exportProductsWithoutContent' => array_values(array_diff($exportProducts, $contentProducts)),
|
||||||
|
'contentProductsWithoutDates' => array_values(array_diff($contentProducts, $exportProducts)),
|
||||||
|
'exportHotelsWithoutContent' => array_values(array_diff(array_keys($exportHotels), array_keys($catalog['hotelsByCode']))),
|
||||||
|
'ambiguousHotelCodes' => $ambiguous,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->logger->info('Content feed: unmatched codes', $context + [
|
||||||
|
'counts' => array_map('count', $context),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* export: Export,
|
||||||
|
* records: array<string, ContentDocument>,
|
||||||
|
* productsByCode: array<string, list<ContentDocument>>,
|
||||||
|
* hotelsByCode: array<string, list<ContentDocument>>,
|
||||||
|
* rowsByCode: array<string, list<array<string, mixed>>>,
|
||||||
|
* busProHotels: array<int, array{code: string, name: ?string}>,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function load(): array
|
||||||
|
{
|
||||||
|
$export = $this->exportBuilder->build();
|
||||||
|
$records = $this->repository->findRecords(self::RECORD_TYPES, self::LANGUAGE);
|
||||||
|
|
||||||
|
$productsByCode = [];
|
||||||
|
$hotelsByCode = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$data = $record->getData() ?? [];
|
||||||
|
|
||||||
|
if ('product' === $record->getType() && '' !== ($code = $this->normalize($data['code'] ?? null))) {
|
||||||
|
$productsByCode[$code][] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('hotel' === $record->getType()) {
|
||||||
|
$codes = is_array($data['codes'] ?? null) ? $data['codes'] : [];
|
||||||
|
$codes[] = $data['code'] ?? null;
|
||||||
|
|
||||||
|
foreach (array_unique(array_map($this->normalize(...), $codes)) as $code) {
|
||||||
|
if ('' !== $code) {
|
||||||
|
$hotelsByCode[$code][] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code that is one hotel's own and another's alias resolves to the owner.
|
||||||
|
foreach ($hotelsByCode as $code => $hotels) {
|
||||||
|
usort($hotels, fn (ContentDocument $a, ContentDocument $b): int => [$this->normalize($a->getData()['code'] ?? null) !== $code, $a->getUid()] <=> [$this->normalize($b->getData()['code'] ?? null) !== $code, $b->getUid()]);
|
||||||
|
$hotelsByCode[$code] = $hotels;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsByCode = [];
|
||||||
|
$busProHotels = [];
|
||||||
|
$masterData = $this->hotelLoader->loadAll();
|
||||||
|
|
||||||
|
foreach ($export->rows as $row) {
|
||||||
|
$code = $this->normalize($row['product']['code'] ?? null);
|
||||||
|
if ('' !== $code) {
|
||||||
|
$rowsByCode[$code][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hotelId = (int) ($row['hotel']['busProId'] ?? 0);
|
||||||
|
if (0 !== $hotelId && !isset($busProHotels[$hotelId])) {
|
||||||
|
// BusPro breaks names over lines ("Vacanceole\nRésidence L'Edelweiss").
|
||||||
|
$name = trim((string) preg_replace('/\s+/u', ' ', isset($masterData[$hotelId]) ? (string) $masterData[$hotelId]->name : ''));
|
||||||
|
$busProHotels[$hotelId] = ['code' => $this->normalize($row['hotel']['code'] ?? null), 'name' => '' === $name ? null : $name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'export' => $export,
|
||||||
|
'records' => $records,
|
||||||
|
'productsByCode' => $productsByCode,
|
||||||
|
'hotelsByCode' => $hotelsByCode,
|
||||||
|
'rowsByCode' => $rowsByCode,
|
||||||
|
'busProHotels' => $busProHotels,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published products narrowed by everything the content index can resolve: site, day
|
||||||
|
* trip, concept and destination. Free text is applied per travel date, since it may
|
||||||
|
* name one hotel of a trip.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* @return list<ContentDocument>
|
||||||
|
*/
|
||||||
|
private function candidateProducts(array $catalog, TripCriteria $criteria): array
|
||||||
|
{
|
||||||
|
/** @var array<string, ContentDocument> $records */
|
||||||
|
$records = $catalog['records'];
|
||||||
|
|
||||||
|
$conceptUids = null === $criteria->concept ? null : $this->matchingUids($records, ['concept'], $criteria->concept, matchCode: true);
|
||||||
|
$countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true);
|
||||||
|
$regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region);
|
||||||
|
$cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region);
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ('product' !== $record->getType() || null === $record->getUrl()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $record->getData() ?? [];
|
||||||
|
|
||||||
|
// Closed group trips and staff trainings are not offered; get() still answers
|
||||||
|
// someone who names their code.
|
||||||
|
if (false === ($data['bookable'] ?? null)
|
||||||
|
|| $record->isHiddenFromSearch()
|
||||||
|
|| (null !== $criteria->site && $record->getSite() !== $criteria->site)
|
||||||
|
|| (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip)
|
||||||
|
|| (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true))
|
||||||
|
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $regionUids) {
|
||||||
|
$productRegions = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
|
||||||
|
|
||||||
|
if ([] === array_intersect($productRegions, $regionUids) && !in_array($data['cityUid'] ?? null, $cityUids ?? [], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$products[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uids of the records of the given types whose name contains the text, or whose code
|
||||||
|
* equals it.
|
||||||
|
*
|
||||||
|
* @param array<string, ContentDocument> $records
|
||||||
|
* @param list<string> $types
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function matchingUids(array $records, array $types, string $text, bool $matchCode = false): array
|
||||||
|
{
|
||||||
|
$needle = mb_strtolower(trim($text));
|
||||||
|
$uids = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (!in_array($record->getType(), $types, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = mb_strtolower($record->getTitle());
|
||||||
|
$code = $this->normalize($record->getData()['code'] ?? null);
|
||||||
|
|
||||||
|
if (('' !== $name && (str_contains($name, $needle) || str_contains($needle, $name)))
|
||||||
|
|| ($matchCode && '' !== $code && $code === $this->normalize($text))) {
|
||||||
|
$uids[] = $record->getUid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a free-text query asks for: whole trips, single hotels of trips, or one travel
|
||||||
|
* date. Null when it restricts nothing.
|
||||||
|
*
|
||||||
|
* A code or a name is taken at its word, so naming a trip or hotel returns that one and
|
||||||
|
* not every page that mentions it. In order, stopping at the first that finds anything:
|
||||||
|
* a product, hotel or date code; names holding every word of the query; names the query
|
||||||
|
* spells out among other words; and only then the page texts.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* Hotels are known twice: by their TYPO3 record and by BusPro's own name and code, which
|
||||||
|
* also covers a hotel TYPO3 has no record for
|
||||||
|
*
|
||||||
|
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: ?string}|null
|
||||||
|
*/
|
||||||
|
private function resolveQuery(array $catalog, string $text): ?array
|
||||||
|
{
|
||||||
|
/** @var array<string, ContentDocument> $records */
|
||||||
|
$records = $catalog['records'];
|
||||||
|
|
||||||
|
$matched = $this->recordsWithCode($catalog, $text) ?? $this->recordsNamed($catalog, $text);
|
||||||
|
|
||||||
|
if (null !== $matched) {
|
||||||
|
return $this->tripsOf($records, $matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateCode = (new DateCodeUtility())->sanitize(trim($text));
|
||||||
|
|
||||||
|
foreach ($catalog['export']->rows as $row) {
|
||||||
|
if ($this->normalize($row['date']['code'] ?? null) === $dateCode) {
|
||||||
|
return ['products' => [], 'hotels' => [], 'busProHotels' => [], 'dateCode' => $dateCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$terms = ContentSearchQuery::terms($text);
|
||||||
|
|
||||||
|
if ([] === $terms) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every term first: "Sauna Whirlpool" means both. A question rarely uses the page's
|
||||||
|
// own words for everything, so any term is the fallback.
|
||||||
|
foreach (count($terms) > 1 ? [true, false] : [false] as $allTerms) {
|
||||||
|
$matched = [];
|
||||||
|
foreach ($this->repository->matchRecords((string) ContentSearchQuery::booleanQuery($text, $allTerms), self::RECORD_TYPES, self::LANGUAGE) as $hit) {
|
||||||
|
$matched[$hit['type']][$hit['uid']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $matched) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->tripsOf($records, $matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The products and the hotel carrying the text as their code, hotel aliases included.
|
||||||
|
* Matches are keyed by record type, BusPro's hotels as `busProHotel` by their id.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, true>>|null
|
||||||
|
*/
|
||||||
|
private function recordsWithCode(array $catalog, string $text): ?array
|
||||||
|
{
|
||||||
|
$code = (new DateCodeUtility())->sanitize(trim($text));
|
||||||
|
$matched = [];
|
||||||
|
|
||||||
|
foreach ($catalog['productsByCode'][$code] ?? [] as $product) {
|
||||||
|
$matched['product'][$product->getUid()] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($catalog['hotelsByCode'][$code][0])) {
|
||||||
|
$matched['hotel'][$catalog['hotelsByCode'][$code][0]->getUid()] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
|
||||||
|
if ($busProHotel['code'] === $code) {
|
||||||
|
$matched['busProHotel'][$hotelId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [] === $matched ? null : $matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The records the text names. A name holding every word of the query wins: "Lederer"
|
||||||
|
* is "Sportclub Lederer". Otherwise the query may spell a name out among other words,
|
||||||
|
* and there the most specific kind wins, so that "Sportclub Lederer Saalbach" is the
|
||||||
|
* hotel and not everything in Saalbach.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, true>>|null
|
||||||
|
*/
|
||||||
|
private function recordsNamed(array $catalog, string $text): ?array
|
||||||
|
{
|
||||||
|
$query = NameQuery::tokens($text);
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($catalog['records'] as $record) {
|
||||||
|
$names[] = [$record->getType(), $record->getUid(), $record->getTitle()];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
|
||||||
|
$names[] = ['busProHotel', $hotelId, $busProHotel['name'] ?? ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$within = [];
|
||||||
|
$contained = [];
|
||||||
|
|
||||||
|
foreach ($names as [$type, $id, $title]) {
|
||||||
|
$name = NameQuery::tokens($title);
|
||||||
|
|
||||||
|
if (NameQuery::within($query, $name)) {
|
||||||
|
$within[$type][$id] = true;
|
||||||
|
} elseif (NameQuery::contains($query, $name)) {
|
||||||
|
$contained[$type][$id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $within) {
|
||||||
|
return $within;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([['product', 'hotel', 'busProHotel'], ['city'], ['region'], ['country', 'concept']] as $types) {
|
||||||
|
$matched = array_intersect_key($contained, array_flip($types));
|
||||||
|
|
||||||
|
if ([] !== $matched) {
|
||||||
|
return $matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matched records as trips: a product, destination or concept stands for the whole
|
||||||
|
* trips, a hotel only for itself, since a trip may have many.
|
||||||
|
*
|
||||||
|
* @param array<string, ContentDocument> $records
|
||||||
|
* @param array<string, array<int, true>> $matched by type and uid
|
||||||
|
*
|
||||||
|
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: null}
|
||||||
|
*/
|
||||||
|
private function tripsOf(array $records, array $matched): array
|
||||||
|
{
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ('product' !== $record->getType()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $record->getData() ?? [];
|
||||||
|
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
|
||||||
|
|
||||||
|
if (isset($matched['product'][$record->getUid()])
|
||||||
|
|| [] !== array_intersect($regionUids, array_keys($matched['region'] ?? []))
|
||||||
|
|| isset($matched['country'][$data['countryUid'] ?? -1])
|
||||||
|
|| isset($matched['city'][$data['cityUid'] ?? -1])
|
||||||
|
|| isset($matched['concept'][$data['conceptUid'] ?? -1])) {
|
||||||
|
$products[$record->getUid()] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['products' => $products, 'hotels' => $matched['hotel'] ?? [], 'busProHotels' => $matched['busProHotel'] ?? [], 'dateCode' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search page's filters on one travel date at one hotel. `persons` checks both the
|
||||||
|
* page's condition (the smallest group a room takes) and that enough places are left.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function matches(array $row, TravelDateFacts $facts, ?HotelType $hotelType, TripCriteria $criteria): bool
|
||||||
|
{
|
||||||
|
if (!$facts->isBookable()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((null !== $criteria->dateFrom && $row['date']['from'] < $criteria->dateFrom->format('Y-m-d'))
|
||||||
|
|| (null !== $criteria->dateTo && $row['date']['to'] > $criteria->dateTo->format('Y-m-d'))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((null !== $criteria->priceMin || null !== $criteria->priceMax)
|
||||||
|
&& (null === $facts->priceFrom
|
||||||
|
|| (null !== $criteria->priceMin && $facts->priceFrom < $criteria->priceMin)
|
||||||
|
|| (null !== $criteria->priceMax && $facts->priceFrom > $criteria->priceMax))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $criteria->persons && (($facts->minPax ?? 0) > $criteria->persons || $facts->places < $criteria->persons)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $criteria->board) {
|
||||||
|
$wanted = mb_strtolower($criteria->board);
|
||||||
|
$boards = array_filter($facts->boards, static fn (string $board): bool => str_contains(mb_strtolower($board), $wanted));
|
||||||
|
|
||||||
|
if ([] === $boards) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null === $criteria->accommodation || $hotelType === $criteria->accommodation)
|
||||||
|
&& (null === $criteria->roomType || $facts->hasRoomType($criteria->roomType))
|
||||||
|
&& (null === $criteria->nightsMin || $facts->nights >= $criteria->nightsMin)
|
||||||
|
&& (null === $criteria->nightsMax || $facts->nights <= $criteria->nightsMax)
|
||||||
|
&& (null === $criteria->bus || $facts->busAvailable === $criteria->bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function tripHeader(array $catalog, ContentDocument $product): array
|
||||||
|
{
|
||||||
|
$data = $product->getData() ?? [];
|
||||||
|
$name = fn (string $type, mixed $uid): ?string => is_int($uid) ? ($catalog['records'][$type.':'.$uid] ?? null)?->getTitle() : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $data['code'] ?? null,
|
||||||
|
'name' => $product->getTitle(),
|
||||||
|
'url' => $product->getUrl(),
|
||||||
|
'site' => $product->getSite(),
|
||||||
|
'destination' => array_filter([
|
||||||
|
'country' => $name('country', $data['countryUid'] ?? null),
|
||||||
|
'region' => $name('region', $data['regionUid'] ?? null),
|
||||||
|
'city' => $name('city', $data['cityUid'] ?? null),
|
||||||
|
]),
|
||||||
|
'concept' => $name('concept', $data['conceptUid'] ?? null),
|
||||||
|
'daytrip' => (bool) ($data['daytrip'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
* @param list<string> $hotelCodes normalized codes of the hotels with dates
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product, bool $editorial): array
|
||||||
|
{
|
||||||
|
$hotels = [];
|
||||||
|
|
||||||
|
foreach ($hotelCodes as $code) {
|
||||||
|
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
|
||||||
|
if (null !== $hotel) {
|
||||||
|
$hotels[$hotel->getUid()] = $hotel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trip without upcoming dates still describes its hotels. One whose dates are at
|
||||||
|
// a hotel the website does not know has none to describe.
|
||||||
|
if ([] === $hotelCodes) {
|
||||||
|
foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) {
|
||||||
|
$hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null;
|
||||||
|
if (null !== $hotel) {
|
||||||
|
$hotels[$hotel->getUid()] = $hotel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(fn (ContentDocument $hotel): array => [
|
||||||
|
'code' => $hotel->getData()['code'] ?? null,
|
||||||
|
'name' => $hotel->getTitle(),
|
||||||
|
'url' => $hotel->getUrl(),
|
||||||
|
'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [],
|
||||||
|
] + ($editorial ? ['description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT)] : []), $hotels));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming dates with their bookable rooms per hotel. Pickups for the first few only:
|
||||||
|
* they rarely differ between dates and would otherwise dominate the answer.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
* @param list<array<string, mixed>> $rows sorted by date
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function dates(array $catalog, array $rows): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$dateId = $row['date']['busProId'];
|
||||||
|
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'])][0] ?? null;
|
||||||
|
|
||||||
|
$rooms = [];
|
||||||
|
foreach ($row['rooms'] ?? [] as $room) {
|
||||||
|
if (TravelDateFacts::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rooms[] = array_filter([
|
||||||
|
'room' => $room['label'] ?? null,
|
||||||
|
'board' => $room['boardCode'] ?? null,
|
||||||
|
'nights' => $room['nights'] ?? null,
|
||||||
|
'price' => $room['price'] ?? null,
|
||||||
|
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? TravelDateFacts::AVAILABILITY_FREE : TravelDateFacts::AVAILABILITY_ON_REQUEST,
|
||||||
|
], static fn (mixed $value): bool => null !== $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dates[$dateId] ??= [
|
||||||
|
'dateCode' => $row['date']['code'],
|
||||||
|
'dateFrom' => $row['date']['from'],
|
||||||
|
'dateTo' => $row['date']['to'],
|
||||||
|
'hotels' => [],
|
||||||
|
'pickups' => $this->pickups($row),
|
||||||
|
];
|
||||||
|
|
||||||
|
$dates[$dateId]['hotels'][] = [
|
||||||
|
'hotel' => $this->hotelName($catalog, $hotel, $row),
|
||||||
|
'hotelCode' => $row['hotel']['code'],
|
||||||
|
'rooms' => $rooms,
|
||||||
|
'bookable' => [] !== $rooms,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dates = array_values($dates);
|
||||||
|
|
||||||
|
foreach ($dates as $i => $date) {
|
||||||
|
if ($i >= self::DATES_WITH_PICKUPS) {
|
||||||
|
unset($dates[$i]['pickups']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BusPro prices a boarding point per leg, so a customer travelling both ways pays the
|
||||||
|
* surcharge twice. Both legs and their sum are spelled out, because a single figure
|
||||||
|
* reads as the whole price. A leg listed without a price is billed nothing, so it is 0;
|
||||||
|
* the return is absent only where the point is not served on the way back.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function pickups(array $row): array
|
||||||
|
{
|
||||||
|
$pickups = [];
|
||||||
|
|
||||||
|
foreach ($row['pickups'] ?? [] as $pickup) {
|
||||||
|
if (0 === ($pickup['busProId'] ?? 0) || null === ($pickup['city'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outbound = (float) ($pickup['price'] ?? 0);
|
||||||
|
$return = ($pickup['returnLeg'] ?? false) ? (float) ($pickup['returnPrice'] ?? 0) : null;
|
||||||
|
|
||||||
|
$pickups[] = array_filter([
|
||||||
|
'city' => $pickup['city'],
|
||||||
|
'place' => $pickup['street'] ?? null,
|
||||||
|
// A clock reading without an offset, as BusPro states it.
|
||||||
|
'departure' => is_string($pickup['datetime'] ?? null) ? str_replace('T', ' ', substr($pickup['datetime'], 0, 16)) : null,
|
||||||
|
'surchargeOutbound' => $outbound,
|
||||||
|
'surchargeReturn' => $return,
|
||||||
|
'surchargeRoundTrip' => null !== $return ? round($outbound + $return, 2) : null,
|
||||||
|
], static fn (mixed $value): bool => null !== $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pickups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized codes of the hotel a code names: all its own codes when TYPO3 knows the
|
||||||
|
* hotel, else just the code.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function hotelCodesFor(array $catalog, string $hotelCode): array
|
||||||
|
{
|
||||||
|
$hotel = $catalog['hotelsByCode'][$this->normalize($hotelCode)][0] ?? null;
|
||||||
|
|
||||||
|
if (null === $hotel) {
|
||||||
|
return [$this->normalize($hotelCode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = is_array($hotel->getData()['codes'] ?? null) ? $hotel->getData()['codes'] : [];
|
||||||
|
$codes[] = $hotel->getData()['code'] ?? null;
|
||||||
|
|
||||||
|
return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website's name for the hotel of a travel date, else BusPro's, else its code.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $catalog
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hotelName(array $catalog, ?ContentDocument $hotel, array $row): string
|
||||||
|
{
|
||||||
|
return $hotel?->getTitle()
|
||||||
|
?? $catalog['busProHotels'][$row['hotel']['busProId'] ?? 0]['name']
|
||||||
|
?? (string) $row['hotel']['code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dataAsOf(Export $export): string
|
||||||
|
{
|
||||||
|
return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function truncate(string $text, int $limit): string
|
||||||
|
{
|
||||||
|
if (mb_strlen($text) <= $limit) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function today(): string
|
||||||
|
{
|
||||||
|
return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(mixed $code): string
|
||||||
|
{
|
||||||
|
return is_string($code) ? strtoupper(trim($code)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Command;
|
|
||||||
|
|
||||||
use App\BusProNet\Client\BookingClient;
|
|
||||||
use App\BusProNet\Client\TravelClient;
|
|
||||||
use App\BusProNet\Constants;
|
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
|
||||||
use App\BusProNet\Model\Booking;
|
|
||||||
use App\BusProNet\Model\Notification;
|
|
||||||
use App\BusProNet\Model\Service;
|
|
||||||
use App\BusProNet\Model\Travel;
|
|
||||||
use App\Entity\BookingEditDraft;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Repository\BookingEditDraftRepository;
|
|
||||||
use App\Security\Crypt;
|
|
||||||
use App\Service\BookingChangeTracker;
|
|
||||||
use App\Service\BookingEditDraftMerger;
|
|
||||||
use App\Service\TravelDataProvider;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repairs a booking edit draft that BusPro keeps rejecting.
|
|
||||||
*
|
|
||||||
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
|
||||||
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
|
||||||
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
|
||||||
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
|
||||||
* accumulated 37 such failures over two months.
|
|
||||||
*
|
|
||||||
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
|
||||||
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
|
||||||
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
|
||||||
* every field the customer typed, and reverts only the service selections that BusPro will not
|
|
||||||
* accept - naming each one, so the office can tell the customer what to pick again.
|
|
||||||
*
|
|
||||||
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
|
||||||
*/
|
|
||||||
#[AsCommand(
|
|
||||||
name: 'app:booking:repair-draft',
|
|
||||||
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
|
||||||
)]
|
|
||||||
class BookingRepairDraftCommand extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Draft `services` keys holding a single service id.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Date of birth the office writes into the empty slots of a template booking.
|
|
||||||
*/
|
|
||||||
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
|
||||||
|
|
||||||
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft `services` keys holding a list of service ids.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
|
|
||||||
* pickup and drop-off that only make sense on a coach.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly BookingEditDraftRepository $draftRepository,
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly BookingClient $bookingClient,
|
|
||||||
private readonly TravelClient $travelClient,
|
|
||||||
private readonly Crypt $crypt,
|
|
||||||
private readonly TravelDataProvider $travelDataProvider,
|
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
|
||||||
private readonly BookingEditDraftMerger $draftMerger,
|
|
||||||
private readonly BookingChangeTracker $changeTracker,
|
|
||||||
#[Autowire('%kernel.project_dir%')]
|
|
||||||
private readonly string $projectDir,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void
|
|
||||||
{
|
|
||||||
$this
|
|
||||||
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
|
|
||||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
|
|
||||||
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
|
|
||||||
$bookingId = $input->getOption('booking');
|
|
||||||
if (null === $bookingId) {
|
|
||||||
$io->error('--booking is required.');
|
|
||||||
|
|
||||||
return Command::INVALID;
|
|
||||||
}
|
|
||||||
|
|
||||||
$draft = $this->findSingleDraft($io, (int) $bookingId);
|
|
||||||
if (null === $draft) {
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $draft->getUser();
|
|
||||||
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
|
|
||||||
if (null === $booking) {
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
|
|
||||||
if (null === $travel) {
|
|
||||||
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$formData = $draft->getFormData();
|
|
||||||
$draftParticipants = $formData['participants'] ?? [];
|
|
||||||
|
|
||||||
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$blocked = $this->collectUnbookableServices($io, $travel);
|
|
||||||
|
|
||||||
// The booking as BusPro currently holds it is the only state known to be acceptable, so
|
|
||||||
// it is the base everything is rebuilt on.
|
|
||||||
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
|
|
||||||
$booking,
|
|
||||||
$travel,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
|
|
||||||
|
|
||||||
$reverted = [];
|
|
||||||
foreach ($draftParticipants as $index => $participantData) {
|
|
||||||
if (false === isset($repairedDto->participants[$index])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtered = $this->filterServiceSelections(
|
|
||||||
$participantData,
|
|
||||||
$liveSelections[$index]['services'] ?? [],
|
|
||||||
$blocked,
|
|
||||||
$index,
|
|
||||||
$reverted,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
|
|
||||||
}
|
|
||||||
|
|
||||||
$repaired = $this->changeTracker->extractUserData($repairedDto);
|
|
||||||
|
|
||||||
$this->report($io, $formData, $repaired, $reverted, $blocked);
|
|
||||||
|
|
||||||
if (false === $input->getOption('apply')) {
|
|
||||||
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
|
|
||||||
$io->success(sprintf('Original form data backed up to %s', $backupPath));
|
|
||||||
|
|
||||||
$draft->setFormData($repaired);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
|
|
||||||
{
|
|
||||||
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
|
|
||||||
|
|
||||||
if ([] === $drafts) {
|
|
||||||
$io->error(sprintf('No draft found for booking %d.', $bookingId));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($drafts) > 1) {
|
|
||||||
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$draft = $drafts[0];
|
|
||||||
$io->definitionList(
|
|
||||||
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
|
|
||||||
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
|
|
||||||
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
|
|
||||||
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
|
||||||
{
|
|
||||||
$result = $this->bookingClient->getBooking(
|
|
||||||
(string) $user->getEmail(),
|
|
||||||
$this->crypt->decrypt((string) $user->getPassword()),
|
|
||||||
$bookingId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result instanceof Notification) {
|
|
||||||
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refuses to repair a draft whose positions no longer line up with the live booking.
|
|
||||||
*
|
|
||||||
* Drafts are merged positionally, by array index, with no identity check. Empty template
|
|
||||||
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
|
|
||||||
* date of birth are the only anchors available. If one of those has moved, every later
|
|
||||||
* position is suspect and rebuilding would quietly graft data onto the wrong people.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $draftParticipants
|
|
||||||
*/
|
|
||||||
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
|
|
||||||
{
|
|
||||||
$liveCount = count($booking->participants);
|
|
||||||
$draftCount = count($draftParticipants);
|
|
||||||
|
|
||||||
if ($liveCount !== $draftCount) {
|
|
||||||
$io->warning(sprintf(
|
|
||||||
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
|
|
||||||
$liveCount,
|
|
||||||
$draftCount,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$anchors = 0;
|
|
||||||
$mismatches = [];
|
|
||||||
|
|
||||||
foreach (array_values($booking->participants) as $index => $participant) {
|
|
||||||
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
|
|
||||||
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
++$anchors;
|
|
||||||
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
|
|
||||||
|
|
||||||
if (null !== $draftDob && $draftDob !== $liveDob) {
|
|
||||||
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([] !== $mismatches) {
|
|
||||||
$io->error('Draft positions no longer match the live booking:');
|
|
||||||
$io->listing($mismatches);
|
|
||||||
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects the services BusPro will not accept as an addition.
|
|
||||||
*
|
|
||||||
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
|
|
||||||
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
|
|
||||||
* Live availability is preferred over the travel data, which lags behind it.
|
|
||||||
*
|
|
||||||
* @return array<int, string> service id => status
|
|
||||||
*/
|
|
||||||
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
|
|
||||||
{
|
|
||||||
$statuses = [];
|
|
||||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
|
||||||
if (null !== $service->id && null !== $service->status) {
|
|
||||||
$statuses[$service->id] = $service->status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
|
|
||||||
if ($live instanceof Notification) {
|
|
||||||
$io->warning(sprintf(
|
|
||||||
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
|
||||||
(int) $live->code,
|
|
||||||
(string) $live->message,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
foreach ($live->getServices() as $availability) {
|
|
||||||
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
|
|
||||||
$statuses[$availability->serviceId] = $availability->status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
|
|
||||||
|
|
||||||
if ([] === $blocked) {
|
|
||||||
$io->text('Every service on this travel is currently "Frei".');
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($blocked as $serviceId => $status) {
|
|
||||||
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
|
|
||||||
$label = $service instanceof Service ? (string) $service->label : '?';
|
|
||||||
$rows[] = [$serviceId, $label, $status];
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->section('Services that cannot take new participants');
|
|
||||||
$io->table(['id', 'Leistung', 'status'], $rows);
|
|
||||||
|
|
||||||
return $blocked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops the draft's selections that would add a participant to a service BusPro has closed.
|
|
||||||
*
|
|
||||||
* Removing a key leaves the live booking's own value in place, because the merger applies
|
|
||||||
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
|
|
||||||
* preference - are kept, so the customer loses as little as possible.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $participantData
|
|
||||||
* @param array<string, mixed> $liveServices
|
|
||||||
* @param array<int, string> $blocked
|
|
||||||
* @param list<string> $reverted
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function filterServiceSelections(
|
|
||||||
array $participantData,
|
|
||||||
array $liveServices,
|
|
||||||
array $blocked,
|
|
||||||
int $index,
|
|
||||||
array &$reverted,
|
|
||||||
): array {
|
|
||||||
if (false === isset($participantData['services']) || [] === $blocked) {
|
|
||||||
return $participantData;
|
|
||||||
}
|
|
||||||
|
|
||||||
$services = $participantData['services'];
|
|
||||||
$position = $index + 1;
|
|
||||||
|
|
||||||
foreach (self::SINGLE_SERVICE_KEYS as $key) {
|
|
||||||
$selected = $services[$key] ?? null;
|
|
||||||
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
|
|
||||||
unset($services[$key]);
|
|
||||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::LIST_SERVICE_KEYS as $key) {
|
|
||||||
$selected = $services[$key] ?? null;
|
|
||||||
if (false === is_array($selected)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$liveList = $liveServices[$key] ?? [];
|
|
||||||
$kept = [];
|
|
||||||
foreach ($selected as $serviceId) {
|
|
||||||
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
|
|
||||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$kept[] = $serviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$services[$key] = $kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
|
|
||||||
$selected = $services[$key] ?? null;
|
|
||||||
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
|
||||||
|
|
||||||
// Transport reverts as a unit, pickup and drop-off included.
|
|
||||||
foreach (self::TRANSPORT_KEYS as $transportKey) {
|
|
||||||
unset($services[$transportKey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$participantData['services'] = $services;
|
|
||||||
|
|
||||||
return $participantData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $original
|
|
||||||
* @param array<string, mixed> $repaired
|
|
||||||
* @param list<string> $reverted
|
|
||||||
* @param array<int, string> $blocked
|
|
||||||
*/
|
|
||||||
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
|
|
||||||
{
|
|
||||||
$io->section('Typed data preserved');
|
|
||||||
|
|
||||||
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
|
|
||||||
$kept = 0;
|
|
||||||
$lost = [];
|
|
||||||
|
|
||||||
foreach ($original['participants'] ?? [] as $index => $participantData) {
|
|
||||||
foreach ($fields as $field) {
|
|
||||||
$before = $participantData['personalData'][$field] ?? null;
|
|
||||||
if (null === $before || '' === $before) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
|
|
||||||
if ($before === $after) {
|
|
||||||
++$kept;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
|
|
||||||
|
|
||||||
if ([] !== $lost) {
|
|
||||||
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
|
|
||||||
$io->listing(array_slice($lost, 0, 25));
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->section('Service selections reverted');
|
|
||||||
|
|
||||||
if ([] === $reverted) {
|
|
||||||
$io->text([] === $blocked
|
|
||||||
? 'None - no service on this travel is closed.'
|
|
||||||
: 'None - the draft selects no closed service.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
|
|
||||||
$io->listing($reverted);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
|
|
||||||
{
|
|
||||||
$directory = $this->projectDir.'/'.trim($backupDir, '/');
|
|
||||||
if (false === is_dir($directory)) {
|
|
||||||
mkdir($directory, 0o775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = sprintf(
|
|
||||||
'%s/draft-%d-booking-%d-%s.json',
|
|
||||||
$directory,
|
|
||||||
(int) $draft->getId(),
|
|
||||||
$draft->getBookingId(),
|
|
||||||
(new \DateTimeImmutable())->format('Ymd-His'),
|
|
||||||
);
|
|
||||||
|
|
||||||
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Entity\Groups\Accommodation;
|
|
||||||
use App\Repository\Groups\AccommodationRepository;
|
|
||||||
use App\Repository\Groups\ContingentDayRepository;
|
use App\Repository\Groups\ContingentDayRepository;
|
||||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||||
|
use App\Service\ContingentHotelDirectory;
|
||||||
use App\Service\ContingentSnapshotManager;
|
use App\Service\ContingentSnapshotManager;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
*
|
*
|
||||||
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
|
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
|
||||||
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
|
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
|
||||||
* to re-sync a single accommodation while debugging.
|
* to re-sync a single hotel code while debugging.
|
||||||
|
*
|
||||||
|
* Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus
|
||||||
|
* TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both
|
||||||
|
* sources, so the snapshot tables only ever describe hotels that are still synced.
|
||||||
*/
|
*/
|
||||||
class BpnSyncContingentsCommand extends Command
|
class BpnSyncContingentsCommand extends Command
|
||||||
{
|
{
|
||||||
use LockableTrait;
|
use LockableTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AccommodationRepository $accommodationRepository,
|
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||||
private readonly ContingentDayRepository $dayRepository,
|
private readonly ContingentDayRepository $dayRepository,
|
||||||
private readonly ContingentSnapshotManager $snapshotManager,
|
private readonly ContingentSnapshotManager $snapshotManager,
|
||||||
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
|
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
|
||||||
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
|
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code')
|
||||||
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
|
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0')
|
||||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
|
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
@@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
$dateFrom = $today->toDateTimeImmutable();
|
$dateFrom = $today->toDateTimeImmutable();
|
||||||
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
|
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
|
||||||
|
|
||||||
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
|
$this->hotelDirectory->refresh();
|
||||||
|
// array_keys() turns a numeric code into an int.
|
||||||
|
$allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all()));
|
||||||
|
$hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes;
|
||||||
|
|
||||||
if ([] === $accommodations) {
|
if ([] === $hotelCodes) {
|
||||||
$io->warning('No accommodation with a calendarCode matched.');
|
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
$changed = 0;
|
$changed = 0;
|
||||||
$failedCodes = [];
|
$failedCodes = [];
|
||||||
|
|
||||||
foreach ($accommodations as $accommodation) {
|
foreach ($hotelCodes as $code) {
|
||||||
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
|
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
|
||||||
++$skipped;
|
++$skipped;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
|
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
|
||||||
++$processed;
|
++$processed;
|
||||||
|
|
||||||
if (!$result->successful) {
|
if (!$result->successful) {
|
||||||
$failedCodes[] = (string) $accommodation->getCalendarCode();
|
$failedCodes[] = $code;
|
||||||
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
|
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
|
|
||||||
$io->writeln(sprintf(
|
$io->writeln(sprintf(
|
||||||
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||||
(string) $accommodation->getCalendarCode(),
|
$code,
|
||||||
$result->changed ? 'changed' : 'unchanged',
|
$result->changed ? 'changed' : 'unchanged',
|
||||||
$result->added,
|
$result->added,
|
||||||
$result->updated,
|
$result->updated,
|
||||||
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
||||||
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
|
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
|
||||||
|
|
||||||
|
// Only a full run knows the whole set. A code that left both sources is not served any
|
||||||
|
// more (the availability endpoint answers 400 for it), so its snapshot is dead weight;
|
||||||
|
// should it come back, the next run rebuilds it.
|
||||||
|
if (!is_string($hotelCode)) {
|
||||||
|
$orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||||
|
$orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||||
|
|
||||||
|
if ($orphanedStates > 0) {
|
||||||
|
$this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [
|
||||||
|
'hotels' => $orphanedStates,
|
||||||
|
'days' => $orphanedDays,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only a *total* failure is worth failing the task over: that means upstream is down or
|
// Only a *total* failure is worth failing the task over: that means upstream is down or
|
||||||
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
|
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
|
||||||
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
|
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
|
||||||
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
|
||||||
* @return Accommodation[]
|
|
||||||
*/
|
|
||||||
private function resolveAccommodations(?string $hotelCode): array
|
|
||||||
{
|
|
||||||
if (null === $hotelCode) {
|
|
||||||
return $this->accommodationRepository->findAllWithCalendarCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
|
||||||
|
|
||||||
return null !== $accommodation ? [$accommodation] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
|
|
||||||
{
|
{
|
||||||
if (0 === $staleAfterMinutes) {
|
if (0 === $staleAfterMinutes) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
|
||||||
|
|
||||||
if (null === $syncedAt) {
|
if (null === $syncedAt) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\TravelMailingList;
|
||||||
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Repository\TravelMailingListRepository;
|
||||||
|
use App\Service\TravelMailingListManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:cleanup:travel-mailing-lists',
|
||||||
|
description: 'Deletes travel mailing lists and their orphaned contacts from Mailjet 30 days after the travel started',
|
||||||
|
)]
|
||||||
|
class CleanupTravelMailingListsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||||
|
private readonly TravelMailingListManager $travelMailingListManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$mailingLists = $this->travelMailingListRepository->findDueForDeletion(new \DateTimeImmutable());
|
||||||
|
if ([] === $mailingLists) {
|
||||||
|
$io->success('No travel mailing lists due for deletion.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A travel's lists are deleted together (see TravelMailingListManager::delete()), including
|
||||||
|
// one that is not due yet because the travel's start date moved after it was created.
|
||||||
|
$travelCodes = array_unique(array_map(static fn (TravelMailingList $mailingList) => $mailingList->getTravelCode(), $mailingLists));
|
||||||
|
|
||||||
|
$failed = 0;
|
||||||
|
$deletedLists = 0;
|
||||||
|
foreach ($travelCodes as $travelCode) {
|
||||||
|
$travelLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||||
|
$listIds = implode(', ', array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $travelLists));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$deletedContacts = $this->travelMailingListManager->delete($travelLists);
|
||||||
|
$deletedLists += count($travelLists);
|
||||||
|
$io->writeln(sprintf('Deleted %s (lists %s) and %d orphaned contact(s).', $travelCode, $listIds, $deletedContacts));
|
||||||
|
} catch (NewsletterProviderException $exception) {
|
||||||
|
// The rows of lists not yet deleted stay, so the next run picks them up again.
|
||||||
|
++$failed;
|
||||||
|
$this->logger->error('Delete travel mailing lists failed', [
|
||||||
|
'travel_code' => $travelCode,
|
||||||
|
'mailjet_list_ids' => $listIds,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
$io->error(sprintf('%s (lists %s): %s', $travelCode, $listIds, $exception->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('Deleted %d travel mailing list(s).', $deletedLists));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:content:check-freshness',
|
||||||
|
description: 'Fails when the last accepted content manifest is too old.',
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* The health check of the content sync. A stalled sync otherwise shows only as a chatbot
|
||||||
|
* giving stale answers. Scheduled, a failure reaches the schedule's email_on_failure.
|
||||||
|
*/
|
||||||
|
class ContentCheckFreshnessCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContentSyncStateRepository $stateRepository,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of the last good manifest, in minutes', '60');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$state = $this->stateRepository->get();
|
||||||
|
$generatedAt = $state->getManifestGeneratedAt();
|
||||||
|
$maxAge = (int) $input->getOption('max-age');
|
||||||
|
|
||||||
|
if (null === $generatedAt) {
|
||||||
|
$io->error('No content manifest has been accepted yet.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ageMinutes = (int) floor(($this->clock->now()->getTimestamp() - $generatedAt->getTimestamp()) / 60);
|
||||||
|
|
||||||
|
if ($ageMinutes > $maxAge) {
|
||||||
|
$io->error(sprintf('The last good content manifest is %d minutes old (generated %s). Last run error: %s', $ageMinutes, $generatedAt->format('c'), $state->getLastError() ?? 'none'));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('The last good content manifest is %d minutes old.', $ageMinutes));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\ContentFeed\ContentSyncer;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\LockableTrait;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:content:sync',
|
||||||
|
description: 'Mirrors the TYPO3 content feed for the chatbot MCP server.',
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Console entrypoint for the content feed sync.
|
||||||
|
*
|
||||||
|
* Meant to run every 15 minutes, plus once a week with --refresh in the night window.
|
||||||
|
* Outside 01:00-05:00 Europe/Berlin a run fetches at most 50 pages; --ignore-window lifts
|
||||||
|
* that for a deliberate manual run, such as the first sync on staging.
|
||||||
|
*/
|
||||||
|
class ContentSyncCommand extends Command
|
||||||
|
{
|
||||||
|
use LockableTrait;
|
||||||
|
|
||||||
|
public function __construct(private readonly ContentSyncer $syncer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('refresh', null, InputOption::VALUE_NONE, sprintf('Also refetch items stored more than %d days ago, oldest first', ContentSyncer::REFRESH_AFTER_DAYS))
|
||||||
|
->addOption('ignore-window', null, InputOption::VALUE_NONE, sprintf('Fetch every due page, not just %d, outside the night window', ContentSyncer::PAGES_OUTSIDE_WINDOW))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if (!$this->lock()) {
|
||||||
|
$io->warning('Another content sync is running.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = $this->syncer->run((bool) $input->getOption('refresh'), (bool) $input->getOption('ignore-window'));
|
||||||
|
} finally {
|
||||||
|
$this->release();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->definitionList(
|
||||||
|
['Added' => $report->added],
|
||||||
|
['Deleted' => $report->deleted],
|
||||||
|
['Records fetched' => $report->records],
|
||||||
|
['Pages fetched' => $report->pages],
|
||||||
|
['Failed' => $report->failed],
|
||||||
|
['Pages deferred' => $report->deferred],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null !== $report->pagePhaseEndedBy) {
|
||||||
|
$io->warning('Page phase ended early: '.$report->pagePhaseEndedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $report->stoppedBy) {
|
||||||
|
$io->error('Run stopped: '.$report->stoppedBy);
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success('Content feed synced.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedUnavailableException;
|
||||||
|
use App\ContentFeed\Exception\ContentRequestFailedException;
|
||||||
|
use App\ContentFeed\Exception\PagePhaseEndedException;
|
||||||
|
use App\Model\ContentFeed\ContentManifest;
|
||||||
|
use App\Model\ContentFeed\ManifestItem;
|
||||||
|
use App\Model\ContentFeed\PageFetchResult;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the TYPO3 content feed: the manifest, records by uid, and rendered page fragments.
|
||||||
|
*
|
||||||
|
* The website is an old TYPO3 install on which every page fetch is a full frontend render,
|
||||||
|
* so the load rules here are a contract with its maintainers, not tuning:
|
||||||
|
*
|
||||||
|
* - one request at a time, never parallel;
|
||||||
|
* - at most one page fetch per second;
|
||||||
|
* - after a response slower than 2 s, wait 5 s; after three in a row, no more pages this run;
|
||||||
|
* - a 503 (the kill switch) or three consecutive 5xx responses or timeouts end the run.
|
||||||
|
*
|
||||||
|
* One instance serves one run: call startRun() first, it resets the counters.
|
||||||
|
*/
|
||||||
|
class ContentFeedClient
|
||||||
|
{
|
||||||
|
public const RECORDS_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
private const TIMEOUT_SECONDS = 10.0;
|
||||||
|
private const SLOW_SECONDS = 2.0;
|
||||||
|
private const SLOW_PAUSE_SECONDS = 5.0;
|
||||||
|
private const PAGE_INTERVAL_SECONDS = 1.0;
|
||||||
|
private const MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
|
private const MAX_CONSECUTIVE_SLOW = 3;
|
||||||
|
|
||||||
|
private int $consecutiveFailures = 0;
|
||||||
|
private int $consecutiveSlow = 0;
|
||||||
|
private ?\DateTimeImmutable $notBefore = null;
|
||||||
|
private ?\DateTimeImmutable $lastPageAt = null;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $siteHosts = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly string $baseUrl,
|
||||||
|
private readonly string $apiKey,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startRun(): void
|
||||||
|
{
|
||||||
|
$this->consecutiveFailures = 0;
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
$this->notBefore = null;
|
||||||
|
$this->lastPageAt = null;
|
||||||
|
$this->siteHosts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole manifest. Anything but a usable one stops the run: an empty TYPO3 is a bug,
|
||||||
|
* not a deletion.
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function manifest(): ContentManifest
|
||||||
|
{
|
||||||
|
if ('' === $this->apiKey) {
|
||||||
|
throw new ContentFeedStoppedException('APP_CMS_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->send($this->apiUrl('api/content/manifest'), []);
|
||||||
|
|
||||||
|
if (null === $response || 200 !== $response['status']) {
|
||||||
|
throw new ContentFeedStoppedException(sprintf('Manifest request failed (%s)', $response['status'] ?? 'timeout'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest is not valid JSON', 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || !is_string($data['generatedAt'] ?? null) || !is_array($data['items'] ?? null) || [] === $data['items']) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest has no generatedAt or no items');
|
||||||
|
}
|
||||||
|
|
||||||
|
$generatedAt = ManifestItem::localTime($data['generatedAt']);
|
||||||
|
if (null === $generatedAt) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest generatedAt is unparseable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($data['items'] as $raw) {
|
||||||
|
try {
|
||||||
|
$item = ManifestItem::fromArray(is_array($raw) ? $raw : []);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
// A malformed entry would otherwise read as a deletion of whatever it was.
|
||||||
|
throw new ContentFeedStoppedException('Manifest contains an unusable item: '.$e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
$items[$item->key()] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = [];
|
||||||
|
foreach (is_array($data['sites'] ?? null) ? $data['sites'] : [] as $site) {
|
||||||
|
$host = is_array($site) && is_string($site['base'] ?? null) ? parse_url($site['base'], \PHP_URL_HOST) : null;
|
||||||
|
if (is_string($host)) {
|
||||||
|
$hosts[] = strtolower($host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->siteHosts = array_values(array_unique($hosts));
|
||||||
|
|
||||||
|
return new ContentManifest($generatedAt, $items, $this->siteHosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records of one type by uid, keyed by uid. A requested uid that is absent is no
|
||||||
|
* longer eligible.
|
||||||
|
*
|
||||||
|
* @param list<int> $uids at most RECORDS_BATCH_SIZE
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*
|
||||||
|
* @throws ContentRequestFailedException this batch failed, the run goes on
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function records(string $type, array $uids): array
|
||||||
|
{
|
||||||
|
if (count($uids) > self::RECORDS_BATCH_SIZE) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('At most %d uids per records request', self::RECORDS_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->send($this->apiUrl('api/content/records'), ['type' => $type, 'uids' => implode(',', $uids)]);
|
||||||
|
|
||||||
|
if (null === $response) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records request for %s timed out', $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200 !== $response['status']) {
|
||||||
|
if ($response['status'] >= 500) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records request for %s failed (%d)', $type, $response['status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 and the like: misconfiguration, which the next request would hit too.
|
||||||
|
throw new ContentFeedStoppedException(sprintf('Records request for %s answered %d', $type, $response['status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records response for %s is not valid JSON', $type), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything but the documented envelope fails the batch: read as an empty list, it
|
||||||
|
// would delete every record requested.
|
||||||
|
if (!is_array($data) || ($data['type'] ?? null) !== $type || !is_array($data['items'] ?? null) || !array_is_list($data['items'])) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records response for %s has no items list', $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = [];
|
||||||
|
foreach ($data['items'] as $record) {
|
||||||
|
if (is_array($record) && is_int($record['uid'] ?? null)) {
|
||||||
|
$records[$record['uid']] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare HTML fragment of one page, fetched at most once a second.
|
||||||
|
*
|
||||||
|
* @throws PagePhaseEndedException
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function page(string $contentUrl): PageFetchResult
|
||||||
|
{
|
||||||
|
$host = parse_url($contentUrl, \PHP_URL_HOST);
|
||||||
|
|
||||||
|
// The key goes with every request, so it only ever goes to a host of the feed's own sites.
|
||||||
|
if (!is_string($host) || !in_array(strtolower($host), $this->siteHosts, true)) {
|
||||||
|
$this->logger->warning('Content feed: contentUrl outside the manifest sites, skipped', ['contentUrl' => $contentUrl]);
|
||||||
|
|
||||||
|
return PageFetchResult::failed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->lastPageAt) {
|
||||||
|
$this->waitUntil($this->lastPageAt->modify(sprintf('+%d ms', (int) (self::PAGE_INTERVAL_SECONDS * 1000))));
|
||||||
|
}
|
||||||
|
$this->lastPageAt = $this->clock->now();
|
||||||
|
|
||||||
|
$response = $this->send($contentUrl, [], countSlow: true);
|
||||||
|
|
||||||
|
if (null === $response) {
|
||||||
|
return PageFetchResult::transient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200 === $response['status']) {
|
||||||
|
return PageFetchResult::ok($response['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PageFetchResult::failed($response['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one GET under the load rules. Null on a timeout or transport error.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $query
|
||||||
|
*
|
||||||
|
* @return array{status: int, body: string}|null
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
* @throws PagePhaseEndedException
|
||||||
|
*/
|
||||||
|
private function send(string $url, array $query, bool $countSlow = false): ?array
|
||||||
|
{
|
||||||
|
if (null !== $this->notBefore) {
|
||||||
|
$this->waitUntil($this->notBefore);
|
||||||
|
$this->notBefore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
$duration = 0.0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, [
|
||||||
|
'query' => $query,
|
||||||
|
'headers' => ['X-Api-Key' => $this->apiKey],
|
||||||
|
'timeout' => self::TIMEOUT_SECONDS,
|
||||||
|
'max_duration' => self::TIMEOUT_SECONDS,
|
||||||
|
]);
|
||||||
|
$result = ['status' => $response->getStatusCode(), 'body' => $response->getContent(false)];
|
||||||
|
$duration = $this->duration($response);
|
||||||
|
} catch (TransportExceptionInterface $e) {
|
||||||
|
$this->logger->warning('Content feed: request failed', ['url' => $url, 'error' => $e->getMessage()]);
|
||||||
|
$duration = self::TIMEOUT_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $result && 503 === $result['status']) {
|
||||||
|
throw new ContentFeedUnavailableException($this->retryAfter($response));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $result || $result['status'] >= 500) {
|
||||||
|
if (++$this->consecutiveFailures >= self::MAX_CONSECUTIVE_FAILURES) {
|
||||||
|
throw new ContentFeedStoppedException(sprintf('%d consecutive 5xx responses or timeouts', self::MAX_CONSECUTIVE_FAILURES));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duration > self::SLOW_SECONDS) {
|
||||||
|
$this->notBefore = $this->clock->now()->modify(sprintf('+%d ms', (int) (self::SLOW_PAUSE_SECONDS * 1000)));
|
||||||
|
|
||||||
|
if ($countSlow && ++$this->consecutiveSlow >= self::MAX_CONSECUTIVE_SLOW) {
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
throw new PagePhaseEndedException(sprintf('%d consecutive responses slower than %d s', self::MAX_CONSECUTIVE_SLOW, (int) self::SLOW_SECONDS));
|
||||||
|
}
|
||||||
|
} elseif ($countSlow) {
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function duration(ResponseInterface $response): float
|
||||||
|
{
|
||||||
|
$total = $response->getInfo('total_time');
|
||||||
|
|
||||||
|
return is_float($total) || is_int($total) ? (float) $total : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function retryAfter(ResponseInterface $response): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
$value = $response->getHeaders(false)['retry-after'][0] ?? null;
|
||||||
|
|
||||||
|
if (null === $value || '' === trim($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit(trim($value))) {
|
||||||
|
return $this->clock->now()->modify(sprintf('+%d seconds', (int) trim($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = date_create_immutable($value);
|
||||||
|
|
||||||
|
return false === $date ? null : $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitUntil(\DateTimeImmutable $moment): void
|
||||||
|
{
|
||||||
|
$seconds = (float) $moment->format('U.u') - (float) $this->clock->now()->format('U.u');
|
||||||
|
|
||||||
|
if ($seconds > 0) {
|
||||||
|
$this->clock->sleep($seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiUrl(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($this->baseUrl, '/').'/'.$path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use App\Chatbot\TripCatalog;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedUnavailableException;
|
||||||
|
use App\ContentFeed\Exception\ContentRequestFailedException;
|
||||||
|
use App\ContentFeed\Exception\PagePhaseEndedException;
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Model\ContentFeed\ContentManifest;
|
||||||
|
use App\Model\ContentFeed\ContentSyncReport;
|
||||||
|
use App\Model\ContentFeed\ManifestItem;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the TYPO3 content feed into content_document.
|
||||||
|
*
|
||||||
|
* A run reads the manifest, removes what it no longer lists, refetches the records and
|
||||||
|
* pages whose `modified` changed, and re-merges detail pages into their records. A failed
|
||||||
|
* manifest changes nothing. Bulk page work belongs in the night window: outside it, a run
|
||||||
|
* fetches at most PAGES_OUTSIDE_WINDOW pages and leaves the rest for the next one.
|
||||||
|
*
|
||||||
|
* With `refresh`, items whose stored copy is older than REFRESH_AFTER_DAYS are refetched
|
||||||
|
* regardless of `modified`, oldest first: the backstop for the changes TYPO3 cannot see,
|
||||||
|
* such as a referenced content element changing on another page.
|
||||||
|
*/
|
||||||
|
class ContentSyncer
|
||||||
|
{
|
||||||
|
public const PAGES_OUTSIDE_WINDOW = 50;
|
||||||
|
public const REFRESH_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
private const NIGHT_WINDOW_TIMEZONE = 'Europe/Berlin';
|
||||||
|
private const NIGHT_WINDOW_FROM_HOUR = 1;
|
||||||
|
private const NIGHT_WINDOW_TO_HOUR = 5;
|
||||||
|
private const FLUSH_EVERY_PAGES = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContentFeedClient $client,
|
||||||
|
private readonly ContentDocumentRepository $documentRepository,
|
||||||
|
private readonly ContentSyncStateRepository $stateRepository,
|
||||||
|
private readonly DocumentBodyBuilder $bodyBuilder,
|
||||||
|
private readonly HtmlToMarkdownConverter $converter,
|
||||||
|
private readonly TripCatalog $tripCatalog,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(bool $refresh = false, bool $ignoreWindow = false): ContentSyncReport
|
||||||
|
{
|
||||||
|
$report = new ContentSyncReport();
|
||||||
|
$state = $this->stateRepository->get();
|
||||||
|
$this->entityManager->persist($state);
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
if (null !== $state->getPausedUntil() && $now < $state->getPausedUntil()) {
|
||||||
|
$report->stoppedBy = 'Feed switched off by TYPO3 until '.$state->getPausedUntil()->format('c');
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state->setPausedUntil(null)->setLastRunAt($now);
|
||||||
|
$this->client->startRun();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manifest = $this->client->manifest();
|
||||||
|
$documents = $this->applyManifest($manifest, $report);
|
||||||
|
$state->setManifestGeneratedAt($manifest->generatedAt);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$staleBefore = $refresh ? $now->modify(sprintf('-%d days', self::REFRESH_AFTER_DAYS)) : null;
|
||||||
|
$this->syncRecords($documents, $staleBefore, $report);
|
||||||
|
$this->syncPages($documents, $staleBefore, $ignoreWindow || $this->inNightWindow($now), $report);
|
||||||
|
} catch (ContentFeedUnavailableException $e) {
|
||||||
|
$state->setPausedUntil($e->retryAfter);
|
||||||
|
$report->stoppedBy = $e->getMessage();
|
||||||
|
} catch (ContentFeedStoppedException $e) {
|
||||||
|
$report->stoppedBy = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($documents)) {
|
||||||
|
$this->merge();
|
||||||
|
}
|
||||||
|
|
||||||
|
$state->setLastError($report->stoppedBy ?? $report->pagePhaseEndedBy);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (null === $report->stoppedBy) {
|
||||||
|
$this->logger->info('Content feed sync finished', $report->toLogContext());
|
||||||
|
} else {
|
||||||
|
$this->logger->warning('Content feed sync stopped', $report->toLogContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report->isComplete() && $report->changedAnything()) {
|
||||||
|
$this->tripCatalog->logUnmatchedCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes what the manifest no longer lists and brings every listed item's metadata up
|
||||||
|
* to date. Content is not touched here.
|
||||||
|
*
|
||||||
|
* @return array<string, array{document: ContentDocument, item: ManifestItem}>
|
||||||
|
*/
|
||||||
|
private function applyManifest(ContentManifest $manifest, ContentSyncReport $report): array
|
||||||
|
{
|
||||||
|
$stored = $this->documentRepository->findAllByKey();
|
||||||
|
|
||||||
|
foreach (array_diff_key($stored, $manifest->items) as $document) {
|
||||||
|
$this->entityManager->remove($document);
|
||||||
|
++$report->deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents = [];
|
||||||
|
|
||||||
|
foreach ($manifest->items as $key => $item) {
|
||||||
|
$document = $stored[$key] ?? null;
|
||||||
|
|
||||||
|
if (null === $document) {
|
||||||
|
$document = new ContentDocument($item->type, $item->uid, $item->language, $item->modified);
|
||||||
|
$this->entityManager->persist($document);
|
||||||
|
++$report->added;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->setModified($item->modified)->setSite($item->site)->setUrl($item->url);
|
||||||
|
|
||||||
|
if ($document->isPage()) {
|
||||||
|
$document
|
||||||
|
->setTitle($item->title ?? '')
|
||||||
|
->setBreadcrumb([] === $item->breadcrumb ? null : implode(' › ', $item->breadcrumb))
|
||||||
|
->setData(['contentUrl' => $item->contentUrl, 'description' => $item->description, 'detailOf' => $item->detailOf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents[$key] = ['document' => $document, 'item' => $item];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*/
|
||||||
|
private function syncRecords(array $documents, ?\DateTimeImmutable $staleBefore, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
/** @var array<string, array<int, list<ContentDocument>>> $stale type => uid => documents; the records endpoint takes no language */
|
||||||
|
$stale = [];
|
||||||
|
|
||||||
|
foreach ($this->dueForFetch($documents, $staleBefore, pages: false) as $document) {
|
||||||
|
$stale[$document->getType()][$document->getUid()][] = $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stale as $type => $byUid) {
|
||||||
|
foreach (array_chunk(array_keys($byUid), ContentFeedClient::RECORDS_BATCH_SIZE) as $uids) {
|
||||||
|
try {
|
||||||
|
$records = $this->client->records($type, $uids);
|
||||||
|
} catch (ContentRequestFailedException $e) {
|
||||||
|
$this->logger->warning('Content feed: records batch failed, retried next run', ['type' => $type, 'error' => $e->getMessage()]);
|
||||||
|
$report->failed += count($uids);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
foreach ($byUid[$uid] as $document) {
|
||||||
|
$record = $records[$uid] ?? null;
|
||||||
|
|
||||||
|
if (null === $record) {
|
||||||
|
// Requested but absent: no longer eligible.
|
||||||
|
$this->entityManager->remove($document);
|
||||||
|
++$report->deleted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyRecord($document, $record);
|
||||||
|
++$report->records;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
private function applyRecord(ContentDocument $document, array $record): void
|
||||||
|
{
|
||||||
|
$title = $this->bodyBuilder->title($document->getType(), $record);
|
||||||
|
|
||||||
|
$document
|
||||||
|
->setData($record)
|
||||||
|
->setTitle('' !== $title ? $title : $document->getTitle())
|
||||||
|
// The merged pages are appended in merge().
|
||||||
|
->setBodyMd($this->bodyBuilder->build($document->getType(), $record))
|
||||||
|
->markFetched(ContentFetchState::Ok, $this->clock->now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
private function syncPages(array $documents, ?\DateTimeImmutable $staleBefore, bool $unlimited, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
$due = $this->dueForFetch($documents, $staleBefore, pages: true);
|
||||||
|
|
||||||
|
if (!$unlimited && count($due) > self::PAGES_OUTSIDE_WINDOW) {
|
||||||
|
$report->deferred = count($due) - self::PAGES_OUTSIDE_WINDOW;
|
||||||
|
$due = array_slice($due, 0, self::PAGES_OUTSIDE_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetched = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($due as $document) {
|
||||||
|
$this->fetchPage($document, $report);
|
||||||
|
|
||||||
|
if (0 === ++$fetched % self::FLUSH_EVERY_PAGES) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (PagePhaseEndedException $e) {
|
||||||
|
$report->pagePhaseEndedBy = $e->getMessage();
|
||||||
|
$this->logger->warning('Content feed: page phase ended early', ['reason' => $e->getMessage()]);
|
||||||
|
} finally {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPage(ContentDocument $document, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
$contentUrl = $document->getData()['contentUrl'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($contentUrl)) {
|
||||||
|
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->client->page($contentUrl);
|
||||||
|
|
||||||
|
if ($result->transient) {
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$result->isOk()) {
|
||||||
|
$this->logger->notice('Content feed: page failed, not refetched until it changes', [
|
||||||
|
'uid' => $document->getUid(),
|
||||||
|
'contentUrl' => $contentUrl,
|
||||||
|
'status' => $result->failedStatus,
|
||||||
|
]);
|
||||||
|
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = $this->converter->convert($result->html, $document->getUrl() ?? $contentUrl);
|
||||||
|
$document->setBodyMd($markdown)->markFetched('' === $markdown ? ContentFetchState::Empty : ContentFetchState::Ok, $this->clock->now());
|
||||||
|
++$report->pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items whose `modified` changed since their last fetch, plus, on a refresh, those
|
||||||
|
* fetched before `$staleBefore`, oldest first.
|
||||||
|
*
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*
|
||||||
|
* @return list<ContentDocument>
|
||||||
|
*/
|
||||||
|
private function dueForFetch(array $documents, ?\DateTimeImmutable $staleBefore, bool $pages): array
|
||||||
|
{
|
||||||
|
$due = [];
|
||||||
|
|
||||||
|
foreach ($documents as ['document' => $document]) {
|
||||||
|
if ($document->isPage() !== $pages || !$this->entityManager->contains($document)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outdated = null !== $staleBefore && (null === $document->getSyncedAt() || $document->getSyncedAt() < $staleBefore);
|
||||||
|
|
||||||
|
if ($document->isStale() || $outdated) {
|
||||||
|
$due[] = $document;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($due, static fn (ContentDocument $a, ContentDocument $b): int => ($a->getSyncedAt()?->getTimestamp() ?? 0) <=> ($b->getSyncedAt()?->getTimestamp() ?? 0));
|
||||||
|
|
||||||
|
return $due;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends every detail page to the record it is the detail page of, and keeps the page
|
||||||
|
* itself out of the index. Recomputed from scratch on every run, so a change or deletion
|
||||||
|
* on either side re-merges.
|
||||||
|
*/
|
||||||
|
private function merge(): void
|
||||||
|
{
|
||||||
|
$documents = $this->documentRepository->findAllByKey();
|
||||||
|
/** @var array<string, list<ContentDocument>> $pagesByRecord */
|
||||||
|
$pagesByRecord = [];
|
||||||
|
|
||||||
|
foreach ($documents as $page) {
|
||||||
|
if (!$page->isPage()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = null;
|
||||||
|
foreach ($page->getData()['detailOf'] ?? [] as $ref) {
|
||||||
|
$candidate = $documents[ContentDocument::key($ref['type'], $ref['uid'], $page->getLanguage())] ?? null;
|
||||||
|
|
||||||
|
if (null !== $candidate && ContentFetchState::Ok === $candidate->getFetchState()) {
|
||||||
|
$target = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page->setMergedInto($target?->getRecordRef());
|
||||||
|
|
||||||
|
if (null !== $target && ContentFetchState::Ok === $page->getFetchState() && '' !== $page->getBodyMd()) {
|
||||||
|
$pagesByRecord[$target->getKey()][] = $page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($documents as $record) {
|
||||||
|
if ($record->isPage() || ContentFetchState::Ok !== $record->getFetchState() || null === $record->getData()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = $pagesByRecord[$record->getKey()] ?? [];
|
||||||
|
usort($pages, static fn (ContentDocument $a, ContentDocument $b): int => $a->getUid() <=> $b->getUid());
|
||||||
|
|
||||||
|
$body = $this->bodyBuilder->build($record->getType(), $record->getData());
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$body .= "\n\n".$page->getBodyMd();
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->setBodyMd(trim($body));
|
||||||
|
|
||||||
|
// A record without a reachable page of its own is linkable through its detail page.
|
||||||
|
if (null === $record->getUrl() && [] !== $pages) {
|
||||||
|
$record->setUrl($pages[0]->getUrl())->setSite($record->getSite() ?? $pages[0]->getSite());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inNightWindow(\DateTimeImmutable $now): bool
|
||||||
|
{
|
||||||
|
$hour = (int) $now->setTimezone(new \DateTimeZone(self::NIGHT_WINDOW_TIMEZONE))->format('G');
|
||||||
|
|
||||||
|
return $hour >= self::NIGHT_WINDOW_FROM_HOUR && $hour < self::NIGHT_WINDOW_TO_HOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the searchable Markdown body and the title of a feed record.
|
||||||
|
*
|
||||||
|
* Every record follows the same pattern: the name as heading, the short introductory
|
||||||
|
* fields as paragraphs, then each `text` field as a section under a German heading, in a
|
||||||
|
* fixed order. Structured facts that people ask about (WLAN, Sauna, Pistenkilometer) are
|
||||||
|
* rendered as a list as well, so the full-text search finds them.
|
||||||
|
*/
|
||||||
|
class DocumentBodyBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Introductory fields, rendered without a heading. Plain text in the feed.
|
||||||
|
*/
|
||||||
|
private const LEAD_FIELDS = ['subline', 'headline'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `text` fields per type, in output order, with their section heading. A null heading
|
||||||
|
* renders the field as an introduction straight under the title.
|
||||||
|
*/
|
||||||
|
private const TEXT_SECTIONS = [
|
||||||
|
'product' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'teaserLong' => 'Beschreibung',
|
||||||
|
'feature' => 'Highlights',
|
||||||
|
'conceptDescription' => 'Reisekonzept',
|
||||||
|
'boardDescription' => 'Verpflegung',
|
||||||
|
'includedServices' => 'Leistungen',
|
||||||
|
'additionalServices' => 'Zusatzleistungen',
|
||||||
|
'programmeDescription' => 'Programm',
|
||||||
|
'skiPassDescription' => 'Skipass',
|
||||||
|
'nonBookableInfo' => 'Hinweise zur Buchbarkeit',
|
||||||
|
],
|
||||||
|
'hotel' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'features' => 'Ausstattung',
|
||||||
|
'roomTypes' => 'Zimmer',
|
||||||
|
'additionalInformation' => 'Weitere Informationen',
|
||||||
|
],
|
||||||
|
'region' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'feature' => 'Highlights',
|
||||||
|
'skiArea' => 'Skigebiet',
|
||||||
|
'skiAreaExtended' => 'Skigebiet im Detail',
|
||||||
|
'skiPass' => 'Skipass',
|
||||||
|
'news' => 'Neuigkeiten',
|
||||||
|
],
|
||||||
|
'country' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
],
|
||||||
|
'city' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
'party' => 'Party',
|
||||||
|
'leisure' => 'Freizeit',
|
||||||
|
],
|
||||||
|
'concept' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
'popupText' => 'Kurz erklärt',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts per type, with their German label.
|
||||||
|
*/
|
||||||
|
private const FACTS = [
|
||||||
|
'hotel' => [
|
||||||
|
'bedsCount' => 'Betten',
|
||||||
|
'parkingCount' => 'Parkplätze',
|
||||||
|
'altitude' => 'Höhenlage',
|
||||||
|
'distanceBus' => 'Entfernung zur Bushaltestelle',
|
||||||
|
'distanceLift' => 'Entfernung zum Lift',
|
||||||
|
'shower' => 'Dusche/WC',
|
||||||
|
'partyroom' => 'Partyraum',
|
||||||
|
'gamesroom' => 'Spieleraum',
|
||||||
|
'sauna' => 'Sauna',
|
||||||
|
'wifi' => 'WLAN',
|
||||||
|
],
|
||||||
|
'region' => [
|
||||||
|
'length' => 'Pistenkilometer',
|
||||||
|
'height' => 'Höhe',
|
||||||
|
'lifts' => 'Lifte',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly HtmlToMarkdownConverter $converter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
public function title(string $type, array $record): string
|
||||||
|
{
|
||||||
|
$title = 'news' === $type ? ($record['title'] ?? null) : ($record['name'] ?? null);
|
||||||
|
|
||||||
|
return is_string($title) ? trim($title) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
public function build(string $type, array $record): string
|
||||||
|
{
|
||||||
|
$baseUrl = is_string($record['url'] ?? null) ? $record['url'] : null;
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
$title = $this->title($type, $record);
|
||||||
|
if ('' !== $title) {
|
||||||
|
$blocks[] = '# '.$title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('news' === $type) {
|
||||||
|
return $this->join(array_merge($blocks, $this->newsBlocks($record, $baseUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::LEAD_FIELDS as $field) {
|
||||||
|
$value = $this->plain($record[$field] ?? null);
|
||||||
|
if ('' !== $value && $value !== $title) {
|
||||||
|
$blocks[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('hotel' === $type) {
|
||||||
|
$address = $this->plain($record['address'] ?? null);
|
||||||
|
if ('' !== $address) {
|
||||||
|
$blocks[] = 'Adresse: '.$address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = is_array($record['text'] ?? null) ? $record['text'] : [];
|
||||||
|
foreach (self::TEXT_SECTIONS[$type] ?? [] as $field => $heading) {
|
||||||
|
$markdown = $this->converter->convert(is_string($text[$field] ?? null) ? $text[$field] : null, $baseUrl);
|
||||||
|
if ('' === $markdown) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$blocks[] = null === $heading ? $markdown : "## {$heading}\n\n{$markdown}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$facts = $this->facts($type, is_array($record['facts'] ?? null) ? $record['facts'] : []);
|
||||||
|
if ('' !== $facts) {
|
||||||
|
$blocks[] = "## Fakten\n\n".$facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('region' === $type && is_string($record['webcam'] ?? null) && '' !== trim($record['webcam'])) {
|
||||||
|
$blocks[] = 'Webcam: '.trim($record['webcam']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('product' === $type) {
|
||||||
|
$faqs = $this->faqs(is_array($record['faqs'] ?? null) ? $record['faqs'] : [], $baseUrl);
|
||||||
|
if ('' !== $faqs) {
|
||||||
|
$blocks[] = "## Häufige Fragen\n\n".$faqs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->join($blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function newsBlocks(array $record, ?string $baseUrl): array
|
||||||
|
{
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
if (is_string($record['datetime'] ?? null) && false !== ($date = date_create_immutable($record['datetime']))) {
|
||||||
|
$blocks[] = 'Veröffentlicht am '.$date->format('d.m.Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['teaser', 'bodytext'] as $field) {
|
||||||
|
$markdown = $this->converter->convert(is_string($record[$field] ?? null) ? $record[$field] : null, $baseUrl);
|
||||||
|
if ('' !== $markdown) {
|
||||||
|
$blocks[] = $markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $this->labels($record['tags'] ?? null);
|
||||||
|
if ([] !== $tags) {
|
||||||
|
$blocks[] = 'Themen: '.implode(', ', $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $facts
|
||||||
|
*/
|
||||||
|
private function facts(string $type, array $facts): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
foreach (self::FACTS[$type] ?? [] as $field => $label) {
|
||||||
|
$value = $facts[$field] ?? null;
|
||||||
|
|
||||||
|
$rendered = match (true) {
|
||||||
|
is_bool($value) => $value ? 'ja' : 'nein',
|
||||||
|
is_int($value), is_float($value) => (string) $value,
|
||||||
|
is_string($value) => $this->plain($value),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('' !== $rendered) {
|
||||||
|
$lines[] = "- {$label}: {$rendered}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $faqs
|
||||||
|
*/
|
||||||
|
private function faqs(array $faqs, ?string $baseUrl): string
|
||||||
|
{
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
foreach ($faqs as $faq) {
|
||||||
|
if (!is_array($faq)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$question = $this->plain($faq['question'] ?? null);
|
||||||
|
$answer = $this->converter->convert(is_string($faq['answer'] ?? null) ? $faq['answer'] : null, $baseUrl);
|
||||||
|
|
||||||
|
if ('' !== $question && '' !== $answer) {
|
||||||
|
$blocks[] = "### {$question}\n\n{$answer}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->join($blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags and categories may arrive as strings or as objects with a title or name.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function labels(mixed $values): array
|
||||||
|
{
|
||||||
|
if (!is_array($values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$label = is_array($value) ? ($value['title'] ?? $value['name'] ?? null) : $value;
|
||||||
|
if (is_string($label) && '' !== trim($label)) {
|
||||||
|
$labels[] = trim($label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A field that is plain text in the feed, defensively stripped of any markup.
|
||||||
|
*/
|
||||||
|
private function plain(mixed $value): string
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $blocks
|
||||||
|
*/
|
||||||
|
private function join(array $blocks): string
|
||||||
|
{
|
||||||
|
return implode("\n\n", $blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the whole sync run, keeping everything as it is. The next scheduled run tries again.
|
||||||
|
*
|
||||||
|
* Thrown for a manifest that cannot be trusted, three consecutive 5xx responses or
|
||||||
|
* timeouts, and anything else that means TYPO3 is not in a state to be read.
|
||||||
|
*/
|
||||||
|
class ContentFeedStoppedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TYPO3 answered 503: the feed has been switched off on its side.
|
||||||
|
*/
|
||||||
|
class ContentFeedUnavailableException extends ContentFeedStoppedException
|
||||||
|
{
|
||||||
|
public function __construct(public readonly ?\DateTimeImmutable $retryAfter)
|
||||||
|
{
|
||||||
|
parent::__construct('TYPO3 switched the content feed off (503)'.(null !== $retryAfter ? ', retry after '.$retryAfter->format('c') : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request failed without ending the run. The items it was for keep their stored
|
||||||
|
* version and are retried next run.
|
||||||
|
*/
|
||||||
|
class ContentRequestFailedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three slow responses in a row: TYPO3 is under load, so no more pages this run. The rest
|
||||||
|
* of the run (bookkeeping, merging) still completes.
|
||||||
|
*/
|
||||||
|
class PagePhaseEndedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use League\HTMLToMarkdown\Converter\TableConverter;
|
||||||
|
use League\HTMLToMarkdown\HtmlConverter;
|
||||||
|
use Symfony\Component\DomCrawler\UriResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the CMS's HTML, record text fields and page fragments alike, into Markdown for the
|
||||||
|
* index and the tool output. LLMs read Markdown well; HTML noise only costs tokens.
|
||||||
|
*
|
||||||
|
* Embeds, media and forms are dropped with their content. Relative links are made
|
||||||
|
* absolute against the document's own URL, since the page fragment may carry them.
|
||||||
|
*/
|
||||||
|
class HtmlToMarkdownConverter
|
||||||
|
{
|
||||||
|
private const REMOVED_ELEMENTS = ['script', 'style', 'noscript', 'iframe', 'img', 'picture', 'svg', 'form'];
|
||||||
|
|
||||||
|
private readonly HtmlConverter $converter;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->converter = new HtmlConverter([
|
||||||
|
'header_style' => 'atx',
|
||||||
|
'strip_tags' => true,
|
||||||
|
'strip_placeholder_links' => true,
|
||||||
|
'hard_break' => true,
|
||||||
|
'suppress_errors' => true,
|
||||||
|
'remove_nodes' => implode(' ', self::REMOVED_ELEMENTS),
|
||||||
|
]);
|
||||||
|
$this->converter->getEnvironment()->addConverter(new TableConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $baseUrl what relative links resolve against; left as they are without one
|
||||||
|
*/
|
||||||
|
public function convert(?string $html, ?string $baseUrl = null): string
|
||||||
|
{
|
||||||
|
if (null === $html || '' === trim($html)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = $this->converter->convert($this->prepare($html, $baseUrl));
|
||||||
|
|
||||||
|
return $this->collapseWhitespace($markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepare(string $html, ?string $baseUrl): string
|
||||||
|
{
|
||||||
|
$document = \Dom\HTMLDocument::createFromString('<!DOCTYPE html><html><body>'.$html.'</body></html>', \LIBXML_NOERROR, 'UTF-8');
|
||||||
|
$body = $document->body;
|
||||||
|
|
||||||
|
if (null === $body) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($body->querySelectorAll(implode(',', self::REMOVED_ELEMENTS)) as $element) {
|
||||||
|
$element->remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace in text collapses as a browser would; the template's indentation would
|
||||||
|
// otherwise end up inside link labels and headings.
|
||||||
|
foreach ((new \Dom\XPath($document))->query('//body//text()[not(ancestor::pre)]') as $text) {
|
||||||
|
if (!$text instanceof \Dom\Text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$text->data = (string) preg_replace('/[ \t\r\n]+/', ' ', $text->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $baseUrl) {
|
||||||
|
foreach ($body->querySelectorAll('a[href]') as $link) {
|
||||||
|
$href = trim((string) $link->getAttribute('href'));
|
||||||
|
|
||||||
|
if ('' === $href || preg_match('/^(mailto|tel|javascript):/i', $href)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link->setAttribute('href', UriResolver::resolve($href, $baseUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $body->innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collapseWhitespace(string $markdown): string
|
||||||
|
{
|
||||||
|
// The converter leaves text entity-encoded (`&`), which only costs tokens.
|
||||||
|
$markdown = html_entity_decode($markdown, \ENT_QUOTES | \ENT_HTML5, 'UTF-8');
|
||||||
|
$markdown = str_replace(["\r\n", "\r", "\u{00A0}"], ["\n", "\n", ' '], $markdown);
|
||||||
|
|
||||||
|
// Labels padded by the collapsed whitespace: "[ Kontakt ](…)" -> "[Kontakt](…)".
|
||||||
|
$markdown = (string) preg_replace(['/\[ +/', '/ +\]\(/'], ['[', ']('], $markdown);
|
||||||
|
|
||||||
|
$lines = array_map(
|
||||||
|
// Inner runs only: leading spaces carry list nesting.
|
||||||
|
static fn (string $line): string => rtrim((string) preg_replace('/(?<=\S)[ \t]{2,}/', ' ', $line)),
|
||||||
|
explode("\n", $markdown),
|
||||||
|
);
|
||||||
|
|
||||||
|
return trim((string) preg_replace("/\n{3,}/", "\n\n", implode("\n", $lines)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,9 +158,16 @@ class PersonalDataController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_personal_data');
|
return $this->redirectToRoute('app_personal_data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The form leaves these out of validation because the user has no field for them, so they
|
||||||
|
// are reported separately. Skipped for a failed load, which would list every field.
|
||||||
|
$invalidIdentityFields = null !== $personalData->addressId
|
||||||
|
? $this->completenessChecker->invalidIdentityFields($personalData)
|
||||||
|
: [];
|
||||||
|
|
||||||
return $this->render('account/personal_data.html.twig', [
|
return $this->render('account/personal_data.html.twig', [
|
||||||
'personalData' => $personalData,
|
'personalData' => $personalData,
|
||||||
'personalDataForm' => $personalDataForm,
|
'personalDataForm' => $personalDataForm,
|
||||||
|
'invalidIdentityFields' => $invalidIdentityFields,
|
||||||
'newsletterSubscribed' => $newsletterSubscribed,
|
'newsletterSubscribed' => $newsletterSubscribed,
|
||||||
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class IndexController extends AbstractController
|
|||||||
$request->query->getInt('page', 1),
|
$request->query->getInt('page', 1),
|
||||||
$request->query->getInt('limit', 20),
|
$request->query->getInt('limit', 20),
|
||||||
[
|
[
|
||||||
'defaultSortFieldName' => 'booking.createdAt',
|
'defaultSortFieldName' => 'booking.updatedAt',
|
||||||
'defaultSortDirection' => 'DESC',
|
'defaultSortDirection' => 'DESC',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Admin\Log;
|
namespace App\Controller\Admin\Log;
|
||||||
|
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use App\Service\XmlDumpReader;
|
use App\Service\XmlDumpReader;
|
||||||
use League\Flysystem\FilesystemException;
|
use League\Flysystem\FilesystemException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||||
class DownloadController extends AbstractController
|
class DownloadController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ use App\Controller\Traits\ListFilterTrait;
|
|||||||
use App\Form\Admin\Filter\LogEntryFilterType;
|
use App\Form\Admin\Filter\LogEntryFilterType;
|
||||||
use App\Form\Model\Filter\LogEntryFilterDto;
|
use App\Form\Model\Filter\LogEntryFilterDto;
|
||||||
use App\Repository\LogEntryRepository;
|
use App\Repository\LogEntryRepository;
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||||
class FilterController extends AbstractController
|
class FilterController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Controller\Traits\ListFilterTrait;
|
|||||||
use App\Form\Admin\Filter\LogEntryFilterType;
|
use App\Form\Admin\Filter\LogEntryFilterType;
|
||||||
use App\Form\Model\Filter\LogEntryFilterDto;
|
use App\Form\Model\Filter\LogEntryFilterDto;
|
||||||
use App\Repository\LogEntryRepository;
|
use App\Repository\LogEntryRepository;
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Knp\Component\Pager\PaginatorInterface;
|
use Knp\Component\Pager\PaginatorInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -15,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Admin\Log;
|
namespace App\Controller\Admin\Log;
|
||||||
|
|
||||||
use App\Entity\LogEntry;
|
use App\Entity\LogEntry;
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use App\Service\XmlDumpReader;
|
use App\Service\XmlDumpReader;
|
||||||
use League\Flysystem\FilesystemException;
|
use League\Flysystem\FilesystemException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||||
class XmlDumpController extends AbstractController
|
class XmlDumpController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\TravelMailing;
|
||||||
|
|
||||||
|
use App\BpnConnect\Exception\BpnConnectException;
|
||||||
|
use App\BpnConnect\TravelsClient;
|
||||||
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\Entity\TravelMailingList;
|
||||||
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Form\Model\TravelMailingLookupDto;
|
||||||
|
use App\Repository\TravelMailingListRepository;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use App\Service\TravelMailingListManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
|
class CreateListController extends AbstractController
|
||||||
|
{
|
||||||
|
public const string CSRF_TOKEN_ID = 'create_travel_mailing_list';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelsClient $travelsClient,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||||
|
private readonly TravelMailingListManager $travelMailingListManager,
|
||||||
|
private readonly LoggerInterface $adminLogger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/travel-mailing/list', name: 'app_admin_travelmailing_createlist', methods: ['POST'])]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lookup = new TravelMailingLookupDto();
|
||||||
|
$lookup->travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
|
||||||
|
$lookup->busOnly = $request->request->getBoolean('busOnly');
|
||||||
|
$travelCode = $lookup->travelCode;
|
||||||
|
$busLeg = $lookup->getBusLeg();
|
||||||
|
$returnUrl = $this->generateUrl('app_admin_travelmailing', array_filter([
|
||||||
|
'travelCode' => $travelCode,
|
||||||
|
'busOnly' => $lookup->busOnly ? 1 : null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$name = TravelMailingList::nameFor($travelCode, $busLeg);
|
||||||
|
if (null !== $this->travelMailingListRepository->findOneByName($name)) {
|
||||||
|
$this->addFlash('error', sprintf('Die Mailjet-Liste %s gibt es bereits.', $name));
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
|
||||||
|
if (null === $travelStartsAt) {
|
||||||
|
$this->addFlash('error', sprintf('Für %s ist kein Reisebeginn bekannt, die Liste könnte nicht automatisch gelöscht werden.', $travelCode));
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetched again rather than posted from the page, so the list holds what BusPro reports now.
|
||||||
|
try {
|
||||||
|
$recipients = $this->travelsClient->getMailingRecipients($travelCode, $busLeg)->data;
|
||||||
|
} catch (BpnConnectException) {
|
||||||
|
$this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.');
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $recipients) {
|
||||||
|
$this->addFlash('error', sprintf('Keine Empfänger für %s gefunden.', $travelCode));
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $busLeg, $travelStartsAt);
|
||||||
|
} catch (NewsletterProviderException $exception) {
|
||||||
|
$this->adminLogger->error('Create travel mailing list failed', [
|
||||||
|
'travel_code' => $travelCode,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
$this->addFlash('error', 'Die Mailjet-Liste konnte nicht angelegt werden: '.$exception->getMessage());
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->adminLogger->info('Create travel mailing list', [
|
||||||
|
'travel_code' => $travelCode,
|
||||||
|
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||||
|
'bus_leg' => $busLeg?->value,
|
||||||
|
'recipients' => count($recipients),
|
||||||
|
]);
|
||||||
|
$this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $mailingList->getName(), count($recipients)));
|
||||||
|
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\TravelMailing;
|
||||||
|
|
||||||
|
use App\BpnConnect\Exception\BpnConnectException;
|
||||||
|
use App\BpnConnect\TravelsClient;
|
||||||
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\Entity\TravelMailingList;
|
||||||
|
use App\Form\Admin\TravelMailingLookupType;
|
||||||
|
use App\Form\Model\TravelMailingLookupDto;
|
||||||
|
use App\Repository\TravelMailingListRepository;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
|
class IndexController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelsClient $travelsClient,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/travel-mailing', name: 'app_admin_travelmailing')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$lookup = new TravelMailingLookupDto();
|
||||||
|
$form = $this->createForm(TravelMailingLookupType::class, $lookup, [
|
||||||
|
'action' => $this->generateUrl('app_admin_travelmailing'),
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
$travelLabel = null;
|
||||||
|
$travelStartsAt = null;
|
||||||
|
$mailingLists = [];
|
||||||
|
$mailingList = null;
|
||||||
|
$listName = null;
|
||||||
|
$failed = false;
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
// The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126"
|
||||||
|
// before the label lookup; bpn-connect gets the same code to keep both in step.
|
||||||
|
$travelCode = (new DateCodeUtility())->sanitize((string) $lookup->travelCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->travelsClient->getMailingRecipients($travelCode, $lookup->getBusLeg());
|
||||||
|
$travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode);
|
||||||
|
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
|
||||||
|
$mailingLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||||
|
// The list matching the current filter, if one exists, replaces the create button.
|
||||||
|
$listName = TravelMailingList::nameFor($travelCode, $lookup->getBusLeg());
|
||||||
|
foreach ($mailingLists as $existing) {
|
||||||
|
if ($listName === $existing->getName()) {
|
||||||
|
$mailingList = $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (BpnConnectException) {
|
||||||
|
// The client has already logged the cause; the page only needs to say it failed.
|
||||||
|
$failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/travel_mailing/index.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'result' => $result,
|
||||||
|
'travel_label' => $travelLabel,
|
||||||
|
'travel_starts_at' => $travelStartsAt,
|
||||||
|
'mailing_lists' => $mailingLists,
|
||||||
|
'mailing_list' => $mailingList,
|
||||||
|
'list_name' => $listName,
|
||||||
|
'failed' => $failed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ use App\Model\ContingentCalendarQuery;
|
|||||||
use App\Model\ContingentPricesQuery;
|
use App\Model\ContingentPricesQuery;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Repository\Groups\AccommodationRepository;
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
|
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||||
use App\Service\AccommodationPriceCoverage;
|
use App\Service\AccommodationPriceCoverage;
|
||||||
|
use App\Service\ContingentHotelDirectory;
|
||||||
use App\Service\ContingentSnapshotReader;
|
use App\Service\ContingentSnapshotReader;
|
||||||
use App\Service\PriceTimelineBuilder;
|
use App\Service\PriceTimelineBuilder;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
@@ -33,6 +35,8 @@ class ContingentController extends AbstractController
|
|||||||
private readonly ContingentSnapshotReader $snapshotReader,
|
private readonly ContingentSnapshotReader $snapshotReader,
|
||||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||||
|
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||||
|
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -107,7 +111,7 @@ class ContingentController extends AbstractController
|
|||||||
// An empty or stale snapshot must not be served as though every day were blocked: that
|
// An empty or stale snapshot must not be served as though every day were blocked: that
|
||||||
// is a plausible-looking 200 nobody can distinguish from real data. Fail the way this
|
// is a plausible-looking 200 nobody can distinguish from real data. Fail the way this
|
||||||
// endpoint always failed instead, so existing consumers need no change.
|
// endpoint always failed instead, so existing consumers need no change.
|
||||||
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
|
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||||
|
|
||||||
if (null === $statuses) {
|
if (null === $statuses) {
|
||||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||||
@@ -133,6 +137,86 @@ class ContingentController extends AbstractController
|
|||||||
return $this->json($data);
|
return $this->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are
|
||||||
|
* left out rather than blocked, so a portal shows them as free like it did with the feed
|
||||||
|
* that read bpn-connect directly.
|
||||||
|
*
|
||||||
|
* Serves every synced hotel code, including TYPO3's calendar hotels that have no
|
||||||
|
* accommodation (winter-only hotels).
|
||||||
|
*/
|
||||||
|
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
|
||||||
|
public function availability(
|
||||||
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||||
|
ContingentCalendarQuery $query,
|
||||||
|
): JsonResponse {
|
||||||
|
if (null === $this->hotelDirectory->find($query->hotelCode)) {
|
||||||
|
$this->logger->error('Hotel not found for hotelcode '.$query->hotelCode);
|
||||||
|
|
||||||
|
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a range: from today to wherever the snapshot ends.
|
||||||
|
$dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0);
|
||||||
|
$dateTo = $query->dateToDate();
|
||||||
|
|
||||||
|
// Same reasoning as calendar(): a missing or stale snapshot must not pass for real data.
|
||||||
|
// Here it matters even more, since an empty feed would show every day as free.
|
||||||
|
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||||
|
|
||||||
|
if (null === $statuses) {
|
||||||
|
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($statuses);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
foreach ($statuses as $date => $status) {
|
||||||
|
$data[] = ['date' => (string) $date, 'status' => $status->value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per synced hotel code, for the overview of which hotel supports which calendar.
|
||||||
|
*
|
||||||
|
* `available` is whether `availability` currently serves the hotel; calendar and price table
|
||||||
|
* additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])]
|
||||||
|
public function status(): JsonResponse
|
||||||
|
{
|
||||||
|
$states = $this->syncStateRepository->findAllIndexedByHotelCode();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
foreach ($this->hotelDirectory->all() as $hotel) {
|
||||||
|
$state = $states[$hotel->code] ?? null;
|
||||||
|
$accommodation = $hotel->accommodation;
|
||||||
|
$bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null;
|
||||||
|
|
||||||
|
$data[] = [
|
||||||
|
'hotelCode' => $hotel->code,
|
||||||
|
'name' => $hotel->calendarHotel?->getName(),
|
||||||
|
'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()),
|
||||||
|
'accommodation' => null !== $accommodation ? [
|
||||||
|
'name' => $accommodation->getName(),
|
||||||
|
'cmsCode' => $accommodation->getEffectiveCmsCode(),
|
||||||
|
] : null,
|
||||||
|
'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null,
|
||||||
|
'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null,
|
||||||
|
'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
'lastError' => $state?->getLastError(),
|
||||||
|
'failureCount' => $state?->getFailureCount() ?? 0,
|
||||||
|
'available' => $this->snapshotReader->isCurrent($state),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param AccommodationPrice[] $prices
|
* @param AccommodationPrice[] $prices
|
||||||
* @param array<string, true> $covered
|
* @param array<string, true> $covered
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
|||||||
use App\Exception\BookingSessionNotFoundException;
|
use App\Exception\BookingSessionNotFoundException;
|
||||||
use App\Exception\HotelNotInTravelException;
|
use App\Exception\HotelNotInTravelException;
|
||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
|
use App\Form\BookingApplicantTravelsType;
|
||||||
use App\Form\BookingCreateStep2Type;
|
use App\Form\BookingCreateStep2Type;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
|
|||||||
use App\Service\TravelDataProvider;
|
use App\Service\TravelDataProvider;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the second step of the booking creation process using card-based UI.
|
* Handles the second step of the booking creation process using card-based UI.
|
||||||
@@ -80,14 +83,32 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
|
|
||||||
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
||||||
|
|
||||||
|
// A session can still hold a split for a travel date that does not offer the choice.
|
||||||
|
if (false === $bookingCreateDto->applicantTravels
|
||||||
|
&& false === $bookingCreateDto->travel->allowsNonTravellingApplicant()) {
|
||||||
|
$bookingCreateDto->restoreTravellingApplicant();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account's own data belongs to whoever is the applicant: participant[0] normally,
|
||||||
|
// and the separate applicant record when they do not travel along.
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
if ($user instanceof User
|
if ($user instanceof User) {
|
||||||
&& isset($bookingCreateDto->participants[0])
|
if (false === $bookingCreateDto->applicantTravels) {
|
||||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
$bookingCreateDto->applicant ??= new ParticipantDto();
|
||||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
|
||||||
$user,
|
if ($this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->applicant)) {
|
||||||
$bookingCreateDto->participants[0]
|
$bookingCreateDto->applicant = $this->prepopulationService->prefillApplicantFromUser(
|
||||||
);
|
$user,
|
||||||
|
$bookingCreateDto->applicant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif (isset($bookingCreateDto->participants[0])
|
||||||
|
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||||
|
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||||
|
$user,
|
||||||
|
$bookingCreateDto->participants[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
|
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
|
||||||
@@ -122,6 +143,10 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
$templateData = [
|
$templateData = [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
'bookingCreateContext' => $context,
|
'bookingCreateContext' => $context,
|
||||||
|
'applicantErrors' => $this->collectApplicantErrors($form),
|
||||||
|
'applicantTravelsForm' => $this
|
||||||
|
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
|
||||||
|
->createView(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $this->render('booking/create/step_2.html.twig', $templateData);
|
return $this->render('booking/create/step_2.html.twig', $templateData);
|
||||||
@@ -200,6 +225,42 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls out the violations that belong to the applicant block rather than a participant.
|
||||||
|
*
|
||||||
|
* The step-2 form carries no fields of its own, so every violation surfaces at the form root
|
||||||
|
* and the page can only say "check the participants". An incomplete applicant would then be
|
||||||
|
* an invisible blocker: nothing on the page is marked and the button simply does nothing.
|
||||||
|
*
|
||||||
|
* @param FormInterface<mixed> $form
|
||||||
|
*
|
||||||
|
* @return string[] Messages to render on the applicant card
|
||||||
|
*/
|
||||||
|
private function collectApplicantErrors(FormInterface $form): array
|
||||||
|
{
|
||||||
|
if (false === $form->isSubmitted()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
foreach ($form->getErrors(true) as $error) {
|
||||||
|
$cause = $error->getCause();
|
||||||
|
|
||||||
|
if (false === $cause instanceof ConstraintViolationInterface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === str_contains($cause->getPropertyPath(), 'applicant')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = $error->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($messages));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rejects a room selection that resolves to no participants at all.
|
* Rejects a room selection that resolves to no participants at all.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
|
use App\Form\BookingApplicantTravelsType;
|
||||||
|
use App\Form\BookingApplicantType;
|
||||||
use App\Form\BookingParticipantType;
|
use App\Form\BookingParticipantType;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Service\ApplicantInsuranceCascade;
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
@@ -130,6 +133,135 @@ class Step2ParticipantController extends AbstractController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips the booking-level "I am not travelling myself" choice.
|
||||||
|
*
|
||||||
|
* Toggling on moves participant[0]'s personal data into the applicant record and leaves
|
||||||
|
* behind a blank traveller that keeps only its index and room assignment: the services were
|
||||||
|
* derived from an age that no longer applies. Toggling off moves it back.
|
||||||
|
*
|
||||||
|
* Only offered where the travel date allows it; elsewhere the request changes nothing.
|
||||||
|
*/
|
||||||
|
#[Route(
|
||||||
|
path: '/bookings/create/applicant-toggle',
|
||||||
|
name: 'app_booking_create_step_2_applicant_toggle',
|
||||||
|
methods: ['POST']
|
||||||
|
)]
|
||||||
|
public function toggleApplicantTravels(Request $request): Response
|
||||||
|
{
|
||||||
|
$result = $this->loadBookingDtoOrRedirect($request);
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$bookingDto = $result;
|
||||||
|
|
||||||
|
if (false === $bookingDto->travel->allowsNonTravellingApplicant()) {
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
|
||||||
|
|
||||||
|
// Captured before handleRequest(), which binds the new choice straight onto the DTO.
|
||||||
|
$previousChoice = $bookingDto->applicantTravels;
|
||||||
|
|
||||||
|
$form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$applicantTravels = $bookingDto->applicantTravels;
|
||||||
|
|
||||||
|
if ($applicantTravels !== $previousChoice) {
|
||||||
|
$this->participantFormSupportService->ensureParticipantExists($bookingDto, 0);
|
||||||
|
|
||||||
|
if (false === $applicantTravels) {
|
||||||
|
$bookingDto->applicant = $bookingDto->participants[0];
|
||||||
|
$bookingDto->applicant->index = null;
|
||||||
|
$bookingDto->participants[0] = $this->participantFormSupportService
|
||||||
|
->createBlankTravellerFrom($bookingDto->applicant);
|
||||||
|
} else {
|
||||||
|
$bookingDto->restoreTravellingApplicant();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family insurance is not offered on a split booking, so a choice already made on
|
||||||
|
// participant[0] has to go with it.
|
||||||
|
if (false === $applicantTravels) {
|
||||||
|
$this->participantFormSupportService->clearFamilyInsurance($bookingDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
|
// The choice rebuilds the whole participant list, so navigate rather than swap a fragment.
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact data of an applicant who does not travel along.
|
||||||
|
*
|
||||||
|
* Guests only. A logged-in applicant's data comes from their account and is rendered as
|
||||||
|
* static text on the step-2 card, because editing it here would create a duplicate BusPro
|
||||||
|
* record - the same reason participant[0] is static when the applicant travels.
|
||||||
|
*/
|
||||||
|
#[Route(
|
||||||
|
path: '/bookings/create/applicant',
|
||||||
|
name: 'app_booking_create_applicant'
|
||||||
|
)]
|
||||||
|
public function editApplicant(Request $request): Response
|
||||||
|
{
|
||||||
|
$result = $this->loadBookingDtoOrRedirect($request);
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$bookingDto = $result;
|
||||||
|
|
||||||
|
if (true === $bookingDto->applicantTravels || false === $bookingDto->travel->allowsNonTravellingApplicant()) {
|
||||||
|
return $this->redirectToRoute('app_booking_create_step_2');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookingDto->applicant ??= new ParticipantDto();
|
||||||
|
|
||||||
|
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
|
||||||
|
// whole block with dummy data instead of saving what was typed.
|
||||||
|
if ($form->isSubmitted()
|
||||||
|
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
|
||||||
|
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
|
||||||
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_booking_create_applicant');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_booking_create_step_2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep an invalid submission in the session so the entered data survives a reload.
|
||||||
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
|
$bookingCreateContext = $this->createContextFactory->create(
|
||||||
|
$bookingDto,
|
||||||
|
RoomPricingCalculator::PRICING_MODE_SELECTION
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('booking/create/applicant.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'bookingCreateContext' => $bookingCreateContext,
|
||||||
|
'bookingDto' => $bookingCreateContext->bookingDto,
|
||||||
|
'summaryData' => $bookingCreateContext->summaryData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param FormInterface<mixed> $form
|
* @param FormInterface<mixed> $form
|
||||||
* @param array<array{type: string, message: string}> $notifications
|
* @param array<array{type: string, message: string}> $notifications
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class Step1Controller extends AbstractController
|
|||||||
\DateTimeImmutable $dateTo,
|
\DateTimeImmutable $dateTo,
|
||||||
): array {
|
): array {
|
||||||
$contingentStatus = null !== $accommodation
|
$contingentStatus = null !== $accommodation
|
||||||
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
|
? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? []
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ interface DashboardWidgetProviderInterface
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The role this widget requires, checked with isGranted() — so a widget requiring
|
* The role this widget requires, checked with isGranted() — so a widget requiring
|
||||||
* ROLE_GROUPS_MANAGER also appears for the roles above it in the hierarchy.
|
* ROLE_GROUPS_MANAGER also appears for the roles above it in the hierarchy. A voter
|
||||||
|
* attribute such as ADMINISTRATIVE_ACCESS works too, for a widget every admin-area role sees.
|
||||||
*/
|
*/
|
||||||
public function getRequiredRole(): string;
|
public function getRequiredRole(): string;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Entity\LogEntry;
|
|||||||
use App\Model\DashboardWidget;
|
use App\Model\DashboardWidget;
|
||||||
use App\Model\DashboardWidgetEntry;
|
use App\Model\DashboardWidgetEntry;
|
||||||
use App\Repository\LogEntryRepository;
|
use App\Repository\LogEntryRepository;
|
||||||
use App\Security\Role;
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +37,7 @@ class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
|
|||||||
|
|
||||||
public function getRequiredRole(): string
|
public function getRequiredRole(): string
|
||||||
{
|
{
|
||||||
return Role::CUSTOMER_EXPERT;
|
return AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPriority(): int
|
public function getPriority(): int
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item of the TYPO3 content feed, mirrored for the chatbot's MCP tools.
|
||||||
|
*
|
||||||
|
* Identity is TYPO3's `(type, uid, language)`. A record (product, hotel, destination,
|
||||||
|
* concept, news) carries its structured payload in `data` as received; a page carries its
|
||||||
|
* manifest entry there, including `contentUrl` and `detailOf`.
|
||||||
|
*
|
||||||
|
* A page that is the detail page of a record is merged into it: its text is appended to the
|
||||||
|
* record's `bodyMd`, and the page itself names the record in `mergedInto` and stays out of
|
||||||
|
* the index, so a product does not turn up twice with half its text each time.
|
||||||
|
*
|
||||||
|
* `indexed` is derived, never set: only documents that were fetched, have text and are not
|
||||||
|
* merged into another are searched. A product TYPO3 keeps out of its own website search
|
||||||
|
* (`searchable: false`: closed group trips, staff trainings) stays out of this one too.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: ContentDocumentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'content_document')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'content_document_identity', columns: ['type', 'uid', 'language'])]
|
||||||
|
#[ORM\Index(name: 'content_document_indexed_site', columns: ['indexed', 'site'])]
|
||||||
|
#[ORM\Index(name: 'content_document_url', columns: ['url'])]
|
||||||
|
#[ORM\Index(name: 'content_document_fulltext', columns: ['title', 'breadcrumb', 'body_md'], flags: ['fulltext'])]
|
||||||
|
class ContentDocument
|
||||||
|
{
|
||||||
|
public const TYPE_PAGE = 'page';
|
||||||
|
public const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept', 'news'];
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16)]
|
||||||
|
private string $type;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $uid;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 8)]
|
||||||
|
private string $language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null for a record without a reachable detail page.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $site = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public URL to cite. Null for a record without a reachable detail page.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 768, nullable: true)]
|
||||||
|
private ?string $url = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $title = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages only, joined with ›.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 512, nullable: true)]
|
||||||
|
private ?string $breadcrumb = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, length: 16777215)]
|
||||||
|
private string $bodyMd = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||||
|
private ?array $data = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest's `modified`, as last seen.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $modified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest's `modified` that the stored fetch outcome belongs to. The item is stale
|
||||||
|
* while this differs from `modified`.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $fetchedModified = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, enumType: ContentFetchState::class)]
|
||||||
|
private ContentFetchState $fetchState = ContentFetchState::Pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last fetch attempt that produced an outcome, whichever it was.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $syncedAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `type:uid` of the record this page is the detail page of, in the same language.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $mergedInto = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $indexed = false;
|
||||||
|
|
||||||
|
public function __construct(string $type, int $uid, string $language, \DateTimeImmutable $modified)
|
||||||
|
{
|
||||||
|
$this->type = $type;
|
||||||
|
$this->uid = $uid;
|
||||||
|
$this->language = $language;
|
||||||
|
$this->modified = $modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function key(string $type, int $uid, string $language): string
|
||||||
|
{
|
||||||
|
return $type.':'.$uid.':'.$language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKey(): string
|
||||||
|
{
|
||||||
|
return self::key($this->type, $this->uid, $this->language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `type:uid` a merged page refers to its record by.
|
||||||
|
*/
|
||||||
|
public function getRecordRef(): string
|
||||||
|
{
|
||||||
|
return $this->type.':'.$this->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPage(): bool
|
||||||
|
{
|
||||||
|
return self::TYPE_PAGE === $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStale(): bool
|
||||||
|
{
|
||||||
|
return null === $this->fetchedModified || $this->fetchedModified != $this->modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUid(): int
|
||||||
|
{
|
||||||
|
return $this->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLanguage(): string
|
||||||
|
{
|
||||||
|
return $this->language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSite(): ?string
|
||||||
|
{
|
||||||
|
return $this->site;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSite(?string $site): self
|
||||||
|
{
|
||||||
|
$this->site = $site;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUrl(?string $url): self
|
||||||
|
{
|
||||||
|
$this->url = $url;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(string $title): self
|
||||||
|
{
|
||||||
|
$this->title = mb_substr($title, 0, 255);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBreadcrumb(): ?string
|
||||||
|
{
|
||||||
|
return $this->breadcrumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBreadcrumb(?string $breadcrumb): self
|
||||||
|
{
|
||||||
|
$this->breadcrumb = null === $breadcrumb ? null : mb_substr($breadcrumb, 0, 512);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBodyMd(): string
|
||||||
|
{
|
||||||
|
return $this->bodyMd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBodyMd(string $bodyMd): self
|
||||||
|
{
|
||||||
|
$this->bodyMd = $bodyMd;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function getData(): ?array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $data
|
||||||
|
*/
|
||||||
|
public function setData(?array $data): self
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModified(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setModified(\DateTimeImmutable $modified): self
|
||||||
|
{
|
||||||
|
// Compared by value: assigning an equal instance would still schedule an update.
|
||||||
|
if ($modified != $this->modified) {
|
||||||
|
$this->modified = $modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFetchedModified(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->fetchedModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFetchState(): ContentFetchState
|
||||||
|
{
|
||||||
|
return $this->fetchState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSyncedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->syncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the outcome of a fetch against the `modified` it was made for.
|
||||||
|
*/
|
||||||
|
public function markFetched(ContentFetchState $state, \DateTimeImmutable $at): self
|
||||||
|
{
|
||||||
|
$this->fetchState = $state;
|
||||||
|
$this->fetchedModified = $this->modified;
|
||||||
|
$this->syncedAt = $at;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMergedInto(): ?string
|
||||||
|
{
|
||||||
|
return $this->mergedInto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMergedInto(?string $mergedInto): self
|
||||||
|
{
|
||||||
|
$this->mergedInto = $mergedInto;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isIndexed(): bool
|
||||||
|
{
|
||||||
|
return $this->indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateIndexed(): void
|
||||||
|
{
|
||||||
|
$this->indexed = ContentFetchState::Ok === $this->fetchState
|
||||||
|
&& '' !== $this->bodyMd
|
||||||
|
&& null === $this->mergedInto
|
||||||
|
&& !$this->isHiddenFromSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TYPO3's own "not in the website search" flag, on products.
|
||||||
|
*/
|
||||||
|
public function isHiddenFromSearch(): bool
|
||||||
|
{
|
||||||
|
return 'product' === $this->type && false === ($this->data['searchable'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping for the content feed sync. A single row.
|
||||||
|
*
|
||||||
|
* `manifestGeneratedAt` is the freshness figure: the `generatedAt` of the last manifest
|
||||||
|
* that was accepted. A run that stops early still advances it, because the deletions and
|
||||||
|
* the stale marks it is based on were applied.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: ContentSyncStateRepository::class)]
|
||||||
|
#[ORM\Table(name: 'content_sync_state')]
|
||||||
|
class ContentSyncState
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $manifestGeneratedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $lastRunAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the last run ended, in a few words. Null after a run that completed.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $lastError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from `Retry-After` when TYPO3 switched the feed off. Runs before it do nothing.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $pausedUntil = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getManifestGeneratedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->manifestGeneratedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setManifestGeneratedAt(?\DateTimeImmutable $manifestGeneratedAt): self
|
||||||
|
{
|
||||||
|
$this->manifestGeneratedAt = $manifestGeneratedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastRunAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->lastRunAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastRunAt(?\DateTimeImmutable $lastRunAt): self
|
||||||
|
{
|
||||||
|
$this->lastRunAt = $lastRunAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastError(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastError(?string $lastError): self
|
||||||
|
{
|
||||||
|
$this->lastError = $lastError;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPausedUntil(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->pausedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPausedUntil(?\DateTimeImmutable $pausedUntil): self
|
||||||
|
{
|
||||||
|
$this->pausedUntil = $pausedUntil;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity\Groups;
|
||||||
|
|
||||||
|
use App\Repository\Groups\CalendarHotelRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local copy of the last calendar hotel list TYPO3 answered successfully.
|
||||||
|
*
|
||||||
|
* Kept so the contingent sync still covers TYPO3's hotels while TYPO3 is unreachable. Written
|
||||||
|
* only by {@see \App\Service\ContingentHotelDirectory::refresh()}, never by hand: the list is
|
||||||
|
* TYPO3's, this is not a third place to maintain hotel codes in.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: CalendarHotelRepository::class)]
|
||||||
|
#[ORM\Table(name: 'calendar_hotel')]
|
||||||
|
class CalendarHotel
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, unique: true)]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): self
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user