Compare commits
44
Commits
6d2db1b258
..
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 |
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
|
||||
composer_version: "2"
|
||||
web_environment: [ ]
|
||||
nodejs_version: "22"
|
||||
nodejs_version: "24"
|
||||
corepack_enable: false
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
|
||||
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.
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||
@@ -98,15 +101,6 @@ APP_TRAVEL_PREFER_REMOTE=false
|
||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||
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
|
||||
# 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.
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ function handleHistoryRestore() {
|
||||
function handleTimeout() {
|
||||
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
|
||||
// and handles the counter. This handler only shows the alert.
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuche es erneut. Falls das Problem weiterhin besteht, kontaktiere bitte unseren Support.')
|
||||
}
|
||||
|
||||
function handleBeforeHistorySave() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@import "components/button.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/gallery.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/price_calendar.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@import "components/rte-content.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/gallery.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/price_calendar.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/typography.css";
|
||||
@import "./components/rte-content.css";
|
||||
@@ -1,10 +1,10 @@
|
||||
@import "components/autocomplete.css";
|
||||
@import "components/badge.css";
|
||||
@import "components/button.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/datepicker.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@import "./components/autocomplete.css";
|
||||
@import "./components/badge.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/datatable.css";
|
||||
@import "./components/datepicker.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.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
|
||||
scrollbar appears (e.g. when a collapsible section is opened). */
|
||||
.scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
/* Reserve the scrollbar gutter so content does not shift width when a
|
||||
scrollbar appears (e.g. when a collapsible section is opened). */
|
||||
@utility scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components_admin.css";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components_admin.css" layer(components);
|
||||
@@ -1,8 +1,5 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components.css";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@import "_utilities.css";
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components.css" layer(components);
|
||||
@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. */
|
||||
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
|
||||
@apply uppercase text-center text-sm/6 md:text-base/6;
|
||||
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
||||
@apply transition-colors outline-hidden focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
||||
@apply rounded-md;
|
||||
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
|
||||
min-height: calc(1lh + 2 * theme('spacing.2') + 2px);
|
||||
min-height: calc(1lh + 2 * --spacing(2) + 2px);
|
||||
}
|
||||
|
||||
.button--small {
|
||||
@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 {
|
||||
|
||||
@@ -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:hover {
|
||||
background-color: theme('colors.secondary');
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.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 {
|
||||
@@ -11,11 +11,11 @@
|
||||
}
|
||||
|
||||
.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"] {
|
||||
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"] {
|
||||
@@ -33,7 +33,7 @@
|
||||
.pc-day.pc-day--pending-start,
|
||||
.pc-day.pc-day--selected-start,
|
||||
.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 {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
@import "toastify-js/src/toastify.css";
|
||||
|
||||
.toastify {
|
||||
@apply shadow-lg rounded;
|
||||
@apply shadow-lg rounded-sm;
|
||||
}
|
||||
|
||||
.toastify--success {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.emerald.600');
|
||||
background: var(--color-emerald-600);
|
||||
}
|
||||
|
||||
.toastify--warning {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.red.600');
|
||||
background: var(--color-red-600);
|
||||
}
|
||||
|
||||
.toastify--error {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.red.700');
|
||||
background: var(--color-red-700);
|
||||
}
|
||||
|
||||
.toastify--info {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.primary');
|
||||
background: var(--color-primary);
|
||||
}
|
||||
@@ -1,71 +1,71 @@
|
||||
@import "tippy.js/dist/tippy.css";
|
||||
|
||||
.tippy-box {
|
||||
background-color: theme('colors.primary');
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.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 {
|
||||
border-bottom-color: theme('colors.primary');
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.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 {
|
||||
border-right-color: theme('colors.primary');
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sbw {
|
||||
.tippy-box {
|
||||
background-color: theme('colors.sbw.secondary');
|
||||
background-color: var(--color-sbw-secondary);
|
||||
}
|
||||
.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 {
|
||||
border-bottom-color: theme('colors.sbw.secondary');
|
||||
border-bottom-color: var(--color-sbw-secondary);
|
||||
}
|
||||
.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 {
|
||||
border-right-color: theme('colors.sbw.secondary');
|
||||
border-right-color: var(--color-sbw-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.snz {
|
||||
.tippy-box {
|
||||
background-color: theme('colors.snz.primary-dark');
|
||||
background-color: var(--color-snz-primary-dark);
|
||||
}
|
||||
.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 {
|
||||
border-bottom-color: theme('colors.snz.primary-dark');
|
||||
border-bottom-color: var(--color-snz-primary-dark);
|
||||
}
|
||||
.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 {
|
||||
border-right-color: theme('colors.snz.primary-dark');
|
||||
border-right-color: var(--color-snz-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.ser {
|
||||
.tippy-box {
|
||||
background-color: theme('colors.ser.secondary');
|
||||
background-color: var(--color-ser-secondary);
|
||||
}
|
||||
.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 {
|
||||
border-bottom-color: theme('colors.ser.secondary');
|
||||
border-bottom-color: var(--color-ser-secondary);
|
||||
}
|
||||
.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 {
|
||||
border-right-color: theme('colors.ser.secondary');
|
||||
border-right-color: var(--color-ser-secondary);
|
||||
}
|
||||
}
|
||||
Generated
+1016
-664
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,10 @@ zenstruck_schedule:
|
||||
frequency: "45 1 * * *"
|
||||
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
|
||||
frequency: "0 * * * *"
|
||||
description: "Removes expired oauth2 tokens hourly"
|
||||
|
||||
+14
-4
@@ -201,10 +201,6 @@ services:
|
||||
arguments:
|
||||
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
||||
|
||||
App\BusProNet\XmlParser\TravelParser:
|
||||
arguments:
|
||||
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
|
||||
|
||||
App\Service\TravelDataProvider:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.core'
|
||||
@@ -445,6 +441,20 @@ services:
|
||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
$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:
|
||||
arguments:
|
||||
$options:
|
||||
|
||||
@@ -5,7 +5,7 @@ parameters:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
myswb.ddev.site:
|
||||
mysbw.ddev.site:
|
||||
theme: sbw
|
||||
gtm_id: GTM-MS2C8CH
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
|
||||
|
||||
+2
-2
@@ -40,6 +40,7 @@ $rsyncOptions = [
|
||||
'.env.dev.local.php',
|
||||
'.env.test',
|
||||
'.env.test.local',
|
||||
'.browserslistrc',
|
||||
'.gitignore',
|
||||
'.nvmrc',
|
||||
'.php-cs-fixer.cache',
|
||||
@@ -58,8 +59,7 @@ $rsyncOptions = [
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'ray.php',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'webpack.config.mjs',
|
||||
'bin/.phpunit',
|
||||
'temp/',
|
||||
'tests/',
|
||||
|
||||
@@ -507,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.
|
||||
|
||||
#### `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)
|
||||
|
||||
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`.
|
||||
2. Deploy; `database:migrate` creates the empty 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.
|
||||
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
|
||||
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 tables.
|
||||
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 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.
|
||||
|
||||
**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
|
||||
|
||||
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`.
|
||||
|
||||
**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
|
||||
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
|
||||
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
|
||||
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.
|
||||
real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. 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
|
||||
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()`) |
|
||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||
| `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()` |
|
||||
| `teilnehmerliste` | 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);
|
||||
```
|
||||
|
||||
**V 2026-09-28 — the XML export can omit the return-leg price.** Since one price row is
|
||||
surfaced twice, an asymmetric pair is an export defect: on products 701 and 2435 a
|
||||
`<zustieg_rueck>` carries no `preis` although the engine still bills the return
|
||||
`Abfahrtsortzuschlag`, so reading the export verbatim undercharges by one leg. Measured over
|
||||
all exports, a stated return price never differs from its outbound counterpart (510 pairs,
|
||||
zero counter-examples). `TravelParser` therefore levels a boarding point's price across both
|
||||
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.
|
||||
**V 2026-10-02 — an unpriced return leg is free.** The export can state a `preis` on the
|
||||
outbound `<zustieg>` but none on the matching `<zustieg_rueck>` (seen on products 701 and
|
||||
2435). The booking engine bills a return `Abfahrtsortzuschlag` only where the return entry
|
||||
states a price, so the export is read verbatim, per leg. A missing return price is a catalog
|
||||
omission for the office to fix, not something to compensate locally: pricing the return leg
|
||||
from the outbound one makes the booking inquiry fail with a price mismatch.
|
||||
|
||||
**N — the departure surcharge is computed.** Booking responses carry a position
|
||||
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
|
||||
|
||||
@@ -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",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/core": "^8.0.7",
|
||||
"@babel/preset-env": "^8.0.7",
|
||||
"@hotwired/stimulus": "^3.0.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/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"core-js": "^3.38.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"babel-plugin-polyfill-corejs3": "^1.0.0",
|
||||
"core-js": "^3.51.0",
|
||||
"cypress": "^15.5.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"htmx.org": "^1.9.12",
|
||||
"htmx.org": "^2.0.11",
|
||||
"lightningcss": "^1.33.0",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"toastify-js": "^1.12.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^6.0.1"
|
||||
"webpack-cli": "^7.2.3"
|
||||
},
|
||||
"license": "WTFPL",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev-server": "encore dev-server",
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress",
|
||||
@@ -37,5 +35,9 @@
|
||||
"sortablejs": "^1.15.7",
|
||||
"stimulus-use": "^0.52.3",
|
||||
"vanilla-calendar-pro": "^3.1.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"cypress": true,
|
||||
"core-js": false
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
readonly class TravelMailingResponse
|
||||
{
|
||||
/**
|
||||
@@ -12,6 +14,7 @@ readonly class TravelMailingResponse
|
||||
public function __construct(
|
||||
public string $travelCode,
|
||||
public array $data,
|
||||
public ?BusLeg $busLeg = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ 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
|
||||
{
|
||||
@@ -14,12 +16,30 @@ class TravelsClient extends AbstractApiClient
|
||||
/**
|
||||
* 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): TravelMailingResponse
|
||||
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
|
||||
{
|
||||
$payload = $this->request(self::MAILING_PATH, [
|
||||
'travelCode' => $travelCode,
|
||||
]);
|
||||
$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(
|
||||
@@ -30,6 +50,6 @@ class TravelsClient extends AbstractApiClient
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients);
|
||||
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
|
||||
* - 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>)
|
||||
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not
|
||||
* the first participant, so copying applicant data into participant[0] would be incorrect
|
||||
* - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
|
||||
* 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
|
||||
{
|
||||
@@ -74,32 +75,6 @@ class BookingDataProcessor
|
||||
$participantData = ParticipantDto::fromPersonalData($participant);
|
||||
$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
|
||||
$participantData->courses = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
|
||||
@@ -178,14 +153,57 @@ class BookingDataProcessor
|
||||
$roomSelection->price = $room->price;
|
||||
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
|
||||
$roomSelection->capacity = $room->minPax ?? 1;
|
||||
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
|
||||
$dto->roomSelections[] = $roomSelection;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -322,6 +340,7 @@ class BookingDataProcessor
|
||||
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
|
||||
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
|
||||
*/
|
||||
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 = [
|
||||
'buchungsart' => $bookingType,
|
||||
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
|
||||
'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
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
|
||||
|
||||
// Determine if this is an inquiry booking for voucher handling
|
||||
$isInquiryBooking = 'A' === $bookingDto->bookingStatus;
|
||||
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
|
||||
// Add participants
|
||||
$payload['teilnehmerliste']['teilnehmer'] = [];
|
||||
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
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
|
||||
if (null !== $participant->dateOfBirth) {
|
||||
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
] + $this->buildPersonalDataPayload($participant, true);
|
||||
|
||||
// Add body dimensions
|
||||
if (null !== $participant->height) {
|
||||
@@ -434,6 +383,57 @@ class BookingPayloadBuilder
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
|
||||
* Processes participant service selections for booking updates.
|
||||
*
|
||||
* Handles the assignment and mapping of services (additional, transportation,
|
||||
* pickup, room, insurance) from participant DTOs to the booking model during
|
||||
* the update request workflow.
|
||||
* pickup, insurance) from participant DTOs to the booking model during the
|
||||
* 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
|
||||
{
|
||||
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
|
||||
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
|
||||
* Processes all services for a single 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 Booking $bookingData The booking data object to update
|
||||
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
|
||||
$this->processTransportationServices($participant, $bookingData, $travelData);
|
||||
$this->processPickupLocations($participant, $bookingData);
|
||||
$this->processDropOffLocations($participant, $bookingData);
|
||||
$this->processRoomAssignment($participant, $bookingData);
|
||||
$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
|
||||
* booking edits while maintaining the constraint that participants can only be
|
||||
* assigned to room types that have already been booked.
|
||||
* Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
|
||||
* them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
|
||||
* 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) {
|
||||
if ($room->id === $participant->assignedRoomId) {
|
||||
$room->mapping[] = $participant->index;
|
||||
break;
|
||||
}
|
||||
$room->mapping = array_values(array_filter(
|
||||
$room->mapping,
|
||||
fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
@@ -15,17 +16,47 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
class Communication
|
||||
{
|
||||
#[Assert\When(
|
||||
expression: 'this.isPhoneNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['personal_data'],
|
||||
)]
|
||||
public ?string $phone = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\When(
|
||||
expression: 'this.isMobileNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['personal_data'],
|
||||
)]
|
||||
public ?string $mobile = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])]
|
||||
// The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
|
||||
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 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.
|
||||
*
|
||||
|
||||
@@ -23,10 +23,12 @@ class PersonalData
|
||||
public bool $mutable = false;
|
||||
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;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
|
||||
public ?string $firstName = '';
|
||||
|
||||
public ?string $salutation = null;
|
||||
@@ -53,7 +55,7 @@ class PersonalData
|
||||
#[Assert\Valid(groups: ['personal_data'])]
|
||||
public Address $address;
|
||||
|
||||
#[Assert\Valid(groups: ['personal_data'])]
|
||||
#[Assert\Valid(groups: ['personal_data', 'personal_identity'])]
|
||||
public Communication $communication;
|
||||
|
||||
/** @var list<string> */
|
||||
|
||||
@@ -20,6 +20,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
class Travel
|
||||
{
|
||||
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'])]
|
||||
public ?int $id = null;
|
||||
|
||||
@@ -240,6 +246,15 @@ class Travel
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Utility\XmlCharacterFilter;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Service\XmlDumpWriter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -208,8 +209,18 @@ class XmlChannel
|
||||
*/
|
||||
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
|
||||
{
|
||||
// The dump keeps the raw response; only the parser sees the filtered one.
|
||||
$filtered = XmlCharacterFilter::strip($xml, $removed);
|
||||
if ($removed > 0) {
|
||||
$this->logger->warning('Removed characters not allowed in XML from API response', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type->value,
|
||||
'removed' => $removed,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
|
||||
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
|
||||
} catch (ResponseParserException $e) {
|
||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||
|
||||
@@ -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];
|
||||
$service->available = $availability->available;
|
||||
|
||||
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||
// response actually carries one, so a response that omits the attribute does not wipe
|
||||
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||
// The live status is fresher than the nightly export's. The parser has already mapped it
|
||||
// onto the export vocabulary. Only overwrite when the response actually carries one, so a
|
||||
// response that omits the attribute does not wipe the value parsed from the XML export.
|
||||
// Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||
$service->status = $availability->status;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Traits\TypeConversionTrait;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
@@ -9,6 +10,19 @@ abstract class AbstractParser
|
||||
{
|
||||
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
|
||||
{
|
||||
return 0 < $node->count() ? $node->text() : null;
|
||||
@@ -87,4 +101,16 @@ abstract class AbstractParser
|
||||
|
||||
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) {
|
||||
$availability = new Availability();
|
||||
$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->price = $this->stringToFloat($node->attr('preis'));
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ExtendedAvailabilitiesParser extends AbstractParser
|
||||
|
||||
$availability = new ExtendedAvailability();
|
||||
$availability->serviceId = $serviceId;
|
||||
$availability->status = $node->attr('status');
|
||||
$availability->status = $this->toServiceStatus($node->attr('status'));
|
||||
$availability->available = (int) $node->attr('frei');
|
||||
$availability->price = $this->stringToFloat($node->attr('preis'));
|
||||
$availability->dateFrom = $this->stringToDate($node->attr('termin'));
|
||||
|
||||
@@ -50,6 +50,7 @@ class PersonalDataParser extends AbstractParser
|
||||
$communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil'));
|
||||
$communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email'));
|
||||
$communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter'));
|
||||
$communication->rememberLoadedNumbers();
|
||||
|
||||
$personalData->communication = $communication;
|
||||
}
|
||||
|
||||
@@ -29,20 +29,8 @@ class TravelParser extends AbstractParser
|
||||
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(
|
||||
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->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);
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use Carbon\CarbonImmutable;
|
||||
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
|
||||
* 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
|
||||
{
|
||||
use LockableTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly ContingentDayRepository $dayRepository,
|
||||
private readonly ContingentSnapshotManager $snapshotManager,
|
||||
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
|
||||
{
|
||||
$this
|
||||
->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('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
|
||||
->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 a hotel is eligible (ignored with --force)', '0')
|
||||
->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();
|
||||
$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) {
|
||||
$io->warning('No accommodation with a calendarCode matched.');
|
||||
if ([] === $hotelCodes) {
|
||||
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
|
||||
$changed = 0;
|
||||
$failedCodes = [];
|
||||
|
||||
foreach ($accommodations as $accommodation) {
|
||||
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
|
||||
foreach ($hotelCodes as $code) {
|
||||
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
|
||||
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
|
||||
++$processed;
|
||||
|
||||
if (!$result->successful) {
|
||||
$failedCodes[] = (string) $accommodation->getCalendarCode();
|
||||
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
|
||||
$failedCodes[] = $code;
|
||||
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
|
||||
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||
(string) $accommodation->getCalendarCode(),
|
||||
$code,
|
||||
$result->changed ? 'changed' : 'unchanged',
|
||||
$result->added,
|
||||
$result->updated,
|
||||
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
|
||||
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
||||
$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
|
||||
// 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
|
||||
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
|
||||
{
|
||||
if (0 === $staleAfterMinutes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -158,9 +158,16 @@ class PersonalDataController extends AbstractController
|
||||
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', [
|
||||
'personalData' => $personalData,
|
||||
'personalDataForm' => $personalDataForm,
|
||||
'invalidIdentityFields' => $invalidIdentityFields,
|
||||
'newsletterSubscribed' => $newsletterSubscribed,
|
||||
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ 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;
|
||||
@@ -22,6 +24,7 @@ class IndexController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly TravelsClient $travelsClient,
|
||||
private readonly TravelDataProvider $travelDataProvider,
|
||||
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,6 +39,10 @@ class IndexController extends AbstractController
|
||||
|
||||
$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"
|
||||
@@ -43,8 +50,17 @@ class IndexController extends AbstractController
|
||||
$travelCode = (new DateCodeUtility())->sanitize((string) $lookup->travelCode);
|
||||
|
||||
try {
|
||||
$result = $this->travelsClient->getMailingRecipients($travelCode);
|
||||
$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;
|
||||
@@ -55,6 +71,10 @@ class IndexController extends AbstractController
|
||||
'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\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotReader;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -33,6 +35,8 @@ class ContingentController extends AbstractController
|
||||
private readonly ContingentSnapshotReader $snapshotReader,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
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
|
||||
// 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.
|
||||
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
|
||||
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
if (null === $statuses) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 array<string, true> $covered
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\HotelNotInTravelException;
|
||||
use App\Exception\TravelNotFoundException;
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
|
||||
/**
|
||||
* Handles the second step of the booking creation process using card-based UI.
|
||||
@@ -80,14 +83,32 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
|
||||
$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();
|
||||
if ($user instanceof User
|
||||
&& isset($bookingCreateDto->participants[0])
|
||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->participants[0]
|
||||
);
|
||||
if ($user instanceof User) {
|
||||
if (false === $bookingCreateDto->applicantTravels) {
|
||||
$bookingCreateDto->applicant ??= new ParticipantDto();
|
||||
|
||||
if ($this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->applicant)) {
|
||||
$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)
|
||||
@@ -122,6 +143,10 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
$templateData = [
|
||||
'form' => $form->createView(),
|
||||
'bookingCreateContext' => $context,
|
||||
'applicantErrors' => $this->collectApplicantErrors($form),
|
||||
'applicantTravelsForm' => $this
|
||||
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
|
||||
->createView(),
|
||||
];
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingApplicantType;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
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 array<array{type: string, message: string}> $notifications
|
||||
|
||||
@@ -240,7 +240,7 @@ class Step1Controller extends AbstractController
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
$contingentStatus = null !== $accommodation
|
||||
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
|
||||
? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? []
|
||||
: [];
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
/**
|
||||
* Local snapshot of a single day of contingent availability as reported by bpn-connect.
|
||||
*
|
||||
* Keyed by hotel code, not by accommodation: the snapshot also covers TYPO3's calendar hotels,
|
||||
* which have no `Accommodation`.
|
||||
*
|
||||
* Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ContingentDayRepository::class)]
|
||||
#[ORM\Table(name: 'contingent_day')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_hotel_code_date', columns: ['hotel_code', 'date'])]
|
||||
class ContingentDay
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -24,8 +27,15 @@ class ContingentDay
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode and no longer written. Only rows from before the switch still
|
||||
* carry it; it stays mapped until the column is dropped, so the switch can be rolled back.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
@@ -39,6 +49,18 @@ class ContingentDay
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function setHotelCode(string $hotelCode): self
|
||||
{
|
||||
$this->hotelCode = $hotelCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
||||
* Bookkeeping for the scheduled contingent sync of a single hotel code.
|
||||
*
|
||||
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||
@@ -23,8 +23,14 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode, see {@see ContingentDay::$accommodation}.
|
||||
*/
|
||||
#[ORM\OneToOne]
|
||||
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, unique: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
/**
|
||||
@@ -48,9 +54,9 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private int $failureCount = 0;
|
||||
|
||||
public function __construct(?Accommodation $accommodation = null)
|
||||
public function __construct(?string $hotelCode = null)
|
||||
{
|
||||
$this->accommodation = $accommodation;
|
||||
$this->hotelCode = $hotelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -58,6 +64,11 @@ class ContingentSyncState
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A Mailjet contact list created for one travel's participants. The row exists so the list can
|
||||
* be deleted again once the travel is past; the list itself is never updated after creation.
|
||||
*
|
||||
* A travel can have two lists, one for all participants and one for bus passengers only, told
|
||||
* apart by their name.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TravelMailingListRepository::class)]
|
||||
#[ORM\Index(columns: ['travel_code'])]
|
||||
class TravelMailingList
|
||||
{
|
||||
private const string BUS_ONLY_SUFFIX = ' nur Bus';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $travelCode;
|
||||
|
||||
/** The Mailjet list name. */
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $mailjetListId;
|
||||
|
||||
/** The bus leg its participants were filtered by; null when it holds all participants. */
|
||||
#[ORM\Column(length: 20, nullable: true, enumType: BusLeg::class)]
|
||||
private ?BusLeg $busLeg;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $deleteAfter;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(string $travelCode, int $mailjetListId, ?BusLeg $busLeg, \DateTimeImmutable $deleteAfter)
|
||||
{
|
||||
$this->travelCode = $travelCode;
|
||||
$this->name = self::nameFor($travelCode, $busLeg);
|
||||
$this->mailjetListId = $mailjetListId;
|
||||
$this->busLeg = $busLeg;
|
||||
$this->deleteAfter = $deleteAfter;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public static function nameFor(string $travelCode, ?BusLeg $busLeg): string
|
||||
{
|
||||
return null !== $busLeg ? $travelCode.self::BUS_ONLY_SUFFIX : $travelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTravelCode(): string
|
||||
{
|
||||
return $this->travelCode;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getMailjetListId(): int
|
||||
{
|
||||
return $this->mailjetListId;
|
||||
}
|
||||
|
||||
public function getBusLeg(): ?BusLeg
|
||||
{
|
||||
return $this->busLeg;
|
||||
}
|
||||
|
||||
public function getDeleteAfter(): \DateTimeImmutable
|
||||
{
|
||||
return $this->deleteAfter;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Which bus leg a travel participant must have booked to be selected, as bpn-connect's travel
|
||||
* mailing endpoint takes it (`busLeg`).
|
||||
*/
|
||||
enum BusLeg: string
|
||||
{
|
||||
case Outbound = 'outbound';
|
||||
case Inbound = 'inbound';
|
||||
case Any = 'any';
|
||||
case Both = 'both';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Outbound => 'Busreisende (Hinfahrt)',
|
||||
self::Inbound => 'Busreisende (Rückfahrt)',
|
||||
self::Any => 'Busreisende (Hin- oder Rückfahrt)',
|
||||
self::Both => 'Busreisende (Hin- und Rückfahrt)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Where a hotel code in the contingent snapshot comes from. A code can have both.
|
||||
*/
|
||||
enum ContingentHotelSource: string
|
||||
{
|
||||
/** The calendarCode of a group house (`Accommodation`). */
|
||||
case Accommodation = 'accommodation';
|
||||
|
||||
/** The calendar hotel of a visible TYPO3 product, from TYPO3's `/api/calendar-hotels`. */
|
||||
case Typo3 = 'typo3';
|
||||
}
|
||||
@@ -6,13 +6,14 @@ namespace App\Form\Admin;
|
||||
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
|
||||
* and can be bookmarked or shared.
|
||||
* (plus `&busOnly=1`) and can be bookmarked or shared.
|
||||
*
|
||||
* @extends AbstractType<TravelMailingLookupDto>
|
||||
*/
|
||||
@@ -26,6 +27,10 @@ class TravelMailingLookupType extends AbstractType
|
||||
'placeholder' => 'z. B. LPJMP281126',
|
||||
],
|
||||
]);
|
||||
$builder->add('busOnly', CheckboxType::class, [
|
||||
'label' => 'nur '.TravelMailingLookupDto::BUS_LEG->label(),
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* The booking-level choice "do I travel along myself?".
|
||||
*
|
||||
* Rendered inside the step 2 form, so the template emits the widget alone and never a second
|
||||
* <form> tag; htmx posts the enclosed inputs, the CSRF token among them, to the toggle route.
|
||||
*
|
||||
* Validation is switched off: this form only carries the choice, and running the step 2 groups
|
||||
* here would report every gap in the participant data the moment somebody flips the radio.
|
||||
*
|
||||
* @extends AbstractType<BookingDto>
|
||||
*/
|
||||
class BookingApplicantTravelsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('applicantTravels', ChoiceType::class, [
|
||||
'label' => 'Wer reist mit?',
|
||||
'choices' => [
|
||||
'Ich reise selbst mit' => true,
|
||||
// Note the literal non-breaking space in "z. B." - it keeps the abbreviation
|
||||
// from wrapping, and replacing it with a plain space is a silent regression.
|
||||
'Ich buche für andere (z. B. mein Kind)' => false,
|
||||
],
|
||||
'expanded' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingDto::class,
|
||||
'validation_groups' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Form\CountryType;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Contact data of an applicant who does not travel along.
|
||||
*
|
||||
* Bound to ParticipantDto rather than a contact DTO of its own: the fields are the same
|
||||
* personal-data set, and a second near-identical type would only have to be kept in step with
|
||||
* this one. Only shown to guests; a logged-in applicant's data comes from their account and is
|
||||
* rendered as static text, because editing it here would create a duplicate BusPro record.
|
||||
*
|
||||
* Validation runs in the dedicated 'booking_applicant' group, which BookingDto::validateApplicant()
|
||||
* owns, so the applicant is checked independently of the participant cards.
|
||||
*
|
||||
* @extends AbstractType<ParticipantDto>
|
||||
*/
|
||||
class BookingApplicantType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('firstName', TextType::class, [
|
||||
'label' => 'Vorname',
|
||||
'required' => true,
|
||||
])
|
||||
->add('lastName', TextType::class, [
|
||||
'label' => 'Nachname',
|
||||
'required' => true,
|
||||
])
|
||||
->add('gender', ChoiceType::class, [
|
||||
'label' => 'Gender',
|
||||
'required' => true,
|
||||
'placeholder' => false,
|
||||
'choices' => [
|
||||
'männlich' => 'M',
|
||||
'weiblich' => 'W',
|
||||
'divers' => 'D',
|
||||
],
|
||||
])
|
||||
->add('nationality', CountryType::class, [
|
||||
'label' => 'Nationalität',
|
||||
'required' => true,
|
||||
'property' => 'nationality',
|
||||
'placeholder' => 'Bitte auswählen...',
|
||||
'preferred_choices' => ['D', 'A', 'CH'],
|
||||
])
|
||||
->add('dateOfBirth', BirthdayType::class, [
|
||||
'label' => 'Geburtsdatum',
|
||||
'required' => true,
|
||||
'widget' => 'text',
|
||||
'input' => 'datetime_immutable',
|
||||
'html5' => false,
|
||||
])
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'E-Mail',
|
||||
'required' => true,
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
],
|
||||
])
|
||||
->add('mobile', TextType::class, [
|
||||
'label' => 'Telefon (mobil)',
|
||||
'required' => true,
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
],
|
||||
])
|
||||
->add('address', AddressType::class, [
|
||||
'label' => 'Adresse',
|
||||
'required' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ParticipantDto::class,
|
||||
'validation_groups' => ['booking_applicant', 'booking_create'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,9 @@ class BookingCreateStep2Type extends AbstractType
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingDto::class,
|
||||
'validation_groups' => function (FormInterface $form) {
|
||||
// Create mode: always strict validation
|
||||
return ['strict_required', 'booking_create'];
|
||||
// Create mode: always strict validation. 'booking_applicant' additionally
|
||||
// requires a complete applicant when they do not travel along.
|
||||
return ['strict_required', 'booking_create', 'booking_applicant'];
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -222,7 +222,6 @@ class BookingParticipantType extends AbstractType
|
||||
$form->add('mobile', TextType::class, $this->mergeFieldState([
|
||||
'label' => 'Telefon (mobil)',
|
||||
'required' => false,
|
||||
'sanitize_html' => true,
|
||||
'property_path' => 'participant.mobile',
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
|
||||
@@ -58,11 +58,40 @@ class BookingDto
|
||||
*/
|
||||
public string $bookingStatus = 'F';
|
||||
|
||||
/**
|
||||
* Booking status the travel itself calls for when the booking starts, before any room is
|
||||
* selected and before the create-flow status rules run. Step 1 falls back to it whenever
|
||||
* no on-request room is selected, so the status can move back from 'A'.
|
||||
*
|
||||
* Null in sessions started before it was introduced.
|
||||
*/
|
||||
public ?string $initialBookingStatus = null;
|
||||
|
||||
/**
|
||||
* Reference to booking entity (only populated in edit mode).
|
||||
*/
|
||||
public ?Booking $booking = null;
|
||||
|
||||
/**
|
||||
* Whether the applicant travels along, i.e. is also participant[0].
|
||||
*
|
||||
* Create mode only: it is the booking-level choice behind "Ich reise selbst mit".
|
||||
* In edit mode the split is derived from the loaded booking instead, see
|
||||
* isFirstParticipantApplicant().
|
||||
*/
|
||||
public bool $applicantTravels = true;
|
||||
|
||||
/**
|
||||
* The applicant's own personal data, used only when they do not travel.
|
||||
*
|
||||
* Reuses ParticipantDto for the personal fields rather than introducing a second,
|
||||
* near-identical contact DTO. Its index stays null, which is what tells the shared
|
||||
* 'booking_applicant' rules apart from an ordinary traveller, and validateApplicant()
|
||||
* below drives its checks explicitly rather than by cascade, so the violations land
|
||||
* under 'applicant.' exactly once.
|
||||
*/
|
||||
public ?ParticipantDto $applicant = null;
|
||||
|
||||
/**
|
||||
* Fingerprint of the booking state when loaded from API (edit mode only).
|
||||
* This property stores the original state and is never updated after initial load.
|
||||
@@ -173,6 +202,105 @@ class BookingDto
|
||||
return $this->participants[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a split: the applicant takes the participant[0] slot back and travels again.
|
||||
*
|
||||
* The traveller who held the slot is dropped; only the room assignment carries over,
|
||||
* the mirror image of what the split left behind.
|
||||
*/
|
||||
public function restoreTravellingApplicant(): void
|
||||
{
|
||||
$restored = $this->applicant;
|
||||
|
||||
if (null !== $restored) {
|
||||
$restored->index = 0;
|
||||
$restored->assignedRoomId = ($this->participants[0] ?? null)?->assignedRoomId;
|
||||
$this->participants[0] = $restored;
|
||||
}
|
||||
|
||||
$this->applicant = null;
|
||||
$this->applicantTravels = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the participant at $index has to supply an email address.
|
||||
*
|
||||
* Everyone does except an under-16 who is not the applicant; BusPro itself accepts a blank
|
||||
* for any participant. Validation enforces this in two parts, the Assert\When on
|
||||
* ParticipantDto::$email (age) and validateFirstParticipantApplicant() (the applicant), and
|
||||
* this is the same rule in one place for the field state. A participant that does not exist
|
||||
* yet counts as needing one, matching the "unknown counts as an adult" stance of isChild().
|
||||
*/
|
||||
public function requiresParticipantEmail(int $index): bool
|
||||
{
|
||||
$participant = $this->getParticipant($index);
|
||||
|
||||
if (null === $participant || false === $participant->isChild()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 0 === $index && true === $this->isFirstParticipantApplicant();
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant's email address, wherever the applicant is held.
|
||||
*
|
||||
* - participant[0] where they are the applicant
|
||||
* - create, split: the separate applicant block
|
||||
* - edit, split: the loaded booking's anmelder, since $applicant is only filled in create mode
|
||||
*/
|
||||
public function getApplicantEmail(): ?string
|
||||
{
|
||||
if (true === $this->isFirstParticipantApplicant()) {
|
||||
$email = $this->getParticipant(0)?->email;
|
||||
} elseif (self::MODE_CREATE === $this->getMode()) {
|
||||
$email = $this->applicant?->email;
|
||||
} else {
|
||||
$email = $this->booking?->applicant?->communication->email;
|
||||
}
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether participant[0] is also the applicant.
|
||||
*
|
||||
* This is the single predicate for "the first participant IS the booking's contact".
|
||||
* It replaces the former practice of reading isInternalAgencyBooking() to mean the
|
||||
* same thing, which only held because the internal agency was the one case where
|
||||
* applicant and participant[0] differed.
|
||||
*
|
||||
* - create: the booking-level choice. The agency plays no part here: internal agency
|
||||
* bookings are made in the BusPro backend and only ever edited in this application.
|
||||
* - edit, internal agency: always false, their applicant is a customer who is not the
|
||||
* first traveller
|
||||
* - edit, otherwise: derived from the loaded booking. A null person id on either side
|
||||
* counts as "same", so incomplete BPN data keeps today's stricter, locked-down behaviour.
|
||||
*/
|
||||
public function isFirstParticipantApplicant(): bool
|
||||
{
|
||||
if (self::MODE_CREATE === $this->getMode()) {
|
||||
return $this->applicantTravels;
|
||||
}
|
||||
|
||||
if ($this->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$applicantPersonId = $this->booking?->applicant?->personId;
|
||||
$firstParticipantPersonId = $this->getParticipant(0)?->personId;
|
||||
|
||||
if (null === $applicantPersonId || null === $firstParticipantPersonId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $applicantPersonId === $firstParticipantPersonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this is a family booking based on participant age distribution.
|
||||
*
|
||||
@@ -182,6 +310,13 @@ class BookingDto
|
||||
*/
|
||||
public function isFamilyBooking(): bool
|
||||
{
|
||||
// Family insurance is not offered when the applicant does not travel. The age
|
||||
// distribution alone would not rule it out: an 18-year-old sibling travelling with a
|
||||
// younger child still satisfies the adult/child counts below.
|
||||
if (false === $this->applicantTravels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$adults = 0;
|
||||
$children = 0;
|
||||
|
||||
@@ -204,6 +339,47 @@ class BookingDto
|
||||
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete, adult applicant whenever the applicant does not travel.
|
||||
*
|
||||
* Delegates the field checks to ParticipantDto::validateApplicantCompleteness(), which the
|
||||
* applicant form runs directly through the 'booking_applicant' group.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['booking_applicant'])]
|
||||
public function validateApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (true === $this->applicantTravels) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->applicant) {
|
||||
$context->buildViolation('Bitte gib die Daten der anmeldenden Person an.')
|
||||
->atPath('applicant')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applicant->validateApplicantCompleteness($context, 'applicant.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete address and an email from participant[0] only where they are the applicant.
|
||||
*
|
||||
* BusPro asks neither of an ordinary participant, so on a split booking the traveller at index 0
|
||||
* is not asked for an address, and an under-16 there may leave the email blank.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateFirstParticipantApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (false === $this->isFirstParticipantApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->getParticipant(0)?->validateApplicantEmail($context, 'participants[0].');
|
||||
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateBankAccount(ExecutionContextInterface $context): void
|
||||
{
|
||||
|
||||
@@ -21,8 +21,9 @@ class ParticipantDto
|
||||
/**
|
||||
* Age below which a participant counts as a child.
|
||||
*
|
||||
* Children may share an email address with an adult and are not required to have one of
|
||||
* their own; both rules go through isChild(), so this is the only place the age lives.
|
||||
* Children may share an email address with an adult, and have the applicant's address
|
||||
* pre-filled for them; both rules go through isChild(), so this is the only place the
|
||||
* age lives.
|
||||
*/
|
||||
public const CHILD_AGE_THRESHOLD = 16;
|
||||
|
||||
@@ -79,18 +80,30 @@ class ParticipantDto
|
||||
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||
|
||||
// Requiredness is delegated to requiresOwnEmail() so the constraint and the field state
|
||||
// conditions cannot state the rule differently.
|
||||
// BusPro accepts a participant without an email address, so this is the only gate. Under-16s
|
||||
// may leave it blank, everyone else needs one. An under-16 applicant needs one as well; that
|
||||
// case depends on the booking and is enforced by BookingDto::validateFirstParticipantApplicant()
|
||||
// and ParticipantEditDto::validateApplicant(). It stays a property constraint so the missing-field
|
||||
// labels keep their order (see BookingEditPreFlightChecker).
|
||||
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
||||
#[Assert\When(
|
||||
expression: 'this.requiresOwnEmail()',
|
||||
expression: 'not this.isChild()',
|
||||
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
|
||||
groups: ['strict_required']
|
||||
groups: ['strict_required'],
|
||||
)]
|
||||
public ?string $email = null;
|
||||
|
||||
#[Assert\When(
|
||||
expression: 'this.isMobileNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['booking_edit', 'booking_create'],
|
||||
)]
|
||||
public ?string $mobile = null;
|
||||
|
||||
// The number as loaded from BusPro, which goes back unchanged and is exempt from PhoneNumber.
|
||||
// Public so it survives the session round trip through __unserialize().
|
||||
public ?string $loadedMobile = null;
|
||||
|
||||
#[Assert\Valid(groups: ['booking_edit', 'booking_create'])]
|
||||
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
|
||||
public ?Address $address = null;
|
||||
@@ -236,6 +249,7 @@ class ParticipantDto
|
||||
$instance->nationality = $personalData->nationality ?: 'D';
|
||||
$instance->email = $personalData->communication->email;
|
||||
$instance->mobile = $personalData->communication->mobile;
|
||||
$instance->loadedMobile = $personalData->communication->mobile;
|
||||
$instance->dateOfBirth = $personalData->dateOfBirth;
|
||||
$instance->height = $personalData->height;
|
||||
$instance->weight = $personalData->weight;
|
||||
@@ -250,6 +264,11 @@ class ParticipantDto
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function isMobileNewlyEntered(): bool
|
||||
{
|
||||
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
|
||||
}
|
||||
|
||||
public function isApplicant(): bool
|
||||
{
|
||||
return 0 === $this->index;
|
||||
@@ -369,22 +388,6 @@ class ParticipantDto
|
||||
*
|
||||
* @return bool True if participant is under the age threshold, false otherwise or if age unknown
|
||||
*/
|
||||
/**
|
||||
* Decides whether this participant has to supply an email address of their own.
|
||||
*
|
||||
* The applicant is the booking's contact and always has to be reachable. Everyone else only
|
||||
* needs an address once they are old enough to have one, so children are exempt. An unknown
|
||||
* date of birth counts as an adult, following isChild().
|
||||
*
|
||||
* Single source for the rule: the Assert\When on $email enforces it, and the 'required'
|
||||
* field state conditions in CreateFieldStateProvider/EditFieldStateProvider render the
|
||||
* matching mandatory marker.
|
||||
*/
|
||||
public function requiresOwnEmail(): bool
|
||||
{
|
||||
return $this->isApplicant() || false === $this->isChild();
|
||||
}
|
||||
|
||||
public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool
|
||||
{
|
||||
$age = $this->getAge();
|
||||
@@ -495,28 +498,95 @@ class ParticipantDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the participant has a complete address when required.
|
||||
* Validates that this person is usable as the booking's applicant.
|
||||
*
|
||||
* In regular create/edit submission flows this only applies to participant 0.
|
||||
* The edit overview preflight checks address fields separately for the applicant only.
|
||||
* Runs on its own in the 'booking_applicant' group, which only the applicant form activates,
|
||||
* the way 'applicant_address' already scopes the address NotNull above. BookingDto delegates
|
||||
* to the same body for the step-2 submit, so the rule has one home.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
||||
#[Assert\Callback(groups: ['booking_applicant'])]
|
||||
public function validateAsApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Only validate applicant's address in regular create/edit submission flows.
|
||||
if (0 !== $this->index) {
|
||||
// The applicant record is the only ParticipantDto without an index. Travellers carry one,
|
||||
// and BookingDto cascades the 'booking_applicant' group into $participants as well, so
|
||||
// without this guard every traveller would be held to the applicant's rules - an adult
|
||||
// address and a minimum age of 18.
|
||||
if (null !== $this->index) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateAddressFields($context);
|
||||
$this->validateApplicantCompleteness($context);
|
||||
}
|
||||
|
||||
private function validateAddressFields(ExecutionContextInterface $context): void
|
||||
/**
|
||||
* Shared body. $pathPrefix places the violations in the caller's object graph; it is not a
|
||||
* callback itself, because Symfony passes the constraint payload as the second argument.
|
||||
*/
|
||||
public function validateApplicantCompleteness(ExecutionContextInterface $context, string $pathPrefix = ''): void
|
||||
{
|
||||
foreach (['firstName', 'lastName', 'gender', 'nationality', 'email', 'mobile'] as $field) {
|
||||
$value = $this->$field;
|
||||
if (null === $value || '' === trim($value)) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath($pathPrefix.$field)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateAddressFields($context, $pathPrefix);
|
||||
|
||||
if (null === $this->dateOfBirth) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath($pathPrefix.'dateOfBirth')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$age = $this->getAge();
|
||||
if (null !== $age && 18 > $age) {
|
||||
$context->buildViolation('Die anmeldende Person muss volljährig sein.')
|
||||
->atPath($pathPrefix.'dateOfBirth')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that this person, as the booking's contact, has an email address.
|
||||
*
|
||||
* The property constraint on $email already holds everyone aged 16 or older (and anyone
|
||||
* without a date of birth) to it, so this only adds the under-16 contact and never reports
|
||||
* the same field twice. Like validateAddressFields(), the callers decide who the contact is.
|
||||
*/
|
||||
public function validateApplicantEmail(ExecutionContextInterface $context, string $pathPrefix = ''): void
|
||||
{
|
||||
if (false === $this->isChild()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $this->email && false === u($this->email)->trim()->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath($pathPrefix.'email')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that this person has a complete address.
|
||||
*
|
||||
* Only the booking's contact needs one: BusPro does not require an anschrift from an
|
||||
* ordinary participant (measured on 2036, an empty anschrift passes). Deciding who the
|
||||
* contact is needs the booking, so the callers own the gate and this method only ever
|
||||
* runs the field checks. $pathPrefix places the violations in the caller's object graph.
|
||||
*/
|
||||
public function validateAddressFields(ExecutionContextInterface $context, string $pathPrefix = ''): void
|
||||
{
|
||||
// Address object is required for the validated participant
|
||||
if (null === $this->address) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address')
|
||||
->atPath($pathPrefix.'address')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
@@ -525,25 +595,25 @@ class ParticipantDto
|
||||
// Validate address subfields
|
||||
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.street')
|
||||
->atPath($pathPrefix.'address.street')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.postCode')
|
||||
->atPath($pathPrefix.'address.postCode')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.city')
|
||||
->atPath($pathPrefix.'address.city')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.country')
|
||||
->atPath($pathPrefix.'address.country')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +92,37 @@ class ParticipantEditDto
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete address and an email from the participant form's applicant only.
|
||||
*
|
||||
* Mirrors BookingDto::validateFirstParticipantApplicant() for the per-participant form, which
|
||||
* binds this wrapper rather than the booking.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (false === $this->participant->isApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === $this->bookingContext->isFirstParticipantApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->participant->validateApplicantEmail($context, 'participant.');
|
||||
$this->participant->validateAddressFields($context, 'participant.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that non-applicant adult participants have unique email addresses.
|
||||
*
|
||||
* Exempt from validation:
|
||||
* - Applicant (index 0): Can share email with dependents they're booking for
|
||||
* - Children (under 16): Can share email addresses with adults
|
||||
* - The applicant: can share an email with dependents they're booking for
|
||||
* - Children (under 16): Can share email addresses with adults, or have none at all
|
||||
*
|
||||
* Where the applicant does not travel they are not among the participants, so their address
|
||||
* is checked separately: an adult traveller may not reuse it, just as on an ordinary booking
|
||||
* an adult may not reuse participant[0]'s.
|
||||
*
|
||||
* Email comparison is case-insensitive and whitespace is normalized.
|
||||
*/
|
||||
@@ -109,8 +134,9 @@ class ParticipantEditDto
|
||||
return;
|
||||
}
|
||||
|
||||
// In regular create/edit submission flows, the applicant can share an email with dependents.
|
||||
if (true === $this->participant->isApplicant()) {
|
||||
// The applicant can share an email with dependents. On a split booking participant[0] is
|
||||
// an ordinary traveller, so an adult there is held to uniqueness like everyone else.
|
||||
if (true === $this->participant->isApplicant() && true === $this->bookingContext->isFirstParticipantApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,7 +145,7 @@ class ParticipantEditDto
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if email is null or empty (handled by @Email and @NotBlank constraints)
|
||||
// Nothing to compare. Whether a blank is allowed is the property constraint's business.
|
||||
if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -157,5 +183,17 @@ class ParticipantEditDto
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (true === $this->bookingContext->isFirstParticipantApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicantEmail = $this->bookingContext->getApplicantEmail();
|
||||
|
||||
if (null !== $applicantEmail && $normalizedEmail === u($applicantEmail)->lower()->toString()) {
|
||||
$context->buildViolation('Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet')
|
||||
->atPath('participant.email')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class TravelMailingLookupDto
|
||||
{
|
||||
/**
|
||||
* The leg "bus passengers only" stands for: whoever takes the bus out, whatever their return.
|
||||
*/
|
||||
public const BusLeg BUS_LEG = BusLeg::Outbound;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte einen Reisecode angeben.')]
|
||||
public ?string $travelCode = null;
|
||||
|
||||
public bool $busOnly = false;
|
||||
|
||||
public function getBusLeg(): ?BusLeg
|
||||
{
|
||||
return $this->busOnly ? self::BUS_LEG : null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
@@ -69,10 +70,24 @@ class VoucherPurchaseDto
|
||||
#[Assert\Valid]
|
||||
public Address $address;
|
||||
|
||||
#[Assert\When(
|
||||
expression: 'this.isPhoneNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['voucher_step_2'],
|
||||
)]
|
||||
public ?string $phone = null;
|
||||
|
||||
#[Assert\When(
|
||||
expression: 'this.isMobileNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['voucher_step_2'],
|
||||
)]
|
||||
public ?string $mobile = null;
|
||||
|
||||
// The numbers prefilled from BusPro, which are exempt from PhoneNumber.
|
||||
public ?string $loadedPhone = null;
|
||||
public ?string $loadedMobile = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
|
||||
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
|
||||
public ?string $email = null;
|
||||
@@ -90,6 +105,16 @@ class VoucherPurchaseDto
|
||||
$this->address->country = 'D';
|
||||
}
|
||||
|
||||
public function isPhoneNewlyEntered(): bool
|
||||
{
|
||||
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
|
||||
}
|
||||
|
||||
public function isMobileNewlyEntered(): bool
|
||||
{
|
||||
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
|
||||
}
|
||||
|
||||
public function getBuyerFullName(): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
|
||||
@@ -119,6 +144,8 @@ class VoucherPurchaseDto
|
||||
$this->address->country ??= 'D';
|
||||
$this->phone = $personalData->communication->phone;
|
||||
$this->mobile = $personalData->communication->mobile;
|
||||
$this->loadedPhone = $this->phone;
|
||||
$this->loadedMobile = $this->mobile;
|
||||
$this->email = $loginEmail;
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,10 @@ class PersonalDataType extends AbstractType
|
||||
'label' => 'Telefon',
|
||||
'required' => false,
|
||||
'property_path' => 'communication.phone',
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
->add('mobile', TextType::class, [
|
||||
'label' => 'Mobil',
|
||||
'property_path' => 'communication.mobile',
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class RegistrationType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => RegistrationDto::class,
|
||||
'validation_groups' => ['Default', 'personal_data'],
|
||||
'validation_groups' => ['Default', 'personal_data', 'personal_identity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition for "participant[0] is the booking's applicant".
|
||||
*
|
||||
* Use it where a field is only mandatory because BusPro asks it of the Anmelder rather than of
|
||||
* an ordinary participant, such as mobile and address (an empty anschrift passes for a
|
||||
* participant, measured on 2036).
|
||||
*
|
||||
* FirstParticipantCondition remains the right choice where plain index 0 is meant, such as
|
||||
* showing a field on the first card regardless of who that person is.
|
||||
*/
|
||||
class FirstParticipantIsApplicantCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* @param BookingDto $bookingDto The current booking data
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
return 0 === $participantIndex && $bookingDto->isFirstParticipantApplicant();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] Empty array - the applicant choice does not change during form interaction
|
||||
*/
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Checks if participant[0] is the applicant';
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,19 @@ use App\Form\Service\Contract\FieldConditionInterface;
|
||||
/**
|
||||
* Condition that determines if the first participant's personal data should be read-only.
|
||||
*
|
||||
* This condition evaluates read-only state based on agency type and authentication state,
|
||||
* replacing the previous personId-based detection with agency code-based detection.
|
||||
* It fires only where participant[0] IS the booking's applicant, which
|
||||
* BookingDto::isFirstParticipantApplicant() decides: the booking-level choice in create mode,
|
||||
* and anmelder vs. participant[0] person id in edit mode, where internal agency bookings
|
||||
* always answer false.
|
||||
*
|
||||
* Business Rules:
|
||||
* - CREATE mode (anonymous user): First participant data is editable (user must fill it in)
|
||||
* - CREATE mode (logged-in, non-internal agency): First participant = applicant, data is read-only
|
||||
* - CREATE mode (logged-in, internal agency): First participant ≠ applicant, data is editable
|
||||
* - EDIT mode (internal agency): First participant ≠ applicant, data is editable (mutability applies separately)
|
||||
* - EDIT mode (non-internal agency): First participant = applicant, always read-only
|
||||
* - CREATE mode (logged-in, participant[0] is the applicant): data is prepopulated, read-only
|
||||
* - CREATE mode (applicant does not travel): participant[0] is an ordinary traveller, data is
|
||||
* editable
|
||||
* - EDIT mode (participant[0] is the applicant): always read-only
|
||||
* - EDIT mode (split booking, or internal agency): participant[0] ≠ applicant, editable here
|
||||
* (PersonalDataMutabilityCondition still applies separately)
|
||||
*
|
||||
* Non-first participants are never affected by this condition - they return false here
|
||||
* but may still be read-only via PersonalDataMutabilityCondition in edit mode.
|
||||
@@ -36,45 +40,31 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
// This condition only applies to first participant (agency-based read-only)
|
||||
// This condition only applies to the first participant.
|
||||
// Non-first participants: return false here, but may still be read-only via PersonalDataMutabilityCondition
|
||||
if (0 !== $participantIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
// CREATE mode logic
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Anonymous user (no personId) = must fill in data = editable
|
||||
if (null === $participant?->personId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Internal agency = first participant ≠ applicant = editable
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Logged-in user with non-internal agency = data prepopulated = read-only
|
||||
return true;
|
||||
}
|
||||
|
||||
// EDIT mode logic
|
||||
// Internal agency = first participant ≠ applicant = editable (mutability check applies separately)
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
// participant[0] is a traveller in their own right: nothing to protect here.
|
||||
if (false === $bookingDto->isFirstParticipantApplicant()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-internal agency = first participant = applicant = always read-only
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Anonymous user (no personId) = must fill in data = editable
|
||||
return null !== $bookingDto->getParticipant($participantIndex)?->personId;
|
||||
}
|
||||
|
||||
// EDIT mode: participant[0] is the applicant, whose record is owned by the account.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns field names that this condition depends on.
|
||||
*
|
||||
* This condition is based on agency code and personId which are set during initialization
|
||||
* and don't change during form interaction, so no field dependencies.
|
||||
* This condition is based on the applicant split and personId, which are set during
|
||||
* initialization and don't change during form interaction, so no field dependencies.
|
||||
*
|
||||
* @return string[] Empty array - no field dependencies
|
||||
*/
|
||||
@@ -90,6 +80,6 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'First participant personal data is read-only for non-internal agency bookings with logged-in users';
|
||||
return 'First participant personal data is read-only when they are also the applicant';
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,11 @@ use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that mirrors ParticipantDto::requiresOwnEmail() into the field state layer.
|
||||
* Condition that mirrors BookingDto::requiresParticipantEmail() into the field state layer.
|
||||
*
|
||||
* It deliberately holds no logic of its own. The rule for who needs an email address is
|
||||
* enforced by the Assert\When on ParticipantDto::$email; this condition exists so the
|
||||
* mandatory marker rendered next to the field is derived from that same method rather than
|
||||
* from a second, independently maintained composition of age and applicant conditions.
|
||||
* It deliberately holds no logic of its own, so the mandatory marker rendered next to the field
|
||||
* is derived from the same method that describes what validation enforces, rather than from a
|
||||
* second, independently maintained composition of age and applicant conditions.
|
||||
*/
|
||||
class RequiresOwnEmailCondition implements FieldConditionInterface
|
||||
{
|
||||
@@ -28,11 +27,7 @@ class RequiresOwnEmailCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
// A participant that does not exist yet is treated as needing an address, matching the
|
||||
// "unknown counts as an adult" stance of ParticipantDto::isChild().
|
||||
return null === $participant || $participant->requiresOwnEmail();
|
||||
return $bookingDto->requiresParticipantEmail($participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantIsApplicantCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
|
||||
use App\Form\Service\Condition\MultipleParticipantsCondition;
|
||||
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
|
||||
@@ -88,7 +89,7 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
);
|
||||
|
||||
// First participant read-only condition
|
||||
// Render personal data fields as static text for first participant in non-internal agency bookings
|
||||
// Render personal data fields as static text for the first participant where they are the applicant
|
||||
// when user is logged in (data is prepopulated from BPN account)
|
||||
$firstParticipantReadOnlyCondition = new FirstParticipantReadOnlyCondition();
|
||||
|
||||
@@ -117,10 +118,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
'required' => new RequiresOwnEmailCondition(),
|
||||
];
|
||||
|
||||
// Mobile field: static text for first participant (when read-only), required for first participant (guest bookings)
|
||||
// Mobile field: static text for first participant (when read-only). Mandatory only for the
|
||||
// applicant (guest bookings) — BusPro does not ask a telefonmobil of an ordinary participant.
|
||||
$this->fieldStateConditions['mobile'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
'required' => new FirstParticipantIsApplicantCondition(),
|
||||
];
|
||||
|
||||
// Address subfields - must render as static text to prevent creating duplicate BPN records
|
||||
@@ -313,11 +315,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
'hidden' => FieldValueCondition::equals('parking', false),
|
||||
];
|
||||
|
||||
// Make address field required for first participant (index 0)
|
||||
// Also render as static text for first participant when read-only
|
||||
// Address is mandatory for the applicant only; BusPro accepts an empty anschrift from an
|
||||
// ordinary participant. Also rendered as static text for the applicant when read-only.
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
'required' => new FirstParticipantIsApplicantCondition(),
|
||||
];
|
||||
|
||||
// Room assignment field:
|
||||
|
||||
@@ -48,6 +48,17 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
return 'assignedRoomId';
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips edit mode, where room assignments cannot be changed.
|
||||
*
|
||||
* The field is static text there, so a submitted value can only come from a replayed or
|
||||
* hand-crafted POST. The edit request sends the booking's own assignments anyway.
|
||||
*/
|
||||
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
|
||||
{
|
||||
return BookingDto::MODE_EDIT !== $mode && parent::shouldProcess($submittedData, $mode, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the assignedRoomId field for a specific participant.
|
||||
*
|
||||
@@ -65,7 +76,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
* 6. Updates the participant's assignedRoomId property
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
|
||||
* @param BookingDto $bookingDto The booking DTO to update
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
@@ -122,15 +133,8 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate total capacity for this room type
|
||||
// In edit mode, use maxQuantity which includes available rooms beyond the booked count.
|
||||
// In create mode, use quantity which is the user's room selection from step 1.
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
||||
$effectiveQuantity = $roomSelection->maxQuantity;
|
||||
} else {
|
||||
$effectiveQuantity = $roomSelection->quantity ?? 0;
|
||||
}
|
||||
$totalCapacity = $effectiveQuantity * $roomSelection->capacity;
|
||||
// Calculate total capacity for this room type from the step 1 selection
|
||||
$totalCapacity = ($roomSelection->quantity ?? 0) * $roomSelection->capacity;
|
||||
|
||||
// Count current occupancy (excluding current participant)
|
||||
$occupancy = 0;
|
||||
@@ -182,12 +186,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
return; // Shouldn't happen
|
||||
}
|
||||
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
||||
$effectiveQuantity = $roomSelection->maxQuantity;
|
||||
} else {
|
||||
$effectiveQuantity = $roomSelection->quantity ?? 0;
|
||||
}
|
||||
$totalCapacity = $effectiveQuantity * $roomSelection->capacity;
|
||||
$totalCapacity = ($roomSelection->quantity ?? 0) * $roomSelection->capacity;
|
||||
$currentOccupancy = count($participantsWithRoom);
|
||||
$spacesNeeded = ($currentOccupancy + 1) - $totalCapacity;
|
||||
|
||||
@@ -203,7 +202,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
|
||||
// Add notification for unassigned participant
|
||||
// For internal agency bookings, first participant is not the applicant
|
||||
$isApplicant = $participant->isApplicant() && false === $bookingDto->isInternalAgencyBooking();
|
||||
$isApplicant = $participant->isApplicant() && $bookingDto->isFirstParticipantApplicant();
|
||||
$participantLabel = $isApplicant ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
|
||||
$participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel));
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\Form\Model\BookingDto;
|
||||
@@ -86,21 +85,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
*/
|
||||
protected function registerFieldOptionProviders(): void
|
||||
{
|
||||
// Room assignment field provider (available for both create and edit workflows)
|
||||
// Room assignment field provider (create only: edit mode renders the room as static text)
|
||||
// Returns empty array when no room choices available
|
||||
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = [];
|
||||
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Create context: use selected rooms from step 1, filtered by participant age
|
||||
// Use selected rooms from step 1, filtered by participant age
|
||||
$choices = $this->filterRoomsByParticipantAge(
|
||||
$bookingDto->getSelectedRooms(),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
} elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
||||
// Edit context: convert booked rooms to RoomSelectionDto for consistent handling
|
||||
$choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms);
|
||||
}
|
||||
|
||||
if (true === empty($choices)) {
|
||||
@@ -677,7 +673,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'attr' => [
|
||||
'data-description' => sprintf(
|
||||
'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
|
||||
$bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in'
|
||||
$bookingDto->isFirstParticipantApplicant() ? 'den/die Anmelder:in' : 'Teilnehmer:in 1'
|
||||
),
|
||||
],
|
||||
];
|
||||
@@ -782,31 +778,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts booked Room objects to RoomSelectionDto for consistent form handling.
|
||||
*
|
||||
* In edit mode, we need to convert Room entities to RoomSelectionDto objects
|
||||
* so the form can use the same choice_value and choice_label configuration
|
||||
* as create mode, enabling consistent price display in the template.
|
||||
*
|
||||
* @param Room[] $bookedRooms Rooms from existing booking
|
||||
*
|
||||
* @return RoomSelectionDto[] Array of room selection DTOs
|
||||
*/
|
||||
private function convertBookedRoomsToSelectionDtos(array $bookedRooms): array
|
||||
{
|
||||
$selections = [];
|
||||
foreach ($bookedRooms as $room) {
|
||||
$selection = new RoomSelectionDto();
|
||||
$selection->id = $room->id;
|
||||
$selection->label = $room->label;
|
||||
$selection->price = $room->price;
|
||||
$selections[] = $selection;
|
||||
}
|
||||
|
||||
return $selections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters rental services by selected skipass duration.
|
||||
*
|
||||
|
||||
@@ -55,12 +55,10 @@ class VoucherPurchaseStep2Type extends AbstractType
|
||||
->add('phone', TextType::class, [
|
||||
'label' => 'Telefon privat',
|
||||
'required' => false,
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
->add('mobile', TextType::class, [
|
||||
'label' => 'Mobil',
|
||||
'required' => false,
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
+147
-1
@@ -12,6 +12,7 @@ class ApiClient
|
||||
{
|
||||
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
||||
private const int NAME_MAX_LENGTH = 255;
|
||||
private const int PAGE_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
|
||||
@@ -141,6 +142,139 @@ class ApiClient
|
||||
]);
|
||||
}
|
||||
|
||||
public function createContactsList(string $name): int
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$response = $this->request('POST', 'contactslist', [
|
||||
'json' => [
|
||||
'Name' => $name,
|
||||
],
|
||||
]);
|
||||
|
||||
$listId = $response['Data'][0]['ID'] ?? null;
|
||||
if (null === $listId) {
|
||||
throw new NewsletterProviderException(sprintf('Mailjet did not return an id for the new contact list %s', $name));
|
||||
}
|
||||
|
||||
return (int) $listId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the contacts as subscribed, including any who unsubscribed from this list before.
|
||||
* Mailjet processes the request as an asynchronous job.
|
||||
*
|
||||
* @param list<array{email: string, firstName: ?string}> $contacts
|
||||
*
|
||||
* @return int|null the job id
|
||||
*/
|
||||
public function addContactsToList(int $listId, array $contacts): ?int
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$payload = [];
|
||||
foreach ($contacts as $contact) {
|
||||
$entry = ['Email' => $this->normalizeEmail($contact['email'])];
|
||||
|
||||
$firstName = $this->normalizeName($contact['firstName']);
|
||||
if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
|
||||
$entry['Properties'] = [$this->contactMetadataFields['firstName'] => $firstName];
|
||||
}
|
||||
|
||||
$payload[] = $entry;
|
||||
}
|
||||
|
||||
$response = $this->request('POST', sprintf('contactslist/%d/managemanycontacts', $listId), [
|
||||
'json' => [
|
||||
'Action' => 'addforce',
|
||||
'Contacts' => $payload,
|
||||
],
|
||||
]);
|
||||
|
||||
$jobId = $response['Data'][0]['JobID'] ?? null;
|
||||
|
||||
return null !== $jobId ? (int) $jobId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getListContactIds(int $listId): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$contactIds = [];
|
||||
$offset = 0;
|
||||
do {
|
||||
$response = $this->request('GET', 'listrecipient', [
|
||||
'query' => [
|
||||
'ContactsList' => $listId,
|
||||
'Limit' => self::PAGE_LIMIT,
|
||||
'Offset' => $offset,
|
||||
],
|
||||
]);
|
||||
|
||||
$entries = true === is_array($response['Data'] ?? null) ? $response['Data'] : [];
|
||||
foreach ($entries as $entry) {
|
||||
if (true === is_array($entry) && true === isset($entry['ContactID'])) {
|
||||
$contactIds[] = (int) $entry['ContactID'];
|
||||
}
|
||||
}
|
||||
|
||||
$offset += self::PAGE_LIMIT;
|
||||
} while (self::PAGE_LIMIT === count($entries));
|
||||
|
||||
return array_values(array_unique($contactIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the contact is on, subscribed or not. Mailjet includes lists deleted within the last 60 days.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getContactListIds(int $contactId): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$response = $this->request('GET', sprintf('contact/%d/getcontactslists', $contactId), [
|
||||
'allow_404' => true,
|
||||
]);
|
||||
|
||||
$listIds = [];
|
||||
foreach ($response['Data'] ?? [] as $entry) {
|
||||
if (true === is_array($entry) && true === isset($entry['ListID'])) {
|
||||
$listIds[] = (int) $entry['ListID'];
|
||||
}
|
||||
}
|
||||
|
||||
return $listIds;
|
||||
}
|
||||
|
||||
public function deleteContactsList(int $listId): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$this->request('DELETE', sprintf('contactslist/%d', $listId), [
|
||||
'allow_404' => true,
|
||||
'allow_empty' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GDPR deletion: Mailjet anonymises the contact immediately and purges it after 30 days.
|
||||
* This cannot be undone.
|
||||
*/
|
||||
public function deleteContact(int $contactId): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$this->request('DELETE', sprintf('contacts/%d', $contactId), [
|
||||
'allow_404' => true,
|
||||
'allow_empty' => true,
|
||||
'api_version' => 'v4',
|
||||
]);
|
||||
}
|
||||
|
||||
private function findOrCreateContactId(string $email): int
|
||||
{
|
||||
$contactId = $this->resolveContactId($email);
|
||||
@@ -246,15 +380,17 @@ class ApiClient
|
||||
{
|
||||
$allow404 = true === ($options['allow_404'] ?? false);
|
||||
$allowEmpty = true === ($options['allow_empty'] ?? false);
|
||||
$baseUrl = 'v4' === ($options['api_version'] ?? null) ? $this->getV4BaseUrl() : $this->getBaseUrl();
|
||||
unset($options['allow_404']);
|
||||
unset($options['allow_empty']);
|
||||
unset($options['api_version']);
|
||||
|
||||
$resourcePath = trim($resource, '/');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
$method,
|
||||
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
|
||||
sprintf('%s/%s', $baseUrl, $resourcePath),
|
||||
array_merge($options, [
|
||||
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
|
||||
])
|
||||
@@ -310,6 +446,16 @@ class ApiClient
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured base URL points at v3 (`…/v3/REST`); the v4 API sits beside it on the same host.
|
||||
*/
|
||||
private function getV4BaseUrl(): string
|
||||
{
|
||||
$baseUrl = preg_replace('~/v3/REST$~i', '', $this->getBaseUrl());
|
||||
|
||||
return sprintf('%s/v4', $baseUrl);
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* One entry of TYPO3's `/api/calendar-hotels`: the calendar hotel of a visible product.
|
||||
*
|
||||
* TYPO3 also lists the products per hotel; only the code and the name are kept here.
|
||||
*/
|
||||
final readonly class CmsCalendarHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery
|
||||
$hasFrom = null !== $from && '' !== $from;
|
||||
$hasTo = null !== $to && '' !== $to;
|
||||
|
||||
// Neither supplied: the caller wants the full priced span, which the persisted prices define.
|
||||
// Neither supplied: the caller wants the full span, which each endpoint defines for itself.
|
||||
if (!$hasFrom && !$hasTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Enum\ContingentHotelSource;
|
||||
|
||||
/**
|
||||
* A hotel code the contingent snapshot is kept for, with whatever it is known as locally.
|
||||
*/
|
||||
final readonly class ContingentHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public ?Accommodation $accommodation,
|
||||
public ?CalendarHotel $calendarHotel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContingentHotelSource>
|
||||
*/
|
||||
public function sources(): array
|
||||
{
|
||||
$sources = [];
|
||||
|
||||
if (null !== $this->accommodation) {
|
||||
$sources[] = ContingentHotelSource::Accommodation;
|
||||
}
|
||||
|
||||
if (null !== $this->calendarHotel) {
|
||||
$sources[] = ContingentHotelSource::Typo3;
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CalendarHotel>
|
||||
*/
|
||||
class CalendarHotelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CalendarHotel::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CalendarHotel>
|
||||
*/
|
||||
public function findAllIndexedByCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $hotel) {
|
||||
$indexed[(string) $hotel->getCode()] = $hotel;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -20,53 +19,30 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
|
||||
* identified by the given hotel code, keyed by Y-m-d.
|
||||
* Returns the snapshot days of a hotel code within [dateFrom, dateTo], keyed by Y-m-d.
|
||||
* A null dateTo means everything from dateFrom to the end of the snapshot.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
public function findByHotelCodeAndDateRange(
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
/** @var ContingentDay[] $days */
|
||||
$days = $this->createQueryBuilder('cd')
|
||||
->join('cd.accommodation', 'a')
|
||||
->where('a.calendarCode = :hotelCode')
|
||||
$qb = $this->createQueryBuilder('cd')
|
||||
->where('cd.hotelCode = :hotelCode')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('hotelCode', $hotelCode)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->orderBy('cd.date', 'ASC');
|
||||
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
if (null !== $dateTo) {
|
||||
$qb->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('dateTo', $dateTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as above but for a known accommodation; used by the sync to diff against.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
public function findByAccommodationAndDateRange(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
/** @var ContingentDay[] $days */
|
||||
$days = $this->createQueryBuilder('cd')
|
||||
->where('cd.accommodation = :accommodation')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('accommodation', $accommodation)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$days = $qb->getQuery()->getResult();
|
||||
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
@@ -84,6 +60,21 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the days of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('cd')
|
||||
->delete()
|
||||
->where('cd.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ContingentDay[] $days
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -19,30 +18,37 @@ class ContingentSyncStateRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, ContingentSyncState::class);
|
||||
}
|
||||
|
||||
public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState
|
||||
public function findOneByHotelCode(string $hotelCode): ?ContingentSyncState
|
||||
{
|
||||
return $this->findOneBy(['accommodation' => $accommodation]);
|
||||
return $this->findOneBy(['hotelCode' => $hotelCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every sync state keyed by accommodation id, for the revisions endpoint.
|
||||
*
|
||||
* @return array<int, ContingentSyncState>
|
||||
* @return array<string, ContingentSyncState>
|
||||
*/
|
||||
public function findAllIndexedByAccommodationId(): array
|
||||
public function findAllIndexedByHotelCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $state) {
|
||||
$accommodation = $state->getAccommodation();
|
||||
|
||||
if (null === $accommodation || null === $accommodation->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$accommodation->getId()] = $state;
|
||||
$indexed[(string) $state->getHotelCode()] = $state;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the sync state of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->delete()
|
||||
->where('s.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TravelMailingList;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TravelMailingList>
|
||||
*/
|
||||
class TravelMailingListRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TravelMailingList::class);
|
||||
}
|
||||
|
||||
public function findOneByName(string $name): ?TravelMailingList
|
||||
{
|
||||
return $this->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TravelMailingList>
|
||||
*/
|
||||
public function findByTravelCode(string $travelCode): array
|
||||
{
|
||||
return $this->findBy(['travelCode' => $travelCode], ['id' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TravelMailingList>
|
||||
*/
|
||||
public function findDueForDeletion(\DateTimeImmutable $now): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->where('l.deleteAfter <= :now')
|
||||
->setParameter('now', $now)
|
||||
->orderBy('l.deleteAfter', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ class BookingConfigurator
|
||||
? $this->agencyLoader->loadById($agencyId)?->code
|
||||
: null;
|
||||
$bookingCreateDto->bookingStatus = $bookingStatus;
|
||||
$bookingCreateDto->initialBookingStatus = $bookingStatus;
|
||||
$this->applyCreateBookingStatusRules($bookingCreateDto);
|
||||
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
@@ -659,31 +660,43 @@ class BookingConfigurator
|
||||
/**
|
||||
* Updates booking status based on selected room requirements.
|
||||
*
|
||||
* Checks if any selected room has inquiry-only status. If so, forces
|
||||
* the entire booking to inquiry mode. This override happens after room
|
||||
* selection in Step 1 and respects the business rule: if ANY room requires
|
||||
* inquiry, the whole booking becomes an inquiry.
|
||||
* If ANY selected room is only bookable on request, the whole booking becomes an inquiry.
|
||||
* Otherwise the status returns to what the travel called for when the booking started,
|
||||
* and the create-flow rules run again, so a customer who swaps an on-request room for a
|
||||
* free one in Step 1 is no longer submitted as an inquiry. An 'A' from the Step 3 BusPro
|
||||
* fallback is cleared the same way; Step 3 validates again and re-applies it if needed.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO to update
|
||||
*/
|
||||
public function updateBookingStatusFromRoomSelection(BookingDto $bookingDto): void
|
||||
{
|
||||
// Skip if already inquiry - no need to check
|
||||
if ('A' === $bookingDto->bookingStatus) {
|
||||
if (true === $this->hasOnRequestRoomSelected($bookingDto)) {
|
||||
$bookingDto->bookingStatus = Constants::BOOKING_STATUS_INQUIRY;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check selected rooms for inquiry-only status
|
||||
// Sessions started before the initial status was recorded keep their current status
|
||||
if (null === $bookingDto->initialBookingStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bookingDto->bookingStatus = $bookingDto->initialBookingStatus;
|
||||
$this->applyCreateBookingStatusRules($bookingDto);
|
||||
}
|
||||
|
||||
private function hasOnRequestRoomSelected(BookingDto $bookingDto): bool
|
||||
{
|
||||
foreach ($bookingDto->getSelectedRooms() as $roomSelection) {
|
||||
$room = $bookingDto->travel->getRoomById($roomSelection->id);
|
||||
if ($room
|
||||
if (null !== $room
|
||||
&& Constants::STATUS_ON_REQUEST === $room->status
|
||||
&& $room->available > 0) {
|
||||
$bookingDto->bookingStatus = 'A';
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ class BookingEditDraftMerger
|
||||
|
||||
// Room assignment
|
||||
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment']);
|
||||
}
|
||||
|
||||
// License plate
|
||||
@@ -177,26 +177,19 @@ class BookingEditDraftMerger
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the drafted room assignment to the participant.
|
||||
* Applies the drafted room remarks to the participant.
|
||||
*
|
||||
* The room id uses the same merge strategy as single-select services: it is only
|
||||
* applied when it resolves to a room of the current travel. A draft may therefore move
|
||||
* a participant to another room, but never unassign one. Edit mode renders the room as
|
||||
* static text and never submits assignedRoomId, so a null in the draft is only ever an
|
||||
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
|
||||
* would drop the participant's room, both in the UI and in the outbound zuordnung.
|
||||
* The drafted assignedRoomId is ignored. Edit mode renders the room as static text and
|
||||
* never submits it, so the draft only holds what BusPro reported when it was saved, and
|
||||
* replaying it would undo a reassignment the office made since. The room always comes
|
||||
* from the booking as loaded.
|
||||
*
|
||||
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
|
||||
{
|
||||
$assignedRoomId = $data['assignedRoomId'] ?? null;
|
||||
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
|
||||
$participant->assignedRoomId = (int) $assignedRoomId;
|
||||
}
|
||||
|
||||
if (true === array_key_exists('remarksRoom', $data)) {
|
||||
$participant->remarksRoom = $data['remarksRoom'];
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class BookingEditPreFlightChecker
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $bookingDto->isInternalAgencyBooking() && true === $participant->isApplicant()) {
|
||||
if (true === $bookingDto->isFirstParticipantApplicant() && true === $participant->isApplicant()) {
|
||||
foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ class BookingNewsletterOptIn
|
||||
$email = $currentUser->getEmail();
|
||||
}
|
||||
|
||||
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
|
||||
$email = $bookingDto->participants[0]->email;
|
||||
if (null === $email || '' === trim((string) $email)) {
|
||||
$email = $this->resolveApplicantParticipant($bookingDto)?->email;
|
||||
}
|
||||
|
||||
if (null === $email) {
|
||||
@@ -46,9 +46,16 @@ class BookingNewsletterOptIn
|
||||
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The person who is the booking's contact: participant[0] on an ordinary booking, and the
|
||||
* separate applicant record when they do not travel along.
|
||||
*/
|
||||
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
|
||||
{
|
||||
$participant = $bookingDto->participants[0] ?? null;
|
||||
$participant = $bookingDto->applicantTravels
|
||||
? ($bookingDto->participants[0] ?? null)
|
||||
: $bookingDto->applicant;
|
||||
|
||||
if (null === $participant) {
|
||||
return null;
|
||||
}
|
||||
@@ -63,7 +70,7 @@ class BookingNewsletterOptIn
|
||||
/**
|
||||
* Companions eligible for a third-party opt-in offer.
|
||||
*
|
||||
* Children are excluded because they are allowed to share an adult's address, so subscribing
|
||||
* Children are excluded because they carry the applicant's address by default, so subscribing
|
||||
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
|
||||
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
|
||||
*
|
||||
@@ -75,7 +82,13 @@ class BookingNewsletterOptIn
|
||||
$seenEmails = [];
|
||||
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
if (true === $participant->isApplicant() || true === $participant->isChild()) {
|
||||
// participant[0] is only the booking contact when the applicant travels along; on a
|
||||
// split booking they are an ordinary companion like everyone else.
|
||||
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === $participant->isChild()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ use App\Form\Model\ParticipantDto;
|
||||
*/
|
||||
class BookingPricingAssembler
|
||||
{
|
||||
/**
|
||||
* BusPro's label for the per-leg boarding surcharge, suffixed " (Hinfahrt)" or " (Rückfahrt)".
|
||||
* A booking response's `<zuschlag>` carries no type attribute, so the label is all there is.
|
||||
*/
|
||||
private const BOARDING_SURCHARGE_LABEL = 'Abfahrtsortzuschlag';
|
||||
|
||||
public function __construct(
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
||||
@@ -108,6 +114,12 @@ class BookingPricingAssembler
|
||||
continue;
|
||||
}
|
||||
|
||||
// Listed per leg among the transportation services, from the participants' current
|
||||
// selection rather than the stored booking, so BusPro's copy would count it twice
|
||||
if (str_starts_with($surcharge->label ?? '', self::BOARDING_SURCHARGE_LABEL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$surchargePricing[] = [
|
||||
'label' => $surcharge->label ?? 'unbekannt',
|
||||
'participantCount' => count($surcharge->mapping),
|
||||
@@ -122,8 +134,10 @@ class BookingPricingAssembler
|
||||
* Aggregates all transportation-related services and pricing into separate line items.
|
||||
*
|
||||
* Creates separate entries for:
|
||||
* - Beförderung: Sum of all positive pickup prices and base transportation costs
|
||||
* - Beförderung - Rabatt: Sum of all negative transportation prices (discounts)
|
||||
* - Beförderung: Sum of all positive base transportation costs
|
||||
* - Beförderung - Rabatt: Sum of all negative base transportation prices (discounts)
|
||||
* - Abfahrtsortzuschlag (Hinfahrt) / (Rückfahrt): Boarding surcharges per leg, labelled as
|
||||
* BusPro bills them, so the breakdown reads like the invoice
|
||||
* - Parkplatz: Sum of all parking service prices
|
||||
*
|
||||
* Only includes transportation costs from eligible participants.
|
||||
@@ -134,36 +148,25 @@ class BookingPricingAssembler
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
$transportationPositiveTotal = 0.0;
|
||||
$transportationDiscountTotal = 0.0;
|
||||
$parkingTotal = 0.0;
|
||||
|
||||
$transportationParticipants = 0;
|
||||
$discountParticipants = 0;
|
||||
$parkingParticipants = 0;
|
||||
$totals = [
|
||||
'transportation_positive' => 0.0,
|
||||
'transportation_discount' => 0.0,
|
||||
'transportation_pickup' => 0.0,
|
||||
'transportation_drop_off' => 0.0,
|
||||
'transportation_parking' => 0.0,
|
||||
];
|
||||
$participantCounts = array_fill_keys(array_keys($totals), 0);
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->isParticipantBilled($bookingDto, $participantIndex, $participant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participantTransportationPositiveCost = 0.0;
|
||||
$participantTransportationDiscountCost = 0.0;
|
||||
$participantParkingCost = 0.0;
|
||||
$participantCosts = array_fill_keys(array_keys($totals), 0.0);
|
||||
|
||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||
if ($participant->transportationOutbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationOutbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationOutbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||
if ($participant->transportationInbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationInbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationInbound->price;
|
||||
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $transportation) {
|
||||
if (null !== $transportation && null !== $transportation->price) {
|
||||
$participantCosts[$transportation->price < 0 ? 'transportation_discount' : 'transportation_positive'] += $transportation->price;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,71 +176,47 @@ class BookingPricingAssembler
|
||||
&& DirectionMapper::isBus($participant->transportationInbound->subType);
|
||||
|
||||
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
|
||||
if ($participant->pickup->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->pickup->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->pickup->price;
|
||||
}
|
||||
$participantCosts['transportation_pickup'] += $participant->pickup->price;
|
||||
}
|
||||
|
||||
if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||
if ($participant->dropOff->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->dropOff->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->dropOff->price;
|
||||
}
|
||||
$participantCosts['transportation_drop_off'] += $participant->dropOff->price;
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
$participantParkingCost += $participant->parkingService->price;
|
||||
$participantCosts['transportation_parking'] += $participant->parkingService->price;
|
||||
}
|
||||
|
||||
if ($participantTransportationPositiveCost > 0) {
|
||||
$transportationPositiveTotal += $participantTransportationPositiveCost;
|
||||
++$transportationParticipants;
|
||||
}
|
||||
if ($participantTransportationDiscountCost < 0) {
|
||||
$transportationDiscountTotal += $participantTransportationDiscountCost;
|
||||
++$discountParticipants;
|
||||
}
|
||||
if ($participantParkingCost > 0) {
|
||||
$parkingTotal += $participantParkingCost;
|
||||
++$parkingParticipants;
|
||||
foreach ($participantCosts as $key => $cost) {
|
||||
if (0.0 !== round($cost, 2)) {
|
||||
$totals[$key] += $cost;
|
||||
++$participantCounts[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$definitions = [
|
||||
'transportation_positive' => ['Beförderung', Constants::GROUP_TRANSPORTATION],
|
||||
'transportation_discount' => ['Beförderung - Rabatt', Constants::GROUP_TRANSPORTATION.'_discount'],
|
||||
'transportation_pickup' => [self::BOARDING_SURCHARGE_LABEL.' (Hinfahrt)', Constants::GROUP_TRANSPORTATION],
|
||||
'transportation_drop_off' => [self::BOARDING_SURCHARGE_LABEL.' (Rückfahrt)', Constants::GROUP_TRANSPORTATION],
|
||||
'transportation_parking' => [Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], Constants::GROUP_TRANSPORTATION],
|
||||
];
|
||||
|
||||
$transportationItems = [];
|
||||
|
||||
if ($transportationPositiveTotal > 0) {
|
||||
$transportationItems['transportation_positive'] = [
|
||||
'serviceId' => 'transportation_positive',
|
||||
'label' => 'Beförderung',
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $transportationParticipants,
|
||||
'totalPrice' => $transportationPositiveTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||
];
|
||||
}
|
||||
foreach ($definitions as $key => [$label, $subType]) {
|
||||
if (0 === $participantCounts[$key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($transportationDiscountTotal < 0) {
|
||||
$transportationItems['transportation_discount'] = [
|
||||
'serviceId' => 'transportation_discount',
|
||||
'label' => 'Beförderung - Rabatt',
|
||||
$transportationItems[$key] = [
|
||||
'serviceId' => $key,
|
||||
'label' => $label,
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $discountParticipants,
|
||||
'totalPrice' => $transportationDiscountTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION.'_discount',
|
||||
];
|
||||
}
|
||||
|
||||
if ($parkingTotal > 0) {
|
||||
$transportationItems['transportation_parking'] = [
|
||||
'serviceId' => 'transportation_parking',
|
||||
'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING],
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $parkingParticipants,
|
||||
'totalPrice' => $parkingTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||
'participantCount' => $participantCounts[$key],
|
||||
'totalPrice' => $totals[$key],
|
||||
'subType' => $subType,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Model\CmsCalendarHotel;
|
||||
use App\Model\CmsHotelData;
|
||||
use App\Model\CmsRegionData;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -169,6 +170,40 @@ class CmsDataProvider
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a
|
||||
* usable list. Uncached: the only caller keeps its own copy of the last good answer.
|
||||
*
|
||||
* @return list<CmsCalendarHotel>|null
|
||||
*/
|
||||
public function fetchCalendarHotels(): ?array
|
||||
{
|
||||
try {
|
||||
$data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray();
|
||||
} catch (ExceptionInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_is_list($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
$code = $entry['code'] ?? null;
|
||||
|
||||
if (!is_string($code) || '' === $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $entry['name'] ?? null;
|
||||
$hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code);
|
||||
}
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
|
||||
* the calendar hotels of TYPO3's visible products.
|
||||
*
|
||||
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
|
||||
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
|
||||
* TYPO3 does not make its hotels drop out of the sync.
|
||||
*/
|
||||
class ContingentHotelDirectory
|
||||
{
|
||||
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
|
||||
// for is not worth syncing, and it would not fit the column either.
|
||||
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
|
||||
|
||||
public function __construct(
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly CalendarHotelRepository $calendarHotelRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
|
||||
*
|
||||
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
|
||||
* a fault on its side, not every winter hotel being withdrawn at once.
|
||||
*/
|
||||
public function refresh(): bool
|
||||
{
|
||||
$fetched = [];
|
||||
|
||||
foreach ($this->cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) {
|
||||
if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) {
|
||||
$this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fetched[$hotel->code] = $hotel;
|
||||
}
|
||||
|
||||
if ([] === $fetched) {
|
||||
$this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
foreach ($fetched as $code => $hotel) {
|
||||
$calendarHotel = $stored[$code] ?? null;
|
||||
|
||||
if (null === $calendarHotel) {
|
||||
$this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name));
|
||||
} elseif ($calendarHotel->getName() !== $hotel->name) {
|
||||
$calendarHotel->setName($hotel->name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_diff_key($stored, $fetched) as $calendarHotel) {
|
||||
$this->entityManager->remove($calendarHotel);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentHotel> keyed and sorted by hotel code
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$accommodations = [];
|
||||
|
||||
foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) {
|
||||
$accommodations[(string) $accommodation->getCalendarCode()] = $accommodation;
|
||||
}
|
||||
|
||||
$calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach (array_keys($accommodations + $calendarHotels) as $code) {
|
||||
$code = (string) $code;
|
||||
$hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null);
|
||||
}
|
||||
|
||||
ksort($hotels, \SORT_STRING);
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
public function find(string $hotelCode): ?ContingentHotel
|
||||
{
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
$calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]);
|
||||
|
||||
if (null === $accommodation && null === $calendarHotel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ContingentHotel($hotelCode, $accommodation, $calendarHotel);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Service;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Model\ContingentSyncResult;
|
||||
@@ -34,24 +33,22 @@ class ContingentSnapshotManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes [dateFrom, dateTo] for a single accommodation.
|
||||
* Refreshes [dateFrom, dateTo] for a single hotel code.
|
||||
*
|
||||
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
|
||||
* the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a
|
||||
* full-horizon run already stored.
|
||||
*/
|
||||
public function sync(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ContingentSyncResult {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
|
||||
if ('' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('No hotel code given.');
|
||||
}
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode);
|
||||
|
||||
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||
// are the window growing, not the contingent situation changing.
|
||||
@@ -67,7 +64,7 @@ class ContingentSnapshotManager
|
||||
return $this->recordFailure($state, $hotelCode, $e->getMessage());
|
||||
}
|
||||
|
||||
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
|
||||
$existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
// An empty upstream response would otherwise wipe the window and render the whole
|
||||
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
|
||||
@@ -103,7 +100,7 @@ class ContingentSnapshotManager
|
||||
|
||||
if (null === $day) {
|
||||
$day = (new ContingentDay())
|
||||
->setAccommodation($accommodation)
|
||||
->setHotelCode($hotelCode)
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -41,22 +41,20 @@ class ContingentSnapshotReader
|
||||
* Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel.
|
||||
*
|
||||
* Days inside the range that the snapshot does not cover are simply absent from the map;
|
||||
* what that means is the caller's decision.
|
||||
* what that means is the caller's decision. A null dateTo reads to the end of the snapshot.
|
||||
*
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
public function statusesFor(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): ?array {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isUsable($accommodation, $hotelCode)) {
|
||||
if (!$this->isUsable($hotelCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,25 +67,36 @@ class ContingentSnapshotReader
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
|
||||
/**
|
||||
* Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without
|
||||
* the logging: for overviews that ask about every hotel at once.
|
||||
*/
|
||||
public function isCurrent(?ContingentSyncState $state): bool
|
||||
{
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE);
|
||||
}
|
||||
|
||||
private function isUsable(string $hotelCode): bool
|
||||
{
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode);
|
||||
|
||||
if ($this->isCurrent($state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
|
||||
} else {
|
||||
$this->logger->error('Contingent snapshot is stale', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class ParticipantCardAssembler
|
||||
|
||||
if ('' === $name) {
|
||||
// For internal agency bookings, first participant is not the applicant
|
||||
if (0 === $index && false === $bookingDto->isInternalAgencyBooking()) {
|
||||
if (0 === $index && $bookingDto->isFirstParticipantApplicant()) {
|
||||
return 'Anmelder:in';
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ class ParticipantDataPrefiller
|
||||
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
|
||||
$applicant->email = $prepopulated->email;
|
||||
$applicant->mobile = $prepopulated->mobile;
|
||||
$applicant->loadedMobile = $prepopulated->loadedMobile;
|
||||
$applicant->address = $prepopulated->address;
|
||||
$applicant->height = $prepopulated->height;
|
||||
$applicant->weight = $prepopulated->weight;
|
||||
@@ -132,6 +133,33 @@ class ParticipantDataPrefiller
|
||||
return self::TOKEN === $participant->lastName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the applicant DTO with generated dummy contact data.
|
||||
*
|
||||
* Same `#KUN#` shortcut as fillDummyParticipant(), but applicant-shaped: the applicant has to
|
||||
* be a complete adult, so gender and nationality are filled too and the date of birth is well
|
||||
* clear of 18. Used for manual testing against the BusPro test system.
|
||||
*/
|
||||
public function fillDummyApplicant(ParticipantDto $applicant): void
|
||||
{
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$applicant->firstName = 'Probeelternteil';
|
||||
$applicant->lastName = sprintf('Elternteil %s', $now->format('H:i'));
|
||||
$applicant->gender = 'W';
|
||||
$applicant->nationality = 'D';
|
||||
$applicant->mobile = '0171/222222';
|
||||
$applicant->email = '[email protected]';
|
||||
$applicant->dateOfBirth = $now->subYears(45)->toImmutable();
|
||||
|
||||
$address = new Address();
|
||||
$address->street = 'Probeweg 7';
|
||||
$address->postCode = '99999';
|
||||
$address->city = 'MusterOrt';
|
||||
$address->country = 'D';
|
||||
$applicant->address = $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the participant DTO with generated dummy personal data.
|
||||
*/
|
||||
@@ -150,7 +178,7 @@ class ParticipantDataPrefiller
|
||||
$address->street = 'Musterstr. 123';
|
||||
$address->postCode = '99999';
|
||||
$address->city = 'MusterOrt';
|
||||
$address->country = 'DE';
|
||||
$address->country = 'D';
|
||||
$participant->address = $address;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user