Compare commits
31
Commits
32083c7966
...
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 |
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
|
||||
|
||||
@@ -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
+905
-639
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"
|
||||
|
||||
@@ -441,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:
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -341,6 +340,7 @@ class BookingDataProcessor
|
||||
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +4,11 @@ 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\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -53,6 +53,8 @@ class BookingApplicantType extends AbstractType
|
||||
->add('nationality', CountryType::class, [
|
||||
'label' => 'Nationalität',
|
||||
'required' => true,
|
||||
'property' => 'nationality',
|
||||
'placeholder' => 'Bitte auswählen...',
|
||||
'preferred_choices' => ['D', 'A', 'CH'],
|
||||
])
|
||||
->add('dateOfBirth', BirthdayType::class, [
|
||||
@@ -72,7 +74,6 @@ class BookingApplicantType extends AbstractType
|
||||
->add('mobile', TextType::class, [
|
||||
'label' => 'Telefon (mobil)',
|
||||
'required' => true,
|
||||
'sanitize_html' => true,
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -93,8 +93,17 @@ class ParticipantDto
|
||||
)]
|
||||
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;
|
||||
@@ -240,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;
|
||||
@@ -254,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;
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -147,12 +147,8 @@ class RoomPricingCalculator
|
||||
}
|
||||
}
|
||||
|
||||
// Count current participant assignments from the DTO (reflects live state
|
||||
// including new assignments not yet in the booking entity)
|
||||
$assignmentCounts = $bookingDto->getRoomAssignmentCounts();
|
||||
|
||||
// Build pricing array
|
||||
foreach ($roomGroups as $roomId => $data) {
|
||||
foreach ($roomGroups as $data) {
|
||||
$room = $data['room'];
|
||||
$participantCount = $data['participantCount'];
|
||||
$totalPrice = $data['totalPrice'];
|
||||
@@ -160,14 +156,11 @@ class RoomPricingCalculator
|
||||
// Calculate average unit price (price per person)
|
||||
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
|
||||
|
||||
// Use live assignment count for quantity display so the sidebar reflects
|
||||
// new room assignments that haven't been persisted to the booking entity yet
|
||||
$quantity = $assignmentCounts[$roomId] ?? $room->totalCount;
|
||||
|
||||
$roomPricing[] = [
|
||||
'roomId' => $room->id,
|
||||
'label' => $room->label,
|
||||
'quantity' => $quantity,
|
||||
// Rooms, not occupants: room assignments cannot change in edit mode
|
||||
'quantity' => $room->totalCount,
|
||||
'participantCount' => $participantCount,
|
||||
'unitPrice' => $unitPrice,
|
||||
'totalPrice' => $totalPrice,
|
||||
|
||||
@@ -219,6 +219,10 @@ class ServiceAvailabilityCalculator
|
||||
/**
|
||||
* Calculate how many times each service has been selected by other participants.
|
||||
*
|
||||
* Only new selections count. In edit mode the form is prefilled with the live booking, whose
|
||||
* seats BusPro has already deducted from frei; counting them again would report a contingent
|
||||
* as exhausted while seats are still free.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data with participant selections
|
||||
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
||||
*
|
||||
@@ -238,6 +242,10 @@ class ServiceAvailabilityCalculator
|
||||
$participantUsage = $this->countParticipantServiceUsage($participant);
|
||||
|
||||
foreach ($participantUsage as $serviceId => $count) {
|
||||
if (null !== $bookingDto->booking && $bookingDto->booking->hasServiceForParticipant($index, $serviceId)) {
|
||||
--$count;
|
||||
}
|
||||
|
||||
$serviceUsage[$serviceId] = ($serviceUsage[$serviceId] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,18 @@ class TravelDataProvider
|
||||
return $this->travelLookupService->mapDateCodeToLabel($dateCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map date code to the travel's start date.
|
||||
*
|
||||
* @param string $dateCode The date code to map
|
||||
*
|
||||
* @return \DateTimeImmutable|null The start date or null if not found
|
||||
*/
|
||||
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->travelLookupService->mapDateCodeToStartDate($dateCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map hotel code to hotel ID.
|
||||
*
|
||||
|
||||
@@ -127,6 +127,26 @@ class TravelIndex
|
||||
return $this->generateFilesMap()[$dateId]['label'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a date code to the travel's start date.
|
||||
*
|
||||
* @param string $dateCode The date code to resolve
|
||||
*
|
||||
* @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none
|
||||
*/
|
||||
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
|
||||
{
|
||||
$dateId = $this->mapDateCodeToId($dateCode);
|
||||
|
||||
if (null === $dateId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null;
|
||||
|
||||
return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotel code string to its corresponding hotel ID.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Enum\BusLeg;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Mailjet\ApiClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Creates Mailjet contact lists for a travel (all participants, bus passengers only) and deletes
|
||||
* them again 30 days after the travel started, together with every contact that is on no other
|
||||
* Mailjet list.
|
||||
*/
|
||||
class TravelMailingListManager
|
||||
{
|
||||
private const string DELETE_AFTER = '+30 days';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $mailjet,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $recipients
|
||||
* @param ?BusLeg $busLeg the bus leg the recipients were filtered by, if any
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function create(string $travelCode, array $recipients, ?BusLeg $busLeg, \DateTimeImmutable $travelStartsAt): TravelMailingList
|
||||
{
|
||||
$listId = $this->mailjet->createContactsList(TravelMailingList::nameFor($travelCode, $busLeg));
|
||||
|
||||
// Tracked before the contacts go in, so a list Mailjet already holds is never left without a row
|
||||
// that the cleanup would find.
|
||||
$mailingList = new TravelMailingList($travelCode, $listId, $busLeg, $travelStartsAt->modify(self::DELETE_AFTER));
|
||||
$this->entityManager->persist($mailingList);
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients));
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->discardEmptyList($mailingList);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logger->info('Created Mailjet travel mailing list', [
|
||||
'travel_code' => $travelCode,
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $listId,
|
||||
'mailjet_job_id' => $jobId,
|
||||
'bus_leg' => $busLeg?->value,
|
||||
'recipients' => count($recipients),
|
||||
]);
|
||||
|
||||
return $mailingList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all lists of one travel together. Taken one at a time, a contact on both would
|
||||
* never count as orphaned: Mailjet still reports a list for 60 days after its deletion.
|
||||
*
|
||||
* The orphaned contacts go first and the lists last, so a run that fails halfway can be
|
||||
* repeated: the lists still name whoever is left.
|
||||
*
|
||||
* @param TravelMailingList[] $mailingLists
|
||||
*
|
||||
* @return int the number of contacts deleted
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function delete(array $mailingLists): int
|
||||
{
|
||||
$listIds = array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $mailingLists);
|
||||
|
||||
$contactIds = [];
|
||||
foreach ($listIds as $listId) {
|
||||
array_push($contactIds, ...$this->mailjet->getListContactIds($listId));
|
||||
}
|
||||
|
||||
$deletedContacts = 0;
|
||||
foreach (array_unique($contactIds) as $contactId) {
|
||||
// Anyone who is, or recently was, on another list is kept: their newsletter consent or
|
||||
// another travel's mailing still needs the contact.
|
||||
if ([] !== array_diff($this->mailjet->getContactListIds($contactId), $listIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailjet->deleteContact($contactId);
|
||||
++$deletedContacts;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted orphaned travel mailing contacts', [
|
||||
'mailjet_list_ids' => $listIds,
|
||||
'deleted_contacts' => $deletedContacts,
|
||||
]);
|
||||
|
||||
foreach ($mailingLists as $mailingList) {
|
||||
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
|
||||
|
||||
$this->entityManager->remove($mailingList);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Deleted Mailjet travel mailing list', [
|
||||
'travel_code' => $mailingList->getTravelCode(),
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $deletedContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $recipients
|
||||
*
|
||||
* @return list<array{email: string, firstName: ?string}>
|
||||
*/
|
||||
private function buildContacts(array $recipients): array
|
||||
{
|
||||
// Several participants can share one address; the first name that comes with it wins.
|
||||
$contacts = [];
|
||||
foreach ($recipients as $recipient) {
|
||||
$email = mb_strtolower(trim($recipient->email));
|
||||
if ('' === $email || true === isset($contacts[$email])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contacts[$email] = [
|
||||
'email' => $email,
|
||||
'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($contacts);
|
||||
}
|
||||
|
||||
private function discardEmptyList(TravelMailingList $mailingList): void
|
||||
{
|
||||
try {
|
||||
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
// The row stays, so the scheduled cleanup removes the list later.
|
||||
$this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [
|
||||
'travel_code' => $mailingList->getTravelCode(),
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entityManager->remove($mailingList);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraints\Regex;
|
||||
use Symfony\Component\Validator\Constraints\RegexValidator;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Limits a phone number to digits, spaces, slashes and hyphens, with an optional leading plus.
|
||||
*
|
||||
* Only applied to numbers the user entered or changed: BusPro already holds numbers that break
|
||||
* this rule, and one loaded from BusPro has to go back unchanged, or an edit is rejected for the
|
||||
* mismatch. The DTOs remember the loaded number and gate the constraint with isNewlyEntered().
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
|
||||
class PhoneNumber extends Regex
|
||||
{
|
||||
public const PATTERN = '~^\+?[0-9][0-9 /\-]*$~';
|
||||
|
||||
/** @param string[]|null $groups */
|
||||
public function __construct(?array $groups = null, mixed $payload = null)
|
||||
{
|
||||
parent::__construct(
|
||||
pattern: self::PATTERN,
|
||||
message: 'Bitte nur Ziffern, Leerzeichen, /, - und ein führendes + verwenden',
|
||||
groups: $groups,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
public static function isNewlyEntered(?string $value, ?string $loaded): bool
|
||||
{
|
||||
return u($value ?? '')->trim()->toString() !== u($loaded ?? '')->trim()->toString();
|
||||
}
|
||||
|
||||
public function validatedBy(): string
|
||||
{
|
||||
return RegexValidator::class;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./templates/**/*.twig',
|
||||
'./src/**/*.php',
|
||||
],
|
||||
safelist: ['rotate-90'],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Lato', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: '#3d8ccb',
|
||||
secondary: '#f9c700',
|
||||
'primary-bg': '#ebf3fa',
|
||||
'primary-light': '#0070e0',
|
||||
'primary-medium': '#165883',
|
||||
'primary-dark': '#18527b',
|
||||
pink: '#fa1a8c',
|
||||
sbw: {
|
||||
primary: '#B3820F',
|
||||
secondary: '#E91C26',
|
||||
'primary-dark': '#8D7207',
|
||||
overlay: '#c20000',
|
||||
},
|
||||
snz: {
|
||||
primary: '#82c3c6',
|
||||
secondary: '#f9c33d',
|
||||
'primary-dark': '#82c3c6',
|
||||
},
|
||||
ser: {
|
||||
primary: '#006fb6',
|
||||
secondary: '#DE2C1B',
|
||||
'primary-light': '#04A4CC',
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'badge-gradient': 'linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%)',
|
||||
'brand-gradient': 'linear-gradient(to top right, #009fff, #165883, #18527b)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
{% if app.session.flashBag.peek('info')|length > 0 %}
|
||||
{% include '_partials/_toast.html.twig' with { level: 'info', messages: app.flashes('info') } %}
|
||||
{% endif %}
|
||||
{% if app.session.flashBag.peek('warning')|length > 0 %}
|
||||
{% include '_partials/_toast.html.twig' with { level: 'warning', messages: app.flashes('warning') } %}
|
||||
{% endif %}
|
||||
@@ -28,7 +28,7 @@
|
||||
name="q"
|
||||
value="{{ filter.q }}"
|
||||
placeholder="{{ form.q.vars.attr.placeholder|default('Suchen…') }}"
|
||||
class="block w-64 rounded-md border-0 py-1.5 pl-9 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary text-sm">
|
||||
class="block w-64 rounded-md border-0 py-1.5 pl-9 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary text-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-2.5 pointer-events-none">
|
||||
{{ icon('search', 'w-4 h-4 text-gray-500') }}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div id="loading-indicator"
|
||||
class="invisible fixed top-0 left-0 inset-0 z-[60] backdrop-blur-sm flex items-center justify-center transition-all duration-100">
|
||||
class="invisible fixed top-0 left-0 inset-0 z-[60] backdrop-blur-xs flex items-center justify-center transition-all duration-100">
|
||||
{% include '_partials/_spinner.html.twig' %}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
|
||||
<div class="absolute inset-0 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute inset-0 backdrop-blur-xs" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
|
||||
<div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-yellow-500': level == 'warning', 'bg-primary-light': level == 'info' }) }}">
|
||||
<div class="flex justify-between pb-4 px-4">
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<div class="w-64">
|
||||
{{ form_row(form.travelCode) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_row(form.busOnly) }}
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="button button--primary button--small">
|
||||
abrufen
|
||||
@@ -26,7 +29,7 @@
|
||||
{% if result.data is empty %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'warning',
|
||||
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)],
|
||||
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer%s.'|format(result.travelCode|e, result.busLeg ? ' unter den ' ~ result.busLeg.label : '')],
|
||||
} %}
|
||||
{% else %}
|
||||
<div class="pb-4">
|
||||
@@ -34,9 +37,41 @@
|
||||
{{ travel_label|default('Reise nicht im lokalen Reisebestand') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>
|
||||
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>{% if result.busLeg %}, nur {{ result.busLeg.label }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pb-6 text-sm">
|
||||
{% for list in mailing_lists %}
|
||||
<p>
|
||||
Mailjet-Liste <code>{{ list.name }}</code> (ID {{ list.mailjetListId }},
|
||||
{{ list.busLeg ? 'nur ' ~ list.busLeg.label : 'alle Teilnehmer' }})
|
||||
am {{ list.createdAt|date('d.m.Y, H:i') }} angelegt,
|
||||
wird ab {{ list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
|
||||
</p>
|
||||
{% endfor %}
|
||||
{% if mailing_list is not null %}
|
||||
{# the list for this filter exists and is listed above #}
|
||||
{% elseif travel_starts_at is null %}
|
||||
<p class="text-gray-700">
|
||||
Kein Reisebeginn bekannt, daher kann keine Mailjet-Liste angelegt werden.
|
||||
</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_admin_travelmailing_createlist') }}"{% if mailing_lists is not empty %} class="pt-2"{% endif %}>
|
||||
<input type="hidden" name="travelCode" value="{{ result.travelCode }}">
|
||||
{% if result.busLeg %}
|
||||
<input type="hidden" name="busOnly" value="1">
|
||||
{% endif %}
|
||||
<input type="hidden" name="_token" value="{{ csrf_token(constant('App\\Controller\\Admin\\TravelMailing\\CreateListController::CSRF_TOKEN_ID')) }}">
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
Mailjet-Liste {{ list_name }} anlegen
|
||||
</button>
|
||||
<span class="pl-2 text-gray-700">
|
||||
Alle Empfänger werden ohne Opt-in eingetragen; die Liste wird 30 Tage nach Reisebeginn
|
||||
({{ travel_starts_at|date('d.m.Y') }}) gelöscht.
|
||||
</span>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<div class="py-4" id="participant-card-{{ index }}">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="{{ html_classes('w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full font-bold', {
|
||||
<div class="{{ html_classes('w-12 h-12 inline-flex shrink-0 items-center justify-center rounded-full font-bold', {
|
||||
'text-white': not showAsAttention,
|
||||
'bg-yellow-500': showAsAttention,
|
||||
'bg-primary-dark': not showAsError and not showAsAttention and booking_theme() == 'base',
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
{% import _self as macros %}
|
||||
|
||||
<div class="flex items-center gap-4 pb-4 mb-4 border-b border-primary-bg">
|
||||
<div class="{{ html_classes('w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full text-white text-2xl font-bold', {
|
||||
<div class="{{ html_classes('w-12 h-12 inline-flex shrink-0 items-center justify-center rounded-full text-white text-2xl font-bold', {
|
||||
'bg-primary-dark': booking_theme() == 'base',
|
||||
'bg-sbw-primary': booking_theme() == 'sbw',
|
||||
'bg-snz-primary-dark': booking_theme() == 'snz',
|
||||
@@ -365,7 +365,7 @@
|
||||
{{ form_errors(form.board) }}
|
||||
{% endif %}
|
||||
<div class="text-sm mt-2 px-2 flex items-start space-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span>Bitte teile uns eventuelle Unverträglichkeiten an <a href="mailto:{{ customer_service_email }}" class="text-primary-light hover:underline">{{ customer_service_email }}</a> (inkl. der Buchungsnummer) mit.</span>
|
||||
@@ -425,7 +425,7 @@
|
||||
{{ form_help(form.rentals) }}
|
||||
{% if is_travel_start_cutoff_reached(bookingDto) %}
|
||||
<div class="text-sm mt-2 px-2 flex items-start space-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span>Online nicht mehr buchbar. Bitte direkt über die <strong>Hausleitung vor Ort</strong> anfragen (vorbehaltlich Verfügbarkeit)</span>
|
||||
@@ -440,7 +440,7 @@
|
||||
{{ form_row(form.bodyDimensions.shoeSize) }}
|
||||
{{ form_row(form.bodyDimensions.weight) }}
|
||||
<div class="lg:col-span-3 text-sm -mt-2 px-2 flex items-start space-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span>{{ form.bodyDimensions.vars.help }}</span>
|
||||
@@ -590,7 +590,7 @@
|
||||
{{ form_errors(form.insurance) }}
|
||||
{{ form_help(form.insurance) }}
|
||||
<div class="text-sm mt-2 px-2 flex items-start space-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span>Eine Versicherung kann nur abgeschlossen werden, wenn Dein Wohnsitz in Deutschland ist.</span>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
{# No form_start here: this sits inside the step 2 form and nested
|
||||
form tags are invalid. htmx posts the inputs enclosed below,
|
||||
including the CSRF token, to the toggle route. #}
|
||||
<div class="mb-6 rounded border border-gray-200 bg-white p-4"
|
||||
<div class="mb-6 rounded-sm border border-gray-200 bg-white p-4"
|
||||
hx-post="{{ path('app_booking_create_step_2_applicant_toggle') }}"
|
||||
hx-trigger="change"
|
||||
hx-include="this"
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{{ form_row(form.bankAccount.accountHolder) }}
|
||||
{{ form_row(form.bankAccount.bankName) }}
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-sm">
|
||||
{{ form_row(form.bankAccount.sepaMandateAccepted, {
|
||||
'label_attr': {'class': 'text-sm'}
|
||||
}) }}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% for booking in bookings %}
|
||||
<div class="grid md:grid-cols-2 gap-4 p-2 bg-white rounded-md">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
|
||||
<div class="w-12 h-12 inline-flex shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="relative inline-block text-left group cursor-pointer">
|
||||
<button type="button"
|
||||
class="flex items-center text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-0"
|
||||
class="flex items-center text-gray-400 hover:text-gray-600 focus:outline-hidden focus:ring-0"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true">
|
||||
<span class="sr-only">Funktionen</span>
|
||||
@@ -11,7 +11,7 @@
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
tabindex="-1">
|
||||
<div class="rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div class="rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-hidden">
|
||||
<div class="py-1 menu menu--pulldown" role="none">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
<div {{ stimulus_target('multiselect', 'label') }} class="flex-1 min-h-8 py-1.5 text-gray-900 text-sm"></div>
|
||||
<svg class="w-4 h-4 mt-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="12"/><circle cx="128" cy="60" r="12"/><circle cx="128" cy="196" r="12"/></svg>
|
||||
</div>
|
||||
<div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-scroll px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden">
|
||||
<div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-auto px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden">
|
||||
{% for item in form.children %}
|
||||
{{ form_row(item, { 'attr': { 'data-action': 'multiselect#update', 'data-multiselect-target': 'choice', 'data-label': item.vars.label } }) }}
|
||||
{% endfor %}
|
||||
@@ -286,7 +286,7 @@
|
||||
|
||||
{{- form_widget(child, {'attr': child_attr}) -}}
|
||||
{% if not loop.last %}
|
||||
<span class="block flex-shrink-0">.</span>
|
||||
<span class="block shrink-0">.</span>
|
||||
{% endif %}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{%- block form_widget_simple -%}
|
||||
{%- set type = type|default('text') -%}
|
||||
{%- if type != 'hidden' -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%}
|
||||
{% else %}
|
||||
@@ -49,7 +49,7 @@
|
||||
{%- endblock form_widget_simple -%}
|
||||
|
||||
{%- block textarea_widget -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%}
|
||||
{% else %}
|
||||
@@ -62,7 +62,7 @@
|
||||
{%- endblock textarea_widget -%}
|
||||
|
||||
{%- block checkbox_widget -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' h-4 w-4 rounded text-primary')|trim }) -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' h-4 w-4 rounded-sm text-primary')|trim }) -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' border-red-500 text-red-500 ring-red-500 focus:ring-red-500' }) -%}
|
||||
{% else %}
|
||||
@@ -89,7 +89,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{%- block choice_widget_collapsed -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%}
|
||||
{% else %}
|
||||
@@ -154,7 +154,7 @@
|
||||
{%- set minDate = form.vars.min_date ? form.vars.min_date | date('Y-m-d') : null -%}
|
||||
{%- set maxDate = form.vars.max_date ? form.vars.max_date | date('Y-m-d') : null -%}
|
||||
{%- set displayValue = value ? value|date('d.m.Y') : '' -%}
|
||||
{%- set inputClass = 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm' -%}
|
||||
{%- set inputClass = 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm' -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set inputClass = inputClass ~ ' ring-red-500 placeholder:red-500 focus:ring-red-500' -%}
|
||||
{%- else -%}
|
||||
@@ -181,7 +181,7 @@
|
||||
{%- endblock datepicker_widget %}
|
||||
|
||||
{%- block autocomplete_widget -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' autocomplete-input block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- set attr = attr|merge({'class': (attr.class|default('') ~ ' autocomplete-input block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%}
|
||||
{%- if errors|length -%}
|
||||
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%}
|
||||
{% else %}
|
||||
@@ -227,7 +227,7 @@
|
||||
<div {{ stimulus_target('multiselect', 'label') }} class="flex-1 min-h-8 py-1.5 text-gray-900 text-sm"></div>
|
||||
{{ icon('dots', 'w-4 h-4 mt-2') }}
|
||||
</div>
|
||||
<div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-scroll px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden">
|
||||
<div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-auto px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden">
|
||||
{% for item in form.children %}
|
||||
{{ form_row(item, { 'attr': { 'data-action': 'multiselect#update', 'data-multiselect-target': 'choice', 'data-label': item.vars.label } }) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
} %}
|
||||
{% block body %}
|
||||
{% if region.altitude or region.length or region.lifts %}
|
||||
<ul class="flex flex-wrap gap-x-6 text-sm not-prose">
|
||||
<ul class="flex flex-wrap gap-x-6 text-sm">
|
||||
{% if region.altitude %}
|
||||
<li>{{ region.altitude }} m Höhe</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div id="htmx-modal"
|
||||
class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
|
||||
<div class="absolute inset-0 w-full h-full bg-gray-300/50 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute inset-0 w-full h-full bg-gray-300/50 backdrop-blur-xs" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
|
||||
<div class="relative bg-white py-8 rounded-md shadow-lg">
|
||||
<div class="flex justify-between pb-4 px-8">
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{{ icon('close', 'w-8 h-8') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
|
||||
<div class="w-full h-full p-8 pt-0 overflow-y-auto">
|
||||
{% if app.user %}
|
||||
{{ knp_menu_render(knp_menu_get('admin_main')) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% if voucherThumbnail %}
|
||||
<img src="{{ asset(voucherThumbnail) }}"
|
||||
alt="Motiv {{ voucher.template.label }}"
|
||||
class="block w-full max-w-sm h-auto mb-4 shadow">
|
||||
class="block w-full max-w-sm h-auto mb-4 shadow-sm">
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-primary-bg mb-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\BpnConnect;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\Enum\BusLeg;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
@@ -35,12 +36,49 @@ class TravelsClientTest extends TestCase
|
||||
self::assertSame('https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126', $requests[0][1]);
|
||||
self::assertContains('X-API-KEY: secret', $requests[0][2]['headers']);
|
||||
self::assertSame('LPJMP281126', $response->travelCode);
|
||||
self::assertNull($response->busLeg);
|
||||
self::assertEquals([
|
||||
new TravelMailingRecipient('[email protected]', 'Erika', 'Muster'),
|
||||
new TravelMailingRecipient('[email protected]', 'Max', 'Muster'),
|
||||
], $response->data);
|
||||
}
|
||||
|
||||
public function testGetMailingRecipientsPassesTheBusLegAndAcceptsItsEcho(): void
|
||||
{
|
||||
$requests = [];
|
||||
$http = new MockHttpClient(function (string $method, string $url) use (&$requests): MockResponse {
|
||||
$requests[] = $url;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'meta' => ['travel_code' => 'LPJMP281126', 'bus_leg' => 'outbound', 'row_count' => 1, 'execution_time_ms' => 12],
|
||||
'data' => [
|
||||
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$response = $this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
|
||||
|
||||
self::assertSame(['https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126&busLeg=outbound'], $requests);
|
||||
self::assertSame(BusLeg::Outbound, $response->busLeg);
|
||||
self::assertCount(1, $response->data);
|
||||
}
|
||||
|
||||
public function testABusLegTheServerDidNotApplyIsRejected(): void
|
||||
{
|
||||
// What a bpn-connect without the filter answers: everyone, and no bus_leg in meta.
|
||||
$http = new MockHttpClient($this->jsonResponse([
|
||||
'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 1, 'execution_time_ms' => 12],
|
||||
'data' => [
|
||||
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->expectException(BpnConnectException::class);
|
||||
|
||||
$this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
|
||||
}
|
||||
|
||||
public function testAnUnknownTravelCodeYieldsAnEmptyList(): void
|
||||
{
|
||||
$http = new MockHttpClient($this->jsonResponse([
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user