Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b755a6f43e | ||
|
|
0a55544ca8 | ||
|
|
3c81c79819 | ||
|
|
e51664df6c | ||
|
|
f3dd122678 | ||
|
|
a628e96d06 | ||
|
|
2bb0148e49 | ||
|
|
289741d5d4 | ||
|
|
f070f4117c | ||
|
|
72ef3ff105 | ||
|
|
1301df80c2 | ||
|
|
56deb1e24b | ||
|
|
ec10ca3623 | ||
|
|
67c0c2fb0c | ||
|
|
eb45693257 | ||
|
|
5fdee68ccd | ||
|
|
7001bc0cf2 | ||
|
|
972839286c | ||
|
|
ede8fd840a | ||
|
|
6c700ea18b | ||
|
|
fe18ebf479 | ||
|
|
1d42565a84 | ||
|
|
272c811f4f | ||
|
|
c2c55bc8b2 | ||
|
|
8a6ce7606f | ||
|
|
7ec21c1cac | ||
|
|
f951242465 | ||
|
|
7dadb6806a | ||
|
|
6b539f43f3 | ||
|
|
1845441bc2 | ||
|
|
33c31d088c |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: myep
|
name: myep
|
||||||
type: php
|
type: php
|
||||||
docroot: public
|
docroot: public
|
||||||
php_version: "8.4"
|
php_version: "8.5"
|
||||||
webserver_type: apache-fpm
|
webserver_type: apache-fpm
|
||||||
xdebug_enabled: false
|
xdebug_enabled: false
|
||||||
additional_hostnames:
|
additional_hostnames:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keeps buttons disabled until the given fields pass the browser's constraint validation
|
||||||
|
* (required, min, max, step, …), which the form types render from the same rules the server
|
||||||
|
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
|
||||||
|
*
|
||||||
|
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['field', 'button']
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|
||||||
|
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
|
||||||
|
this.buttonTargets.forEach(button => button.disabled = invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,13 @@ export default class extends Controller {
|
|||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.itemsCountValue = this.fieldTargets.length
|
this.itemsCountValue = this.fieldTargets.length
|
||||||
|
// A running counter rather than the current count: after a remove, count + 1 can be an
|
||||||
|
// index a remaining entry still holds, and the two would submit into the same slot.
|
||||||
|
this.nextIndex = this.fieldTargets.length
|
||||||
}
|
}
|
||||||
|
|
||||||
addItem() {
|
addItem() {
|
||||||
const index = this.fieldTargets.length + 1
|
const index = this.nextIndex++
|
||||||
const prototype = JSON.parse(this.prototypeValue)
|
const prototype = JSON.parse(this.prototypeValue)
|
||||||
const newField = prototype.replace(/__name__/g, index)
|
const newField = prototype.replace(/__name__/g, index)
|
||||||
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
|
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shows an indicator over an iframe until its content has loaded.
|
||||||
|
*
|
||||||
|
* The controller sets the frame's src itself, after the load listener is attached, so a fast
|
||||||
|
* response cannot finish before anybody listens.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['frame', 'indicator']
|
||||||
|
static values = {src: String}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.loaded = this.loaded.bind(this)
|
||||||
|
this.frameTarget.addEventListener('load', this.loaded)
|
||||||
|
this.frameTarget.src = this.srcValue
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.frameTarget.removeEventListener('load', this.loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded() {
|
||||||
|
this.indicatorTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.4",
|
"php": ">=8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"league/flysystem-bundle": "^3.4",
|
"league/flysystem-bundle": "^3.4",
|
||||||
"league/flysystem-sftp-v3": "^3.29",
|
"league/flysystem-sftp-v3": "^3.29",
|
||||||
"league/oauth2-server-bundle": "^1.0",
|
"league/oauth2-server-bundle": "^1.0",
|
||||||
"nelexa/zip": "^4.0",
|
"maennchen/zipstream-php": "^3.2",
|
||||||
"nesbot/carbon": "^3.8",
|
"nesbot/carbon": "^3.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpoffice/phpspreadsheet": "^5.4",
|
"phpoffice/phpspreadsheet": "^5.4",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.4.24"
|
"php": "8.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
Generated
+245
-318
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@ league_oauth2_server:
|
|||||||
private_key_passphrase: null
|
private_key_passphrase: null
|
||||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||||
require_code_challenge_for_public_clients: true
|
require_code_challenge_for_public_clients: true
|
||||||
enable_client_credentials_grant: false
|
enable_client_credentials_grant: true
|
||||||
enable_auth_code_grant: true
|
enable_auth_code_grant: true
|
||||||
enable_refresh_token_grant: true
|
enable_refresh_token_grant: true
|
||||||
enable_password_grant: false
|
enable_password_grant: false
|
||||||
|
|||||||
+43
-11
@@ -128,18 +128,22 @@ services:
|
|||||||
- '../src/Model'
|
- '../src/Model'
|
||||||
- '../src/Kernel.php'
|
- '../src/Kernel.php'
|
||||||
|
|
||||||
App\BusProNet\ApiClient:
|
App\BusProNet\Transport\XmlChannel:
|
||||||
arguments:
|
arguments:
|
||||||
$logger: '@monolog.logger.bpn'
|
$logger: '@monolog.logger.bpn'
|
||||||
$options:
|
$options:
|
||||||
bpn_username: '%env(APP_BPN_USER)%'
|
bpn_username: '%env(APP_BPN_USER)%'
|
||||||
bpn_password: '%env(APP_BPN_PASSWORD)%'
|
bpn_password: '%env(APP_BPN_PASSWORD)%'
|
||||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
|
||||||
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
|
|
||||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||||
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
|
||||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
App\BusProNet\Transport\SocketTransport:
|
||||||
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
$host: '%env(APP_BPN_IP)%'
|
||||||
|
$ports: '%env(csv:APP_BPN_PORTS)%'
|
||||||
|
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||||
|
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||||
|
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||||
|
|
||||||
App\Twig\AppRuntime:
|
App\Twig\AppRuntime:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -207,6 +211,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\BusProNet\Utility\CountryCodeMapper:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\BusProNet\XmlLoader\HotelLoader:
|
App\BusProNet\XmlLoader\HotelLoader:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
@@ -219,11 +227,40 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\Service\TravelExportBuilder:
|
||||||
|
arguments:
|
||||||
|
$cache: '@bpn.cache'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
App\Service\ProductExportBuilder:
|
||||||
|
arguments:
|
||||||
|
$cache: '@bpn.cache'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\Service\VoucherValidator:
|
App\Service\VoucherValidator:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@cache.app'
|
$cache: '@cache.app'
|
||||||
$logger: '@monolog.logger.bpn'
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
# Voucher purchase flow (/gutschein)
|
||||||
|
App\Service\VoucherTemplateProvider:
|
||||||
|
arguments:
|
||||||
|
$cache: '@bpn.cache'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
App\Service\VoucherThumbnailResolver:
|
||||||
|
arguments:
|
||||||
|
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
App\Controller\Voucher\Step1Controller:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
App\Controller\Voucher\Step3Controller:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
# Field Handlers with dependencies
|
# Field Handlers with dependencies
|
||||||
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
||||||
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
||||||
@@ -386,8 +423,3 @@ services:
|
|||||||
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||||
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||||
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||||
|
|
||||||
league.oauth2_server.grant.client_credentials:
|
|
||||||
class: League\OAuth2\Server\Grant\ClientCredentialsGrant
|
|
||||||
tags:
|
|
||||||
- { name: league.oauth2_server.authorization_server.grant, accessTokenTTL: 'PT1H' }
|
|
||||||
+288
-8
@@ -10,8 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
|
|||||||
|
|
||||||
- Token endpoint: `POST /token` (unauthenticated).
|
- Token endpoint: `POST /token` (unauthenticated).
|
||||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||||
- Access token TTL depends on the grant: **1 hour** for client_credentials, **10 minutes** for authorization_code.
|
- Access token TTL is **10 minutes** for every grant.
|
||||||
Do not hardcode either value — read `expires_in` from the token response, it is authoritative and may be tuned.
|
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||||
|
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
|
||||||
|
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
|
||||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||||
- Send the token as `Authorization: Bearer <access_token>`.
|
- Send the token as `Authorization: Bearer <access_token>`.
|
||||||
@@ -48,7 +50,9 @@ There is no single error envelope; three shapes occur:
|
|||||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||||
|
|
||||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||||
|
|
||||||
|
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||||||
|
|
||||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||||
|
|
||||||
@@ -60,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
|
|||||||
|
|
||||||
#### `GET /api/hotels` — scope `api`
|
#### `GET /api/hotels` — scope `api`
|
||||||
|
|
||||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||||||
|
`costUnit`, `countryCode`.
|
||||||
|
|
||||||
|
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||||||
|
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||||||
|
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||||||
|
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||||||
|
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||||||
|
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||||||
|
the mapping table is extended.
|
||||||
|
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||||||
|
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||||||
|
|
||||||
#### `GET /api/hotels/{id}` — scope `api`
|
#### `GET /api/hotels/{id}` — scope `api`
|
||||||
|
|
||||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||||||
|
`404 {"message":"Not found"}` if unknown.
|
||||||
|
|
||||||
#### `GET /api/countries` — scope `api`
|
#### `GET /api/countries` — scope `api`
|
||||||
|
|
||||||
@@ -76,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
|
|||||||
|
|
||||||
#### `GET /api/last-update` — scope `api`
|
#### `GET /api/last-update` — scope `api`
|
||||||
|
|
||||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
|
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
|
||||||
|
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
|
||||||
|
status 200 — treat null as "unknown", not as "changed".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,6 +141,262 @@ This is a full overwrite, not a merge.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Bulk exports
|
||||||
|
|
||||||
|
#### `GET /api/exports/travels` — scope `api`
|
||||||
|
|
||||||
|
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||||||
|
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||||||
|
|
||||||
|
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
|
||||||
|
|
||||||
|
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||||||
|
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||||||
|
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||||||
|
| `perPage` | int 1–1000 | `500` | |
|
||||||
|
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||||||
|
|
||||||
|
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||||||
|
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||||||
|
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"busProId": 12794,
|
||||||
|
"code": "PCJSER261226",
|
||||||
|
"product": "Semesterende - Portes du Soleil - Joliment",
|
||||||
|
"dateFrom": "2026-12-26",
|
||||||
|
"dateTo": "2026-12-30",
|
||||||
|
"hotel": {
|
||||||
|
"busProId": 225444,
|
||||||
|
"code": "PCJSER1",
|
||||||
|
"name": "NEU SER Jolimont 1",
|
||||||
|
"costUnit": "863",
|
||||||
|
"country": "CH",
|
||||||
|
"countryCode": "ch"
|
||||||
|
},
|
||||||
|
"pickups": [
|
||||||
|
{
|
||||||
|
"busProId": 18,
|
||||||
|
"code": "ESS-Route",
|
||||||
|
"city": "Essen",
|
||||||
|
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||||
|
"time": "20:15",
|
||||||
|
"datetime": "2026-12-25T20:15:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 500,
|
||||||
|
"total": 415,
|
||||||
|
"totalPages": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"pickupsWithoutMasterData": 0,
|
||||||
|
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
|
||||||
|
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||||||
|
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||||
|
empty, never null.
|
||||||
|
|
||||||
|
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
|
||||||
|
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||||
|
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||||
|
|
||||||
|
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||||
|
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||||||
|
lost rows you previously had.
|
||||||
|
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||||||
|
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||||||
|
with no name is more useful to a disposition view than one that silently vanished.
|
||||||
|
|
||||||
|
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||||||
|
|
||||||
|
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
|
||||||
|
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
|
||||||
|
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
|
||||||
|
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
|
||||||
|
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
|
||||||
|
an offset to compare or sort them; they already sort correctly as strings.
|
||||||
|
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
|
||||||
|
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
|
||||||
|
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
|
||||||
|
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
|
||||||
|
`datetime` carries that resolution for bare-time sources too.
|
||||||
|
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||||
|
null together.
|
||||||
|
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||||||
|
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||||||
|
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||||||
|
|
||||||
|
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||||||
|
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||||
|
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||||
|
|
||||||
|
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
|
||||||
|
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
|
||||||
|
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
|
||||||
|
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
|
||||||
|
|
||||||
|
#### `GET /api/exports/products` — scope `api`
|
||||||
|
|
||||||
|
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
|
||||||
|
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
|
||||||
|
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
|
||||||
|
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
|
||||||
|
|
||||||
|
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
|
||||||
|
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
|
||||||
|
them a website shows, and how, is the client's rule, not this endpoint's.
|
||||||
|
|
||||||
|
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
|
||||||
|
returns it in one page.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"product": { "code": "DKIWE", "busProId": 1127 },
|
||||||
|
"date": {
|
||||||
|
"code": "DKIWE041226",
|
||||||
|
"busProId": 12437,
|
||||||
|
"from": "2026-12-04",
|
||||||
|
"to": "2026-12-06",
|
||||||
|
"type": "F"
|
||||||
|
},
|
||||||
|
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
|
||||||
|
"pseudoPrice": null,
|
||||||
|
"selectionGroups": [
|
||||||
|
{
|
||||||
|
"busProId": 7,
|
||||||
|
"label": "Leistungen Skipass",
|
||||||
|
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transportServices": [
|
||||||
|
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
|
||||||
|
],
|
||||||
|
"dateServices": [
|
||||||
|
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||||
|
],
|
||||||
|
"hotelServices": [
|
||||||
|
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||||
|
],
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"busProId": 74,
|
||||||
|
"code": "MBZET",
|
||||||
|
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
|
||||||
|
"category": "GR",
|
||||||
|
"minPax": 1,
|
||||||
|
"maxPax": 1,
|
||||||
|
"nights": 2,
|
||||||
|
"price": 229,
|
||||||
|
"available": 24,
|
||||||
|
"status": "Frei",
|
||||||
|
"boardCode": "Lt. Auss.",
|
||||||
|
"boardBusProId": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pickups": [
|
||||||
|
{
|
||||||
|
"busProId": 4,
|
||||||
|
"code": "FRA-Route",
|
||||||
|
"city": "Frankfurt",
|
||||||
|
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||||
|
"price": 5.9,
|
||||||
|
"datetime": "2026-12-03T23:30:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"guide": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 500,
|
||||||
|
"total": 419,
|
||||||
|
"totalPages": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"pickupsWithoutMasterData": 0,
|
||||||
|
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
|
||||||
|
"generatedAt": "2026-09-30T14:05:00+02:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
|
||||||
|
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
|
||||||
|
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
|
||||||
|
|
||||||
|
**Types.**
|
||||||
|
|
||||||
|
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
|
||||||
|
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
|
||||||
|
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
|
||||||
|
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
|
||||||
|
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
|
||||||
|
|
||||||
|
**Rooms.** One entry per room offered on the date at that hotel.
|
||||||
|
|
||||||
|
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
|
||||||
|
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
|
||||||
|
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
|
||||||
|
empty.
|
||||||
|
|
||||||
|
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
|
||||||
|
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
|
||||||
|
|
||||||
|
**Services.** Three lists, deliberately kept apart:
|
||||||
|
|
||||||
|
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
|
||||||
|
- `dateServices` — the date's general services.
|
||||||
|
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
|
||||||
|
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
|
||||||
|
`busProId`: it identifies a service across both lists.
|
||||||
|
|
||||||
|
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
|
||||||
|
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
|
||||||
|
such as for arriving by car.
|
||||||
|
|
||||||
|
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
|
||||||
|
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
|
||||||
|
|
||||||
|
| `busProId` | Label |
|
||||||
|
|---|---|
|
||||||
|
| 7 | Leistungen Skipass |
|
||||||
|
| 8 | Leistungen Sonstige |
|
||||||
|
| 71 | Leistungen Verpflegung |
|
||||||
|
| 85 | Leistungen Übernachtung |
|
||||||
|
| 86 | Leistungen Programm |
|
||||||
|
| 87 | Leistungen Kurse |
|
||||||
|
| 89 | Leistungen Events |
|
||||||
|
| 90 | Leistungen Transport |
|
||||||
|
|
||||||
|
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
|
||||||
|
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
||||||
|
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
|
||||||
|
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
|
||||||
|
none. It is not levelled against the return leg. An id absent from
|
||||||
|
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
|
||||||
|
of `0` is emitted as-is but not counted.
|
||||||
|
|
||||||
|
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Travels
|
### Travels
|
||||||
|
|
||||||
#### `GET /api/travels` — scope `api`
|
#### `GET /api/travels` — scope `api`
|
||||||
@@ -281,14 +555,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
|||||||
"totalDiscountLabel": null,
|
"totalDiscountLabel": null,
|
||||||
"totalPrice": 123456,
|
"totalPrice": 123456,
|
||||||
"pricingCurrency": "EUR",
|
"pricingCurrency": "EUR",
|
||||||
"pricingVersion": 3,
|
"pricingVersion": 2,
|
||||||
"priceBreakdown": {}
|
"priceBreakdown": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||||
|
|
||||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted.
|
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
|
||||||
|
|
||||||
|
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
|
||||||
|
|
||||||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||||
|
|
||||||
@@ -296,6 +572,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
|
|||||||
|
|
||||||
| field | meaning |
|
| field | meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
|
||||||
|
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
|
||||||
|
| `periodsTotal` | the sum of all `periods[].total` |
|
||||||
|
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
|
||||||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
|
|||||||
key = md5( user + password + date("YYYYMMDD") + satz-typ )
|
key = md5( user + password + date("YYYYMMDD") + satz-typ )
|
||||||
```
|
```
|
||||||
|
|
||||||
Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates
|
Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
|
||||||
the key before sending, so a hand-built payload can carry a placeholder.
|
the key before sending, so a hand-built payload can carry a placeholder.
|
||||||
|
|
||||||
## Satz types
|
## Satz types
|
||||||
@@ -39,7 +39,7 @@ the key before sending, so a hand-built payload can carry a placeholder.
|
|||||||
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
|
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
|
||||||
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
|
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
|
||||||
|
|
||||||
**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the
|
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
|
||||||
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
|
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
|
||||||
Its contract is observed, not specified.
|
Its contract is observed, not specified.
|
||||||
|
|
||||||
@@ -201,11 +201,11 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
|||||||
## How this app maps onto the contract
|
## How this app maps onto the contract
|
||||||
|
|
||||||
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
|
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
|
||||||
`addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them.
|
`addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
|
||||||
|
|
||||||
| Documented element | Sent on create | Sent on edit | Notes |
|
| Documented element | Sent on create | Sent on edit | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
|
||||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||||
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||||
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||||
@@ -253,7 +253,7 @@ booking before relying on it.
|
|||||||
### Gap: edit never dry-runs
|
### Gap: edit never dry-runs
|
||||||
|
|
||||||
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
|
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
|
||||||
`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports
|
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
|
||||||
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
|
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
|
||||||
price *before* mutating a live booking.
|
price *before* mutating a live booking.
|
||||||
|
|
||||||
@@ -312,6 +312,67 @@ answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `prei
|
|||||||
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
|
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
|
||||||
`anzahlung` or errors `1011`-`1018` follow.
|
`anzahlung` or errors `1011`-`1018` follow.
|
||||||
|
|
||||||
|
## Selling vouchers (Kaufgutschein)
|
||||||
|
|
||||||
|
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
|
||||||
|
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
|
||||||
|
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
|
||||||
|
|
||||||
|
| Satz | Request | Response |
|
||||||
|
|---|---|---|
|
||||||
|
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
|
||||||
|
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
|
||||||
|
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
|
||||||
|
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
|
||||||
|
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
|
||||||
|
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<satz typ="GUTSCHEINBUCHUNG"/>
|
||||||
|
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||||
|
<gutscheinart>K</gutscheinart>
|
||||||
|
<idgutscheinvorlage>3</idgutscheinvorlage>
|
||||||
|
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
|
||||||
|
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
|
||||||
|
<kaufpreis>25</kaufpreis>
|
||||||
|
<text/> <!-- Zusatztext; the live shop sends it empty -->
|
||||||
|
<rechnungsempfänger>
|
||||||
|
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
|
||||||
|
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
|
||||||
|
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
|
||||||
|
</rechnungsempfänger>
|
||||||
|
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
|
||||||
|
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
|
||||||
|
live shop also collects a business phone, whose element name is not documented anywhere, so the
|
||||||
|
flow does not collect it.
|
||||||
|
|
||||||
|
### Measured on the test system (2026-10-01)
|
||||||
|
|
||||||
|
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
|
||||||
|
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
|
||||||
|
issued. `idagentur` is not required (619/620 never fired).
|
||||||
|
|
||||||
|
| Variant | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Valid (control), 25 € | `möglich` |
|
||||||
|
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
|
||||||
|
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
|
||||||
|
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
|
||||||
|
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
|
||||||
|
| `empfaenger` missing | `605 Empfängername fehlt` |
|
||||||
|
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
|
||||||
|
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
|
||||||
|
| `strasse` empty | `650 … Strasse` |
|
||||||
|
| `anrede` Herr, `telefonmobil` set | `möglich` |
|
||||||
|
|
||||||
|
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
|
||||||
|
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
|
||||||
|
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
|
||||||
|
shop, which suggests they are shared with production; confirm on the first production read.
|
||||||
|
|
||||||
## Error codes
|
## Error codes
|
||||||
|
|
||||||
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
|
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
|
||||||
@@ -320,7 +381,7 @@ runtime.
|
|||||||
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
|
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
|
||||||
> carrier whose entire content is substituted. `Notification::isError()`
|
> carrier whose entire content is substituted. `Notification::isError()`
|
||||||
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
|
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
|
||||||
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers
|
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
|
||||||
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
|
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
|
||||||
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
|
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
|
||||||
|
|
||||||
@@ -359,8 +420,11 @@ zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs
|
|||||||
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
|
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
|
||||||
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
|
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
|
||||||
|
|
||||||
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm /
|
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
|
||||||
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
|
605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
|
||||||
|
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
|
||||||
|
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
|
||||||
|
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
|
||||||
|
|
||||||
## Applying this: the teamer placeholder booking
|
## Applying this: the teamer placeholder booking
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -9,7 +9,7 @@ Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (liv
|
|||||||
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
|
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
|
||||||
the document that drives the portal's entire authorization model.
|
the document that drives the portal's entire authorization model.
|
||||||
|
|
||||||
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends
|
Request side: `AccountClient::getCrmAttributes()` (`src/BusProNet/Client/AccountClient.php`) sends
|
||||||
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
|
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
|
||||||
Response side: `CrmAttributesResponseParser`
|
Response side: `CrmAttributesResponseParser`
|
||||||
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
|
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
|
||||||
@@ -229,7 +229,7 @@ Three conclusions:
|
|||||||
`dbo.AgenturSelektionenKunde` is a bare two-column link table,
|
`dbo.AgenturSelektionenKunde` is a bare two-column link table,
|
||||||
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
|
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
|
||||||
portal authenticates against BusPro as an **agency** account (`bpn_username` in
|
portal authenticates against BusPro as an **agency** account (`bpn_username` in
|
||||||
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see
|
`XmlChannel`), so a per-agency whitelist of which customer selections that agency may see
|
||||||
and/or edit is exactly the right shape. It is the only candidate found so far that could
|
and/or edit is exactly the right shape. It is the only candidate found so far that could
|
||||||
explain the `1478` omission **and** `aenderbar` through one mechanism.
|
explain the `1478` omission **and** `aenderbar` through one mechanism.
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ and they are long shots. Prefer the escalation path below.
|
|||||||
Escalation path, cheapest first:
|
Escalation path, cheapest first:
|
||||||
|
|
||||||
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
|
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
|
||||||
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()`
|
`xml-sync` cache tag covers the XML *export* sync, and `AccountClient::getCrmAttributes()`
|
||||||
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
|
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
|
||||||
from a response captured after the insert, the staleness is on the BusProNet host and
|
from a response captured after the insert, the staleness is on the BusProNet host and
|
||||||
an application-pool/service restart there is the test.
|
an application-pool/service restart there is the test.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
> per-endpoint migration verdicts and the `frei` discovery SQL.
|
> per-endpoint migration verdicts and the `frei` discovery SQL.
|
||||||
|
|
||||||
This complements `travel-queries.md`. That doc replaced the **XML export** (static
|
This complements `travel-queries.md`. That doc replaced the **XML export** (static
|
||||||
catalog). This one covers the **live socket API** methods on
|
catalog). This one covers the **live socket API** methods of the clients in
|
||||||
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL
|
`src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
|
||||||
query in the Go proxy.
|
query in the Go proxy.
|
||||||
|
|
||||||
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
|
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
|
||||||
|
* priced exactly as before.
|
||||||
|
*/
|
||||||
|
final class Version20260930150445 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
|
||||||
|
$this->addSql('DROP TABLE accommodation_booking_period');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Client;
|
||||||
|
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Model\CrmAttributes;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
||||||
|
use App\BusProNet\Model\RegistrationResponse;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
|
use App\Form\Model\RegistrationDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer's BusPro account: login, address data, newsletter, CRM selections.
|
||||||
|
*/
|
||||||
|
class AccountClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly XmlChannel $channel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getPersonalData(string $email, string $password): Notification|PersonalData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Adressdaten',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Adresse_Neu',
|
||||||
|
'adressdaten' => $registrationData->toPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function resetPassword(string $email): Notification
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Passwort_Anfrage',
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function updatePersonalData(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
PersonalData $personalData,
|
||||||
|
bool $debug = false,
|
||||||
|
): Notification|PersonalDataUpdateResponse {
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Adressdaten_Ändern',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
'idadresse' => $personalData->addressId,
|
||||||
|
'adressdaten' => $personalData->toPayload(),
|
||||||
|
], [], $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function createAddress(
|
||||||
|
PersonalData $personalData,
|
||||||
|
bool $debug = false,
|
||||||
|
): RegistrationResponse|Notification {
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Adresse_Neu',
|
||||||
|
'adressdaten' => $personalData->toPayload(false),
|
||||||
|
'ohnemailversand' => 'True',
|
||||||
|
], [], $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Newsletter',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
'idadresse' => $personalData->addressId,
|
||||||
|
'newsletter' => [
|
||||||
|
'email' => $email,
|
||||||
|
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'SelektionCRM',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Client;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Model\BaseData;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\BookingResponse;
|
||||||
|
use App\BusProNet\Model\BookingUpdate;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer's bookings: listing, details and documents, creating and changing them.
|
||||||
|
*/
|
||||||
|
class BookingClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly XmlChannel $channel,
|
||||||
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getBookings(string $email, string $password): Notification|BaseData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Vorgänge',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getBooking(string $email, string $password, int $id): Notification|Booking
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => 'Vorgang_Details',
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
'idbuchung' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::CustomerData, [
|
||||||
|
'art' => $type,
|
||||||
|
'email' => $email,
|
||||||
|
'passwort' => $password,
|
||||||
|
'idbuchung' => $bookingId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::BookingUpdate, [
|
||||||
|
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
|
||||||
|
...$this->bookingDataProcessor->createUpdateRequestPayload($formData),
|
||||||
|
], [], $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First phase of the two-phase booking process: validates the booking and returns the
|
||||||
|
* prices without creating it.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||||
|
{
|
||||||
|
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second phase of the two-phase booking process: creates the booking after a successful
|
||||||
|
* inquiry.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||||
|
{
|
||||||
|
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::Booking, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Client;
|
||||||
|
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Model\Agency;
|
||||||
|
use App\BusProNet\Model\BaseData;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists that change rarely and that callers cache for long periods.
|
||||||
|
*/
|
||||||
|
class ReferenceDataClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly XmlChannel $channel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Agency[]|Notification
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getAgencies(): array|Notification
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::Agencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getBaseData(MessageType $type): Notification|BaseData
|
||||||
|
{
|
||||||
|
return $this->channel->request($type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Client;
|
||||||
|
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Model\BaseData;
|
||||||
|
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Travels from the live API. Most reads belong in TravelDataProvider, which arbitrates
|
||||||
|
* between this, the XML export and the snapshots.
|
||||||
|
*/
|
||||||
|
class TravelClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly XmlChannel $channel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::ProductData, ['idprodukt' => $travelId], ['hotelId' => $hotelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unparsed response, which has the structure of BusPro's XML export and is
|
||||||
|
* stored in the export directory as is.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getTravelDataXml(int $travelId): string
|
||||||
|
{
|
||||||
|
return $this->channel->requestRaw(MessageType::ProductData, ['idprodukt' => $travelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getProducts(): Notification|BaseData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::Products);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getMutableData(int $dateId): Notification|BaseData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::MutableData, ['idreise' => $dateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::Availability, ['idreise' => $dateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::AvailabilityExtended, ['idreise' => $dateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::AvailabilityHotel, [
|
||||||
|
'idreise' => $dateId,
|
||||||
|
'idpartner' => $hotelId,
|
||||||
|
'terminbis' => $dateTo->format('d.m.Y'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Client;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\PromoVoucher;
|
||||||
|
use App\BusProNet\Model\PurchaseVoucher;
|
||||||
|
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||||
|
use App\BusProNet\Model\VoucherPreview;
|
||||||
|
use App\BusProNet\Model\VoucherSaleResponse;
|
||||||
|
use App\BusProNet\Model\VoucherTemplates;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeeming vouchers in a booking, and selling purchase vouchers (Kaufgutscheine).
|
||||||
|
*/
|
||||||
|
class VoucherClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly XmlChannel $channel,
|
||||||
|
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular
|
||||||
|
* or goodwill (Kulanz), carry a balance that each redemption reduces.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::PurchaseVoucher, ['einloesecode' => $redemptionCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo
|
||||||
|
* vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person
|
||||||
|
* says.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::PromoVoucher, [
|
||||||
|
'aktionsgutschein' => $promoCode,
|
||||||
|
'idreise' => $travelId,
|
||||||
|
'preis' => number_format($participantPrice, 2, ',', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the voucher designs on sale and the amount bounds.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getVoucherTemplates(): Notification|VoucherTemplates
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::VoucherTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the payment methods offered for voucher purchases.
|
||||||
|
*
|
||||||
|
* @return list<VoucherPaymentMethod>|Notification
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getVoucherPaymentMethods(): array|Notification
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::VoucherPaymentMethods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the PDF preview of a voucher design with the entered data.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::VoucherPreview, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry run of a voucher purchase: full validation, nothing is issued.
|
||||||
|
*
|
||||||
|
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
|
||||||
|
* result must not be judged by Notification::isError().
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
|
||||||
|
{
|
||||||
|
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
|
||||||
|
*
|
||||||
|
* Every HINWEIS is a rejection on this path, including 650.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
|
||||||
|
{
|
||||||
|
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
|
||||||
|
{
|
||||||
|
return $this->channel->request(MessageType::VoucherSale, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\DataProcessor;
|
||||||
|
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
|
||||||
|
*
|
||||||
|
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
|
||||||
|
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
|
||||||
|
*/
|
||||||
|
class VoucherSalePayloadBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
|
||||||
|
{
|
||||||
|
if (null === $dto->template || null === $dto->paymentMethod) {
|
||||||
|
throw new \LogicException('A voucher sale needs a template and a payment method.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$communication = [
|
||||||
|
'telefonprivat' => $dto->phone,
|
||||||
|
'email' => $dto->email,
|
||||||
|
];
|
||||||
|
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
|
||||||
|
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
|
||||||
|
$communication['telefonmobil'] = $dto->mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'buchungsart' => $saleType,
|
||||||
|
'gutscheinart' => $dto->template->type,
|
||||||
|
'idgutscheinvorlage' => $dto->template->id,
|
||||||
|
'kaeufer' => $dto->from,
|
||||||
|
'empfaenger' => $dto->to,
|
||||||
|
'kaufpreis' => $dto->amount,
|
||||||
|
'text' => '',
|
||||||
|
'rechnungsempfänger' => [
|
||||||
|
'name' => $dto->lastName,
|
||||||
|
'vorname' => $dto->firstName,
|
||||||
|
'anrede' => $dto->salutation,
|
||||||
|
'titel' => $dto->title,
|
||||||
|
'geschlecht' => '',
|
||||||
|
'geburtsdatum' => '',
|
||||||
|
'anschrift' => $dto->address->toPayload(),
|
||||||
|
'kommunikation' => $communication,
|
||||||
|
],
|
||||||
|
'zahlung' => [
|
||||||
|
'@idzahlungsart' => $dto->paymentMethod->id,
|
||||||
|
'@bezeichnung' => $dto->paymentMethod->label,
|
||||||
|
'@art' => $dto->paymentMethod->type,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
|
||||||
|
{
|
||||||
|
if (null === $dto->template) {
|
||||||
|
throw new \LogicException('A voucher preview needs a template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'idgutscheinvorlage' => $dto->template->id,
|
||||||
|
'kaeufer' => $dto->from,
|
||||||
|
'empfaenger' => $dto->to,
|
||||||
|
'kaufpreis' => $dto->amount,
|
||||||
|
'text' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace App\BusProNet\DataProvider;
|
namespace App\BusProNet\DataProvider;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\ReferenceDataClient;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
use App\BusProNet\Model\Country;
|
use App\BusProNet\Model\Country;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
|
|||||||
|
|
||||||
class CountryDataProvider
|
class CountryDataProvider
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache)
|
public function __construct(private readonly ReferenceDataClient $referenceDataClient, private readonly CacheInterface $cache)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +23,8 @@ class CountryDataProvider
|
|||||||
$item->expiresAfter(6 * 60 * 60);
|
$item->expiresAfter(6 * 60 * 60);
|
||||||
|
|
||||||
return $this
|
return $this
|
||||||
->apiClient
|
->referenceDataClient
|
||||||
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES)
|
->getBaseData(MessageType::Countries)
|
||||||
->getItems()
|
->getItems()
|
||||||
;
|
;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `typ` of BusPro's `<satz>`: the command on a request, the kind of answer on a response.
|
||||||
|
*/
|
||||||
|
enum MessageType: string
|
||||||
|
{
|
||||||
|
// Only ever a response: BusPro's refusal or remark, whatever was asked.
|
||||||
|
case Notification = 'HINWEIS';
|
||||||
|
case CustomerData = 'KUNDENKONTO';
|
||||||
|
case Countries = 'STAMMLAENDER';
|
||||||
|
case MutableData = 'MOEGLICHEAENDERUNGEN';
|
||||||
|
case Availability = 'VERFUEGBARKEIT';
|
||||||
|
case AvailabilityExtended = 'VERFUEGBARKEIT2';
|
||||||
|
case AvailabilityHotel = 'VERFUEGBARKEITHOTEL';
|
||||||
|
case BookingUpdate = 'BUCHUNGAENDERUNG';
|
||||||
|
case Booking = 'BUCHUNG';
|
||||||
|
case Products = 'PRODUKTE';
|
||||||
|
case ProductData = 'PRODUKTDATEN';
|
||||||
|
case Agencies = 'AGENTUREN';
|
||||||
|
case PurchaseVoucher = 'GUTSCHEINPRUEFUNGEINLOESUNG';
|
||||||
|
case PromoVoucher = 'AKTIONSGUTSCHEIN';
|
||||||
|
// Selling Kaufgutscheine. Not to be confused with PurchaseVoucher, which redeems one in a booking.
|
||||||
|
case VoucherTemplates = 'GUTSCHEINVORLAGEN';
|
||||||
|
case VoucherPaymentMethods = 'GUTSCHEINZAHLUNGSARTEN';
|
||||||
|
case VoucherPreview = 'GUTSCHEINVORSCHAU';
|
||||||
|
case VoucherSale = 'GUTSCHEINBUCHUNG';
|
||||||
|
}
|
||||||
@@ -35,4 +35,15 @@ class Hotel
|
|||||||
|
|
||||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
public ?string $country = null;
|
public ?string $country = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
|
||||||
|
* space-separated list such as "84 85", so it is an opaque label, not a number.
|
||||||
|
*/
|
||||||
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
|
public ?string $costUnit = null;
|
||||||
|
|
||||||
|
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
|
||||||
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
|
public ?string $countryCode = null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
|
||||||
|
*
|
||||||
|
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
|
||||||
|
*/
|
||||||
|
final readonly class VoucherPaymentMethod
|
||||||
|
{
|
||||||
|
public const string TYPE_TRANSFER = 'UEBERW';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $label,
|
||||||
|
public string $type,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTransfer(): bool
|
||||||
|
{
|
||||||
|
return self::TYPE_TRANSFER === $this->type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
|
||||||
|
*/
|
||||||
|
final readonly class VoucherPreview
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $pdf,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
|
||||||
|
*
|
||||||
|
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
|
||||||
|
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
|
||||||
|
*
|
||||||
|
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
|
||||||
|
* voucher itself once the transfer has arrived.
|
||||||
|
*/
|
||||||
|
final readonly class VoucherSaleResponse
|
||||||
|
{
|
||||||
|
public const string SALE_POSSIBLE = 'möglich';
|
||||||
|
public const string SALE_PROCESSED = 'erfolgt';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $status,
|
||||||
|
public ?int $voucherId = null,
|
||||||
|
public ?string $voucherNumber = null,
|
||||||
|
public ?string $redemptionCode = null,
|
||||||
|
public ?float $price = null,
|
||||||
|
public ?string $invoiceNumber = null,
|
||||||
|
public ?string $email = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInquiryValid(): bool
|
||||||
|
{
|
||||||
|
return self::SALE_POSSIBLE === $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSaleSuccessful(): bool
|
||||||
|
{
|
||||||
|
return self::SALE_PROCESSED === $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
|
||||||
|
*
|
||||||
|
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
|
||||||
|
*/
|
||||||
|
final readonly class VoucherTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $type,
|
||||||
|
public string $label,
|
||||||
|
public ?string $note = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
|
||||||
|
*
|
||||||
|
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
|
||||||
|
* they were measured on the test system (10 and 5,000 €, both inclusive).
|
||||||
|
*/
|
||||||
|
final readonly class VoucherTemplates
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<VoucherTemplate> $templates
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $minAmount,
|
||||||
|
public int $maxAmount,
|
||||||
|
public array $templates,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?VoucherTemplate
|
||||||
|
{
|
||||||
|
foreach ($this->templates as $template) {
|
||||||
|
if ($id === $template->id) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\BusProNet\Model;
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
use League\Flysystem\FilesystemException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
|
||||||
final readonly class XmlExportInfo
|
final readonly class XmlExportInfo
|
||||||
{
|
{
|
||||||
private const INFO_FILENAME = 'uebertragung.info';
|
private const INFO_FILENAME = 'uebertragung.info';
|
||||||
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
|
|||||||
return self::INFO_FILENAME;
|
return self::INFO_FILENAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the marker from a storage, or null when it is missing or unreadable.
|
||||||
|
*
|
||||||
|
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
|
||||||
|
* callers only ever want to know which export they are looking at, and "unknown" is
|
||||||
|
* an answer they can act on.
|
||||||
|
*/
|
||||||
|
public static function fromStorage(FilesystemOperator $storage): ?self
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (false === $storage->fileExists(self::INFO_FILENAME)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fromString($storage->read(self::INFO_FILENAME));
|
||||||
|
} catch (FilesystemException|\InvalidArgumentException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromString(string $content): self
|
public static function fromString(string $content): self
|
||||||
{
|
{
|
||||||
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
|
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ trait TypeConversionTrait
|
|||||||
* This method is used primarily to parse pickup-times since data is
|
* This method is used primarily to parse pickup-times since data is
|
||||||
* either provided with date and time or time only. In this case a
|
* either provided with date and time or time only. In this case a
|
||||||
* date has to be provided which defaults to a travel's start date.
|
* date has to be provided which defaults to a travel's start date.
|
||||||
|
*
|
||||||
|
* Both shapes are read in the default date's zone, so a caller that needs plain clock
|
||||||
|
* readings can pass a UTC date and no daylight saving gap shifts the time.
|
||||||
*/
|
*/
|
||||||
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
|
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
@@ -89,12 +92,12 @@ trait TypeConversionTrait
|
|||||||
try {
|
try {
|
||||||
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
|
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
|
||||||
// time only provided
|
// time only provided
|
||||||
$dateTime = Carbon::createFromFormat('H:i', $string);
|
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
|
||||||
// assign default date
|
// assign default date
|
||||||
$dateTime->setDateFrom($defaultDate);
|
$dateTime->setDateFrom($defaultDate);
|
||||||
} else {
|
} else {
|
||||||
// date and time provided
|
// date and time provided
|
||||||
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16));
|
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $dateTime->toDateTimeImmutable();
|
return $dateTime->toDateTimeImmutable();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Transport;
|
||||||
|
|
||||||
|
use App\Service\RequestIdGenerator;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids for BPN requests: the id of the current HTTP request, or a fresh one on the CLI, with a
|
||||||
|
* running number, so the log entries and dumps of one page load can be found together.
|
||||||
|
*/
|
||||||
|
class RequestIdSequence
|
||||||
|
{
|
||||||
|
private int $counter = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private readonly RequestIdGenerator $requestIdGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function next(): string
|
||||||
|
{
|
||||||
|
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
|
||||||
|
?? $this->requestIdGenerator->generateBaseId();
|
||||||
|
|
||||||
|
return $baseId.'_'.++$this->counter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Transport;
|
||||||
|
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BusPro's socket framing: one connection per request, each message prefixed with its length
|
||||||
|
* as a 10 byte header. Knows nothing about what the messages contain.
|
||||||
|
*/
|
||||||
|
class SocketTransport
|
||||||
|
{
|
||||||
|
private float $operationStartTime;
|
||||||
|
private int $selectedPort;
|
||||||
|
|
||||||
|
/** @param list<int|string> $ports */
|
||||||
|
public function __construct(
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly string $host,
|
||||||
|
private readonly array $ports,
|
||||||
|
private readonly int $connectionTimeout = 5,
|
||||||
|
private readonly int $streamTimeout = 30,
|
||||||
|
private readonly int $totalTimeout = 45,
|
||||||
|
private readonly int $maxRetries = 25,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends $body over a fresh connection and returns the response payload. $logMessage is
|
||||||
|
* logged with $context and the chosen port once the connection is open.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
public function exchange(string $body, string $logMessage, array $context = []): string
|
||||||
|
{
|
||||||
|
$socket = $this->connect();
|
||||||
|
|
||||||
|
$this->logger->info($logMessage, [...$context, 'port' => $this->selectedPort]);
|
||||||
|
|
||||||
|
$this->send($socket, $body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// the length header is consumed and verified by receive(), this is the payload
|
||||||
|
return $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resource
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws TimeoutException
|
||||||
|
*/
|
||||||
|
private function connect()
|
||||||
|
{
|
||||||
|
// Randomly select a port from the available ports for load balancing
|
||||||
|
$this->selectedPort = $this->ports[array_rand($this->ports)];
|
||||||
|
$this->operationStartTime = microtime(true);
|
||||||
|
|
||||||
|
$tries = 1;
|
||||||
|
$errNo = $errStr = '';
|
||||||
|
$errorCodesForRetry = [
|
||||||
|
SOCKET_ECONNREFUSED,
|
||||||
|
SOCKET_EBADF,
|
||||||
|
];
|
||||||
|
|
||||||
|
$openSocket = function (&$errNo, &$errStr) {
|
||||||
|
return @fsockopen(
|
||||||
|
$this->host,
|
||||||
|
$this->selectedPort,
|
||||||
|
$errNo,
|
||||||
|
$errStr,
|
||||||
|
$this->connectionTimeout
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$socket = $openSocket($errNo, $errStr);
|
||||||
|
|
||||||
|
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->maxRetries > $tries) {
|
||||||
|
// Check if we've exceeded total timeout during retries
|
||||||
|
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
|
||||||
|
$this->logger->error('Connection retry timeout exceeded', [
|
||||||
|
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||||
|
'total_timeout' => $this->totalTimeout,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new TimeoutException('Connection timeout exceeded during retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->warning('Could not connect to socket, retrying', [
|
||||||
|
'error_message' => $errStr,
|
||||||
|
'error_number' => $errNo,
|
||||||
|
'attempt' => $tries,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
++$tries;
|
||||||
|
sleep(1);
|
||||||
|
$socket = $openSocket($errNo, $errStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false !== $socket) {
|
||||||
|
stream_set_timeout($socket, $this->streamTimeout);
|
||||||
|
} else {
|
||||||
|
$this->logger->error('Unable to open socket', [
|
||||||
|
'error_message' => $errStr,
|
||||||
|
'error_number' => $errNo,
|
||||||
|
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new ApiClientException('Unable to open socket');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $socket
|
||||||
|
*
|
||||||
|
* @throws TimeoutException
|
||||||
|
*/
|
||||||
|
private function send($socket, string $data): void
|
||||||
|
{
|
||||||
|
// Check total timeout before sending
|
||||||
|
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
|
||||||
|
$this->logger->error('Total timeout exceeded before send', [
|
||||||
|
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new TimeoutException('Total operation timeout exceeded before send');
|
||||||
|
}
|
||||||
|
|
||||||
|
// message length is prepended to actual message
|
||||||
|
$send = sprintf('%010s', strlen($data)).$data;
|
||||||
|
fwrite($socket, $send);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a single response message and returns its payload. The protocol prepends the
|
||||||
|
* payload length as a 10 byte header, so the announced length is compared against what
|
||||||
|
* actually arrived: a peer closing mid-stream would otherwise yield a silently
|
||||||
|
* truncated body that only surfaces later as an unexplained parse failure.
|
||||||
|
*
|
||||||
|
* @param resource $socket
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws TimeoutException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
private function receive($socket): string
|
||||||
|
{
|
||||||
|
$response = '';
|
||||||
|
$readAttempts = 0;
|
||||||
|
$totalTimeout = $this->totalTimeout;
|
||||||
|
|
||||||
|
while (false === feof($socket)) {
|
||||||
|
// Check total timeout before each read
|
||||||
|
$elapsedTime = microtime(true) - $this->operationStartTime;
|
||||||
|
if ($elapsedTime > $totalTimeout) {
|
||||||
|
$this->logger->error('Total timeout exceeded during receive', [
|
||||||
|
'elapsed_time' => $elapsedTime,
|
||||||
|
'total_timeout' => $totalTimeout,
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'read_attempts' => $readAttempts,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new TimeoutException('Total operation timeout exceeded while receiving data');
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunk = fread($socket, 4096);
|
||||||
|
++$readAttempts;
|
||||||
|
|
||||||
|
// Check if stream timed out on this specific read
|
||||||
|
$metadata = stream_get_meta_data($socket);
|
||||||
|
if (true === $metadata['timed_out']) {
|
||||||
|
// Check if this is an immediate rejection (0 bytes, < 1 second)
|
||||||
|
// This indicates the server is busy rather than a true timeout
|
||||||
|
if (0 === strlen($response) && $elapsedTime < 1.0) {
|
||||||
|
$this->logger->warning('Server closed connection immediately', [
|
||||||
|
'elapsed_time' => $elapsedTime,
|
||||||
|
'bytes_received' => 0,
|
||||||
|
'read_attempts' => $readAttempts,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('Stream read timeout detected', [
|
||||||
|
'elapsed_time' => $elapsedTime,
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'read_attempts' => $readAttempts,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new TimeoutException('Stream timeout while reading from socket');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response .= $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === strlen($response)) {
|
||||||
|
$elapsedTime = microtime(true) - $this->operationStartTime;
|
||||||
|
$this->logger->warning('Server closed connection without sending data', [
|
||||||
|
'elapsed_time' => $elapsedTime,
|
||||||
|
'read_attempts' => $readAttempts,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractPayload($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function extractPayload(string $response): string
|
||||||
|
{
|
||||||
|
if (10 > strlen($response)) {
|
||||||
|
$this->logger->error('Incomplete response header received from API', [
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = substr($response, 0, 10);
|
||||||
|
$announcedLength = (int) trim($header);
|
||||||
|
$payload = substr($response, 10);
|
||||||
|
|
||||||
|
if (1 > $announcedLength) {
|
||||||
|
$this->logger->error('Empty response body announced by API', [
|
||||||
|
'header' => trim($header),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($payload) !== $announcedLength) {
|
||||||
|
$this->logger->error('Truncated response received from API', [
|
||||||
|
'bytes_received' => strlen($payload),
|
||||||
|
'announced_length' => $announcedLength,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $socket */
|
||||||
|
private function disconnect($socket): void
|
||||||
|
{
|
||||||
|
@fclose($socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\Transport;
|
||||||
|
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
|
use App\Service\XmlDumpWriter;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The XML protocol around one BusPro command: authentication, serialization, dumps, the
|
||||||
|
* busy-retry and parsing. What a command contains is up to the caller.
|
||||||
|
*/
|
||||||
|
class XmlChannel
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $options */
|
||||||
|
public function __construct(
|
||||||
|
private readonly SerializerInterface $serializer,
|
||||||
|
private readonly ApiResponseParser $responseParser,
|
||||||
|
private readonly XmlDumpWriter $dumpWriter,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly RequestIdSequence $requestIds,
|
||||||
|
private readonly SocketTransport $transport,
|
||||||
|
array $options,
|
||||||
|
) {
|
||||||
|
$this->config = $this->resolveOptions($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends command $type with $fields and returns the parsed response.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
* @param array<string, mixed> $parserArgs
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed
|
||||||
|
{
|
||||||
|
$data = $this->envelope($type, $fields);
|
||||||
|
|
||||||
|
return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) {
|
||||||
|
$requestId = $this->requestIds->next();
|
||||||
|
$dump = $debug || true === $this->config['debug'];
|
||||||
|
|
||||||
|
$xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump);
|
||||||
|
|
||||||
|
return $this->parseResponse($type, $xml, $parserArgs, $requestId);
|
||||||
|
}, $type->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like request(), but returns the unparsed response.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function requestRaw(MessageType $type, array $fields = []): string
|
||||||
|
{
|
||||||
|
$data = $this->envelope($type, $fields);
|
||||||
|
|
||||||
|
return $this->executeWithRetry(function () use ($type, $data) {
|
||||||
|
$requestId = $this->requestIds->next();
|
||||||
|
$body = $this->serializeXml($data);
|
||||||
|
|
||||||
|
return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a recorded request as is, except for the key, which is regenerated for the current
|
||||||
|
* date. Returns the unparsed response.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function replay(string $xml, bool $debug = false): string
|
||||||
|
{
|
||||||
|
return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authentication every request starts with, followed by the command's own fields.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function envelope(MessageType $type, array $fields): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user' => $this->config['bpn_username'],
|
||||||
|
'key' => $this->createKey($type->value),
|
||||||
|
'satz' => ['@typ' => $type->value],
|
||||||
|
...$fields,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createKey(string $type): string
|
||||||
|
{
|
||||||
|
$date = (new \DateTimeImmutable())->format('Ymd');
|
||||||
|
|
||||||
|
return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an operation with automatic retry on immediate connection close.
|
||||||
|
*
|
||||||
|
* When the BPN server is busy, it may close connections immediately without responding.
|
||||||
|
* This method detects such conditions and automatically retries after a short delay.
|
||||||
|
*
|
||||||
|
* @throws ImmediateConnectionCloseException If all retry attempts fail
|
||||||
|
*/
|
||||||
|
private function executeWithRetry(callable $operation, ?string $type = null): mixed
|
||||||
|
{
|
||||||
|
$maxAttempts = $this->config['busy_retry_attempts'];
|
||||||
|
$retryDelay = $this->config['busy_retry_delay'];
|
||||||
|
$typeContext = null !== $type ? ['type' => $type] : [];
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
|
||||||
|
try {
|
||||||
|
return $operation();
|
||||||
|
} catch (ImmediateConnectionCloseException $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
|
||||||
|
if ($attempt < $maxAttempts) {
|
||||||
|
$this->logger->warning('BPN server busy, retrying request', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'maxAttempts' => $maxAttempts,
|
||||||
|
'retryDelay' => $retryDelay,
|
||||||
|
...$typeContext,
|
||||||
|
]);
|
||||||
|
sleep($retryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]);
|
||||||
|
|
||||||
|
throw $lastException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
private function doReplay(string $xml, bool $debug): string
|
||||||
|
{
|
||||||
|
$requestId = $this->requestIds->next();
|
||||||
|
|
||||||
|
$doc = new \DOMDocument();
|
||||||
|
if (false === @$doc->loadXML($xml)) {
|
||||||
|
throw new ApiClientException('Invalid XML provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
$satzNode = $doc->getElementsByTagName('satz')->item(0);
|
||||||
|
if (null === $satzNode) {
|
||||||
|
throw new ApiClientException('Missing <satz> element in XML');
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $satzNode->getAttribute('typ');
|
||||||
|
if ('' === $type) {
|
||||||
|
throw new ApiClientException('Missing typ attribute on <satz> element');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyNode = $doc->getElementsByTagName('key')->item(0);
|
||||||
|
if (null === $keyNode) {
|
||||||
|
throw new ApiClientException('Missing <key> element in XML');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyNode->nodeValue = $this->createKey($type);
|
||||||
|
|
||||||
|
return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string
|
||||||
|
{
|
||||||
|
if ($dump) {
|
||||||
|
$this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]);
|
||||||
|
|
||||||
|
if ($dump) {
|
||||||
|
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parserArgs
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
|
||||||
|
} catch (ResponseParserException $e) {
|
||||||
|
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||||
|
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||||
|
if ($dumpOnFailure) {
|
||||||
|
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('Unable to parse response received from API', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'type' => $type->value,
|
||||||
|
'response_length' => strlen($xml),
|
||||||
|
'error_message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function serializeXml(array $data): string
|
||||||
|
{
|
||||||
|
return $this->serializer->serialize($data, 'xml', [
|
||||||
|
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
|
||||||
|
XmlEncoder::ENCODING => 'UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resolveOptions(array $options): array
|
||||||
|
{
|
||||||
|
$optionsResolver = new OptionsResolver();
|
||||||
|
$optionsResolver->setRequired([
|
||||||
|
'bpn_username',
|
||||||
|
'bpn_password',
|
||||||
|
]);
|
||||||
|
$optionsResolver->setDefaults([
|
||||||
|
'debug' => false,
|
||||||
|
'busy_retry_attempts' => 3,
|
||||||
|
'busy_retry_delay' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $optionsResolver->resolve($options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Utility;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
|
||||||
|
*
|
||||||
|
* BusPro has no country vocabulary: the field is free text that has accumulated three
|
||||||
|
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
|
||||||
|
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
|
||||||
|
* under several tokens. The table below is therefore part of the API contract rather than
|
||||||
|
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
|
||||||
|
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
|
||||||
|
* guess, never to an empty string.
|
||||||
|
*/
|
||||||
|
class CountryCodeMapper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Keys are upper-cased tokens, values lowercase ISO-2.
|
||||||
|
*
|
||||||
|
* Entries worth their explanation:
|
||||||
|
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
|
||||||
|
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
|
||||||
|
* Slovenia already arrives under its own `SI`.
|
||||||
|
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
|
||||||
|
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
|
||||||
|
* - `UK` is the colloquial form of the ISO `GB`.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const MAP = [
|
||||||
|
'A' => 'at',
|
||||||
|
'AT' => 'at',
|
||||||
|
'AUT' => 'at',
|
||||||
|
'B' => 'be',
|
||||||
|
'CA' => 'ca',
|
||||||
|
'CH' => 'ch',
|
||||||
|
'CZ' => 'cz',
|
||||||
|
'D' => 'de',
|
||||||
|
'DEU' => 'de',
|
||||||
|
'DK' => 'dk',
|
||||||
|
'E' => 'es',
|
||||||
|
'ES' => 'es',
|
||||||
|
'F' => 'fr',
|
||||||
|
'FI' => 'fi',
|
||||||
|
'FL' => 'li',
|
||||||
|
'FR' => 'fr',
|
||||||
|
'GR' => 'gr',
|
||||||
|
'GRE' => 'gr',
|
||||||
|
'HR' => 'hr',
|
||||||
|
'HRV' => 'hr',
|
||||||
|
'HU' => 'hu',
|
||||||
|
'I' => 'it',
|
||||||
|
'IL' => 'il',
|
||||||
|
'IR' => 'ie',
|
||||||
|
'IS' => 'is',
|
||||||
|
'IT' => 'it',
|
||||||
|
'LT' => 'lt',
|
||||||
|
'LV' => 'lv',
|
||||||
|
'MT' => 'mt',
|
||||||
|
'N' => 'no',
|
||||||
|
'NL' => 'nl',
|
||||||
|
'PL' => 'pl',
|
||||||
|
'PRT' => 'pt',
|
||||||
|
'PT' => 'pt',
|
||||||
|
'RUS' => 'ru',
|
||||||
|
'S' => 'se',
|
||||||
|
'SI' => 'si',
|
||||||
|
'SLO' => 'sk',
|
||||||
|
'UK' => 'gb',
|
||||||
|
'USA' => 'us',
|
||||||
|
'ZA' => 'za',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens already reported in this process, so a master data file full of the same
|
||||||
|
* unknown token produces one log line rather than one per hotel.
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $reported = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $token the raw `hotel/land` value
|
||||||
|
*
|
||||||
|
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
|
||||||
|
*/
|
||||||
|
public function map(?string $token): ?string
|
||||||
|
{
|
||||||
|
if (null === $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtoupper(trim($token));
|
||||||
|
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset(self::MAP[$normalized])) {
|
||||||
|
return self::MAP[$normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === isset($this->reported[$normalized])) {
|
||||||
|
$this->reported[$normalized] = true;
|
||||||
|
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\BusProNet\XmlLoader;
|
namespace App\BusProNet\XmlLoader;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\ReferenceDataClient;
|
||||||
use App\BusProNet\Model\Agency;
|
use App\BusProNet\Model\Agency;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
@@ -19,7 +19,7 @@ class AgencyLoader
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ReferenceDataClient $referenceDataClient,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ class AgencyLoader
|
|||||||
// Cache for 24 hours (agencies change infrequently)
|
// Cache for 24 hours (agencies change infrequently)
|
||||||
$item->expiresAfter(24 * 60 * 60);
|
$item->expiresAfter(24 * 60 * 60);
|
||||||
|
|
||||||
$result = $this->apiClient->getAgencies();
|
$result = $this->referenceDataClient->getAgencies();
|
||||||
|
|
||||||
if ($result instanceof Notification) {
|
if ($result instanceof Notification) {
|
||||||
$this->logger->error('Failed to fetch agencies from BPN API', [
|
$this->logger->error('Failed to fetch agencies from BPN API', [
|
||||||
|
|||||||
@@ -4,14 +4,25 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\Hotel;
|
use App\BusProNet\Model\Hotel;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
use App\BusProNet\XmlCrawlerFactory;
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\Exception\HotelNotFoundException;
|
use App\Exception\HotelNotFoundException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
class HotelLoader extends AbstractLoader
|
class HotelLoader extends AbstractLoader
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CountryCodeMapper $countryCodeMapper,
|
||||||
|
CacheInterface $cache,
|
||||||
|
FilesystemOperator $xmlExport,
|
||||||
|
) {
|
||||||
|
parent::__construct($cache, $xmlExport);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int, Hotel> */
|
/** @return array<int, Hotel> */
|
||||||
public function loadAll(?string $filename = 'hotel.xml'): array
|
public function loadAll(?string $filename = 'hotel.xml'): array
|
||||||
{
|
{
|
||||||
@@ -87,6 +98,10 @@ class HotelLoader extends AbstractLoader
|
|||||||
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
||||||
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
||||||
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
||||||
|
// String, never int: see Hotel::$costUnit.
|
||||||
|
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
|
||||||
|
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
|
||||||
|
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
|
||||||
|
|
||||||
return $hotel;
|
return $hotel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ class TravelLoader extends AbstractLoader
|
|||||||
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
||||||
$hotelId = (int) $hotelNode->attr('idbuspro');
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// A travel may reference a hotel that hotel.xml does not carry.
|
||||||
|
// Reading ->name off the miss would fatal the whole mapping, not
|
||||||
|
// just this entry, and take /api/travels down with it.
|
||||||
|
if (false === isset($hotels[$hotelId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$mapping[$travelId]['hotels'][$hotelId] = [
|
$mapping[$travelId]['hotels'][$hotelId] = [
|
||||||
'id' => $hotelId,
|
'id' => $hotelId,
|
||||||
'code' => $hotelNode->attr('code'),
|
'code' => $hotelNode->attr('code'),
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\BusProNet\XmlParser;
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
|
||||||
use App\BusProNet\Exception\ResponseParserException;
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
|
use App\BusProNet\MessageType;
|
||||||
use App\BusProNet\XmlCrawlerFactory;
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
|
||||||
class ApiResponseParser extends AbstractParser
|
class ApiResponseParser extends AbstractParser
|
||||||
@@ -14,10 +14,11 @@ class ApiResponseParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param array<string, mixed> $additionalArgs
|
||||||
|
*
|
||||||
* @throws ResponseParserException
|
* @throws ResponseParserException
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $additionalArgs */
|
public function parseXmlString(MessageType $type, string $xml, array $additionalArgs = []): mixed
|
||||||
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
|
||||||
{
|
{
|
||||||
$crawler = XmlCrawlerFactory::create($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
@@ -25,17 +26,20 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
|
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override type unless present in XML to catch error responses
|
// The type in the XML wins, to catch error responses. BusPro may send one we don't know.
|
||||||
$responseType = $this
|
$typeAttribute = $this->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ'));
|
||||||
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type;
|
$responseType = null === $typeAttribute ? $type : MessageType::tryFrom($typeAttribute);
|
||||||
|
if (null === $responseType) {
|
||||||
|
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $typeAttribute));
|
||||||
|
}
|
||||||
|
|
||||||
$resultNode = $crawler->filterXPath('//ergebnis');
|
$resultNode = $crawler->filterXPath('//ergebnis');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($responseType) {
|
switch ($responseType) {
|
||||||
case ApiClient::TYPE_NOTIFICATION:
|
case MessageType::Notification:
|
||||||
return (new NotificationParser())->parse($crawler);
|
return (new NotificationParser())->parse($crawler);
|
||||||
case ApiClient::TYPE_CUSTOMER_DATA:
|
case MessageType::CustomerData:
|
||||||
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
|
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
|
||||||
if (null === $subType || '' === $subType) {
|
if (null === $subType || '' === $subType) {
|
||||||
throw new ResponseParserException('Unable to determine customer data subtype from XML response');
|
throw new ResponseParserException('Unable to determine customer data subtype from XML response');
|
||||||
@@ -63,23 +67,23 @@ class ApiResponseParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
|
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
|
||||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
case MessageType::Countries:
|
||||||
return (new CountriesParser())->parse($resultNode);
|
return (new CountriesParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_MUTABLE_DATA:
|
case MessageType::MutableData:
|
||||||
return (new MutableDataParser())->parse($resultNode);
|
return (new MutableDataParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_AVAILABILITY:
|
case MessageType::Availability:
|
||||||
return (new AvailabilitiesParser())->parseServices($resultNode);
|
return (new AvailabilitiesParser())->parseServices($resultNode);
|
||||||
case ApiClient::TYPE_AVAILABILITY_EXTENDED:
|
case MessageType::AvailabilityExtended:
|
||||||
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
|
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
|
||||||
case ApiClient::TYPE_AVAILABILITY_HOTEL:
|
case MessageType::AvailabilityHotel:
|
||||||
return (new AvailabilitiesParser())->parseRooms($resultNode);
|
return (new AvailabilitiesParser())->parseRooms($resultNode);
|
||||||
case ApiClient::TYPE_BOOKING_UPDATE:
|
case MessageType::BookingUpdate:
|
||||||
return (new BookingUpdateParser())->parse($resultNode);
|
return (new BookingUpdateParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_BOOKING:
|
case MessageType::Booking:
|
||||||
return (new BookingResponseParser())->parse($resultNode);
|
return (new BookingResponseParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PRODUCTS:
|
case MessageType::Products:
|
||||||
return (new ProductsParser())->parse($resultNode);
|
return (new ProductsParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PRODUCT_DATA:
|
case MessageType::ProductData:
|
||||||
$travelNode = $crawler->filterXPath('//reise/termin');
|
$travelNode = $crawler->filterXPath('//reise/termin');
|
||||||
|
|
||||||
if (0 === $travelNode->count()) {
|
if (0 === $travelNode->count()) {
|
||||||
@@ -87,17 +91,23 @@ class ApiResponseParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
|
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
|
||||||
case ApiClient::TYPE_AGENCIES:
|
case MessageType::Agencies:
|
||||||
return (new AgencyParser())->parse($resultNode);
|
return (new AgencyParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PURCHASE_VOUCHER:
|
case MessageType::PurchaseVoucher:
|
||||||
return (new PurchaseVoucherParser())->parse($resultNode);
|
return (new PurchaseVoucherParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PROMO_VOUCHER:
|
case MessageType::PromoVoucher:
|
||||||
return (new PromoVoucherParser())->parse($resultNode);
|
return (new PromoVoucherParser())->parse($resultNode);
|
||||||
|
case MessageType::VoucherTemplates:
|
||||||
|
return (new VoucherTemplatesParser())->parse($resultNode);
|
||||||
|
case MessageType::VoucherPaymentMethods:
|
||||||
|
return (new VoucherPaymentMethodsParser())->parse($resultNode);
|
||||||
|
case MessageType::VoucherPreview:
|
||||||
|
return (new VoucherPreviewParser())->parse($resultNode);
|
||||||
|
case MessageType::VoucherSale:
|
||||||
|
return (new VoucherSaleResponseParser())->parse($resultNode);
|
||||||
}
|
}
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\BusProNet\XmlParser;
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
use App\BusProNet\Model\File;
|
use App\BusProNet\Model\File;
|
||||||
use PhpZip\Exception\ZipException;
|
|
||||||
use PhpZip\ZipFile;
|
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use ZipStream\Exception as ZipException;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
class DocumentsParser
|
class DocumentsParser
|
||||||
{
|
{
|
||||||
@@ -28,17 +28,28 @@ class DocumentsParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In case of multiple documents create ZIP file
|
// In case of multiple documents create ZIP file
|
||||||
|
$stream = fopen('php://temp', 'w+b');
|
||||||
try {
|
try {
|
||||||
$zip = new ZipFile();
|
// Zero headers trip up some unzip clients, and the data is in memory anyway
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: $stream,
|
||||||
|
defaultEnableZeroHeader: false,
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
$documents->each(function (Crawler $node) use (&$zip) {
|
$documents->each(function (Crawler $node) use ($zip) {
|
||||||
[$filename, $pdfData] = $this->getFileInfo($node);
|
[$filename, $pdfData] = $this->getFileInfo($node);
|
||||||
$zip->addFromString($filename.'.pdf', $pdfData);
|
$zip->addFile($filename.'.pdf', $pdfData);
|
||||||
});
|
});
|
||||||
|
|
||||||
return new File('reisedokumente.zip', $zip->outputAsString(), 'application/zip');
|
$zip->finish();
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
return new File('reisedokumente.zip', stream_get_contents($stream), 'application/zip');
|
||||||
} catch (ZipException $e) {
|
} catch (ZipException $e) {
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
fclose($stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
|
||||||
|
*
|
||||||
|
* Deliberately separate from TravelParser, which builds the booking path's models and
|
||||||
|
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
|
||||||
|
* hotel-nested services and levels boarding-point prices. The product export filters and
|
||||||
|
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
|
||||||
|
* and returns plain arrays in source order.
|
||||||
|
*
|
||||||
|
* @see \App\Service\ProductExportBuilder
|
||||||
|
*/
|
||||||
|
class ProductExportParser extends AbstractParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Every selection group on the date, not only the `Leistungen *` ones.
|
||||||
|
*
|
||||||
|
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
|
||||||
|
*/
|
||||||
|
public function getSelectionGroups(Crawler $dateNode): array
|
||||||
|
{
|
||||||
|
return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [
|
||||||
|
'busProId' => (int) $group->attr('idbuspro'),
|
||||||
|
'label' => $group->attr('bezeichnung'),
|
||||||
|
'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [
|
||||||
|
'busProId' => (int) $selection->attr('idbuspro'),
|
||||||
|
'label' => $selection->attr('bezeichnung'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`.
|
||||||
|
*
|
||||||
|
* The per-list `id` attribute is not read: it restarts at 1 in every list, so it
|
||||||
|
* identifies nothing once a date-level and a hotel-level list are merged.
|
||||||
|
*
|
||||||
|
* @return list<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
|
||||||
|
*/
|
||||||
|
public function getServices(Crawler $serviceNodes): array
|
||||||
|
{
|
||||||
|
return $serviceNodes->each(fn (Crawler $service): array => [
|
||||||
|
'busProId' => (int) $service->attr('idbuspro'),
|
||||||
|
'subType' => $service->attr('unterart'),
|
||||||
|
'label' => $this->getStringOrNullValue($this->children($service, 'text')),
|
||||||
|
'price' => $this->getFloatOrNullValue($this->children($service, 'preis')),
|
||||||
|
'status' => $this->getStringOrNullValue($this->children($service, 'status')),
|
||||||
|
'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hotel's rooms, without the PDGS pseudo room.
|
||||||
|
*
|
||||||
|
* `available` is a count of rooms, not persons. Occupancy is read case-insensitively:
|
||||||
|
* the travel export writes `MinPax`/`MaxPax`.
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getRooms(Crawler $hotelNode): array
|
||||||
|
{
|
||||||
|
$rooms = [];
|
||||||
|
|
||||||
|
$this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) {
|
||||||
|
$code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'));
|
||||||
|
|
||||||
|
if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rooms[] = [
|
||||||
|
'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'),
|
||||||
|
'code' => $code,
|
||||||
|
'label' => $this->getAttrOrNullValue($room, 'zimmertext'),
|
||||||
|
'category' => $this->getAttrOrNullValue($room, 'kat'),
|
||||||
|
'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'),
|
||||||
|
'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'),
|
||||||
|
'nights' => $this->getIntAttrOrNullValue($room, 'naechte'),
|
||||||
|
'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')),
|
||||||
|
'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'),
|
||||||
|
'status' => $this->getAttrOrNullValue($room, 'status'),
|
||||||
|
'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'),
|
||||||
|
'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The struck-through comparison price, carried by the PDGS pseudo room.
|
||||||
|
*/
|
||||||
|
public function getPseudoPrice(Crawler $hotelNode): ?float
|
||||||
|
{
|
||||||
|
foreach ($this->children($hotelNode, 'zimmer/preis') as $room) {
|
||||||
|
$room = new Crawler($room);
|
||||||
|
|
||||||
|
if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) {
|
||||||
|
return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guide of the first outbound bus that has one.
|
||||||
|
*
|
||||||
|
* Not TravelParser::getGuide(), which takes the first guide of any bus, return leg
|
||||||
|
* included.
|
||||||
|
*
|
||||||
|
* @return array{name: ?string, phone: ?string}|null
|
||||||
|
*/
|
||||||
|
public function getGuide(Crawler $dateNode): ?array
|
||||||
|
{
|
||||||
|
$guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter');
|
||||||
|
|
||||||
|
if (0 === $guides->count()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guide = $guides->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $this->getStringOrNullValue($this->children($guide, 'name')),
|
||||||
|
'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A path below the node's own children, never deeper.
|
||||||
|
*
|
||||||
|
* DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds
|
||||||
|
* nothing and `.//x` searches every descendant -- which would pull a hotel's
|
||||||
|
* `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the
|
||||||
|
* node itself, whatever its name.
|
||||||
|
*/
|
||||||
|
public function children(Crawler $node, string $path): Crawler
|
||||||
|
{
|
||||||
|
return $node->filterXPath('*/'.$path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trimOrNull(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses GUTSCHEINZAHLUNGSARTEN responses.
|
||||||
|
*
|
||||||
|
* Expected XML structure (test system, 2026-10-01):
|
||||||
|
* <ergebnis>
|
||||||
|
* <satz typ="GUTSCHEINZAHLUNGSARTEN"></satz>
|
||||||
|
* <zahlungsarten>
|
||||||
|
* <zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
|
||||||
|
* </zahlungsarten>
|
||||||
|
* </ergebnis>
|
||||||
|
*/
|
||||||
|
final class VoucherPaymentMethodsParser extends AbstractParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<VoucherPaymentMethod>
|
||||||
|
*/
|
||||||
|
public function parse(Crawler $node): array
|
||||||
|
{
|
||||||
|
$paymentMethods = [];
|
||||||
|
$node->filterXPath('//zahlungsarten/zahlungsart')->each(function (Crawler $methodNode) use (&$paymentMethods): void {
|
||||||
|
$paymentMethods[] = new VoucherPaymentMethod(
|
||||||
|
id: (int) $this->getRequiredAttrValue($methodNode, 'id', 'voucher payment method'),
|
||||||
|
label: $this->getRequiredAttrValue($methodNode, 'bezeichnung', 'voucher payment method'),
|
||||||
|
type: $this->getRequiredAttrValue($methodNode, 'art', 'voucher payment method'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $paymentMethods;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\VoucherPreview;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses GUTSCHEINVORSCHAU responses.
|
||||||
|
*
|
||||||
|
* Expected XML structure (test system, 2026-10-01):
|
||||||
|
* <ergebnis>
|
||||||
|
* <satz typ="GUTSCHEINVORSCHAU"/>
|
||||||
|
* <gutscheinpdf>data:application/pdf;base64,JVBERi0xLjQ…</gutscheinpdf>
|
||||||
|
* </ergebnis>
|
||||||
|
*/
|
||||||
|
final class VoucherPreviewParser extends AbstractParser
|
||||||
|
{
|
||||||
|
private const string DATA_URI_PREFIX = 'data:application/pdf;base64,';
|
||||||
|
|
||||||
|
public function parse(Crawler $node): VoucherPreview
|
||||||
|
{
|
||||||
|
$dataUri = trim($this->getStringOrNullValue($node->filterXPath('//gutscheinpdf')) ?? '');
|
||||||
|
if (!str_starts_with($dataUri, self::DATA_URI_PREFIX)) {
|
||||||
|
throw new \InvalidArgumentException('Missing or malformed PDF data URI in voucher preview response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf = base64_decode(substr($dataUri, \strlen(self::DATA_URI_PREFIX)), true);
|
||||||
|
if (false === $pdf || '' === $pdf) {
|
||||||
|
throw new \InvalidArgumentException('Undecodable PDF in voucher preview response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VoucherPreview($pdf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\VoucherSaleResponse;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses GUTSCHEINBUCHUNG responses that are not a HINWEIS.
|
||||||
|
*
|
||||||
|
* Dry run (buchungsart Anfrage, measured on the test system 2026-10-01):
|
||||||
|
* <ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>
|
||||||
|
*
|
||||||
|
* Purchase (buchungsart Buchung, as documented by the vendor):
|
||||||
|
* <ergebnis>
|
||||||
|
* <satz typ="GUTSCHEINBUCHUNG"/>
|
||||||
|
* <buchung>erfolgt</buchung>
|
||||||
|
* <idgutschein/><gutscheinnummer/><einloesecode/><preis/><rechnungsnummer/><email/>
|
||||||
|
* </ergebnis>
|
||||||
|
*/
|
||||||
|
final class VoucherSaleResponseParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function parse(Crawler $node): VoucherSaleResponse
|
||||||
|
{
|
||||||
|
$status = trim($this->getStringOrNullValue($node->filterXPath('//buchung')) ?? '');
|
||||||
|
if ('' === $status) {
|
||||||
|
throw new \InvalidArgumentException('Missing required status in voucher sale response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VoucherSaleResponse(
|
||||||
|
status: $status,
|
||||||
|
voucherId: $this->getIntOrNullValue($node->filterXPath('//idgutschein')),
|
||||||
|
voucherNumber: $this->trimmedOrNull($node->filterXPath('//gutscheinnummer')),
|
||||||
|
redemptionCode: $this->trimmedOrNull($node->filterXPath('//einloesecode')),
|
||||||
|
price: $this->getFloatOrNullValue($node->filterXPath('//preis')),
|
||||||
|
invoiceNumber: $this->trimmedOrNull($node->filterXPath('//rechnungsnummer')),
|
||||||
|
email: $this->trimmedOrNull($node->filterXPath('//email')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trimmedOrNull(Crawler $node): ?string
|
||||||
|
{
|
||||||
|
$value = trim($this->getStringOrNullValue($node) ?? '');
|
||||||
|
|
||||||
|
return '' !== $value ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\VoucherTemplate;
|
||||||
|
use App\BusProNet\Model\VoucherTemplates;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses GUTSCHEINVORLAGEN responses.
|
||||||
|
*
|
||||||
|
* Expected XML structure (test system, 2026-10-01):
|
||||||
|
* <ergebnis>
|
||||||
|
* <satz typ="GUTSCHEINVORLAGEN"></satz>
|
||||||
|
* <gutscheinminbetrag>10</gutscheinminbetrag>
|
||||||
|
* <gutscheinmaxbetrag>5000</gutscheinmaxbetrag>
|
||||||
|
* <gutscheine>
|
||||||
|
* <gutschein idgutscheinvorlage="3">
|
||||||
|
* <gutscheinart>K</gutscheinart>
|
||||||
|
* <bezeichnung>Allgemein</bezeichnung>
|
||||||
|
* <hinweis></hinweis>
|
||||||
|
* </gutschein>
|
||||||
|
* </gutscheine>
|
||||||
|
* </ergebnis>
|
||||||
|
*/
|
||||||
|
final class VoucherTemplatesParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function parse(Crawler $node): VoucherTemplates
|
||||||
|
{
|
||||||
|
$minAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinminbetrag'));
|
||||||
|
$maxAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinmaxbetrag'));
|
||||||
|
if (null === $minAmount || '' === trim($minAmount) || null === $maxAmount || '' === trim($maxAmount)) {
|
||||||
|
throw new \InvalidArgumentException('Missing voucher amount bounds in voucher templates response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$templates = [];
|
||||||
|
$node->filterXPath('//gutscheine/gutschein')->each(function (Crawler $templateNode) use (&$templates): void {
|
||||||
|
$note = $this->getStringOrNullValue($templateNode->filterXPath('//hinweis'));
|
||||||
|
|
||||||
|
$templates[] = new VoucherTemplate(
|
||||||
|
id: (int) $this->getRequiredAttrValue($templateNode, 'idgutscheinvorlage', 'voucher template'),
|
||||||
|
type: trim($this->getStringOrNullValue($templateNode->filterXPath('//gutscheinart')) ?? ''),
|
||||||
|
label: trim($this->getStringOrNullValue($templateNode->filterXPath('//bezeichnung')) ?? ''),
|
||||||
|
note: null !== $note && '' !== trim($note) ? trim($note) : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new VoucherTemplates(
|
||||||
|
// whole euros: the flow only offers whole-euro amounts, so the bounds are kept as such
|
||||||
|
minAmount: (int) ceil($this->stringToFloat($minAmount)),
|
||||||
|
maxAmount: (int) floor($this->stringToFloat($maxAmount)),
|
||||||
|
templates: $templates,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\BookingClient;
|
||||||
|
use App\BusProNet\Client\TravelClient;
|
||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
@@ -78,7 +79,8 @@ class BookingRepairDraftCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingEditDraftRepository $draftRepository,
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly BookingClient $bookingClient,
|
||||||
|
private readonly TravelClient $travelClient,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly TravelDataProvider $travelDataProvider,
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
@@ -214,7 +216,7 @@ class BookingRepairDraftCommand extends Command
|
|||||||
|
|
||||||
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
||||||
{
|
{
|
||||||
$result = $this->apiClient->getBooking(
|
$result = $this->bookingClient->getBooking(
|
||||||
(string) $user->getEmail(),
|
(string) $user->getEmail(),
|
||||||
$this->crypt->decrypt((string) $user->getPassword()),
|
$this->crypt->decrypt((string) $user->getPassword()),
|
||||||
$bookingId,
|
$bookingId,
|
||||||
@@ -300,7 +302,7 @@ class BookingRepairDraftCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
|
$live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
|
||||||
if ($live instanceof Notification) {
|
if ($live instanceof Notification) {
|
||||||
$io->warning(sprintf(
|
$io->warning(sprintf(
|
||||||
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\TravelClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use League\Flysystem\FilesystemException;
|
use League\Flysystem\FilesystemException;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
@@ -29,7 +29,7 @@ class BpnFetchTravelCommand extends Command
|
|||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly TravelClient $travelClient,
|
||||||
private readonly FilesystemOperator $xmlExport,
|
private readonly FilesystemOperator $xmlExport,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
@@ -56,7 +56,7 @@ class BpnFetchTravelCommand extends Command
|
|||||||
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
|
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$xml = $this->apiClient->getTravelDataXml($travelId);
|
$xml = $this->travelClient->getTravelDataXml($travelId);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$io->error(sprintf('API request failed: %s', $e->getMessage()));
|
$io->error(sprintf('API request failed: %s', $e->getMessage()));
|
||||||
$this->logger->error('Failed to fetch travel data from API', [
|
$this->logger->error('Failed to fetch travel data from API', [
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Transport\XmlChannel;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -21,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class BpnReplayCommand extends Command
|
class BpnReplayCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly XmlChannel $channel,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ class BpnReplayCommand extends Command
|
|||||||
$io->info('Sending request to BPN API...');
|
$io->info('Sending request to BPN API...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->apiClient->sendRawXml($xml);
|
$response = $this->channel->replay($xml);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$io->error(sprintf('API request failed: %s', $e->getMessage()));
|
$io->error(sprintf('API request failed: %s', $e->getMessage()));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
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\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes local accounts that carry nothing worth keeping.
|
||||||
|
*
|
||||||
|
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
|
||||||
|
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
|
||||||
|
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
|
||||||
|
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
|
||||||
|
*
|
||||||
|
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
|
||||||
|
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
|
||||||
|
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
|
||||||
|
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
|
||||||
|
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
|
||||||
|
*
|
||||||
|
* An account is removed when all of the following hold:
|
||||||
|
*
|
||||||
|
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
|
||||||
|
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
|
||||||
|
* at all is the same state, spelled the way it was before the fallback landed;
|
||||||
|
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
|
||||||
|
* anybody had ruled on it;
|
||||||
|
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
|
||||||
|
*
|
||||||
|
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
|
||||||
|
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
|
||||||
|
*
|
||||||
|
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
|
||||||
|
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
|
||||||
|
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
|
||||||
|
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
|
||||||
|
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
|
||||||
|
* say out loud that the task destroys data.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:cleanup:users',
|
||||||
|
description: 'Removes accounts that hold no role, no draft and no group booking reference',
|
||||||
|
)]
|
||||||
|
class CleanupUsersCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* How many accounts are removed between two flushes. The unit of work holds every scheduled
|
||||||
|
* removal until then, and the first run has thousands of rows to get through, so it is a
|
||||||
|
* memory bound rather than a policy one.
|
||||||
|
*/
|
||||||
|
private const BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption(
|
||||||
|
'inactive-for',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Only consider accounts without a login for this many months (default: no such limit)',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'limit',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Maximum number of accounts to delete in one run (default: no limit)',
|
||||||
|
)
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
|
||||||
|
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
|
||||||
|
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
$force = (bool) $input->getOption('force');
|
||||||
|
|
||||||
|
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
|
||||||
|
$io->error('--inactive-for and --limit have to be at least 1 when given.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
|
||||||
|
|
||||||
|
$candidates = array_values(array_filter(
|
||||||
|
$this->userRepository->findCleanupCandidates($inactiveSince),
|
||||||
|
static fn (User $user): bool => self::isDisposable($user),
|
||||||
|
));
|
||||||
|
|
||||||
|
// After the role filter, never before it: --limit caps the accounts actually deleted, and
|
||||||
|
// the candidate query cannot tell which ones those are.
|
||||||
|
if (null !== $limit) {
|
||||||
|
$candidates = \array_slice($candidates, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $candidates) {
|
||||||
|
$io->success('No accounts to remove.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $dryRun || false === $force) {
|
||||||
|
$io->table(
|
||||||
|
['E-Mail', 'Letzter Login', 'Rollen'],
|
||||||
|
array_map(static fn (User $user): array => [
|
||||||
|
$user->getEmail(),
|
||||||
|
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
|
||||||
|
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
|
||||||
|
], $candidates),
|
||||||
|
);
|
||||||
|
|
||||||
|
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
|
||||||
|
|
||||||
|
if (true === $dryRun) {
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
|
||||||
|
// schedule entry missing --force turns into a mail rather than a task that quietly
|
||||||
|
// does nothing every week.
|
||||||
|
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $index => $user) {
|
||||||
|
// One line per account, before the removal rather than after: once the row is gone
|
||||||
|
// this is the only place left that records which account the uuid belonged to. Worth
|
||||||
|
// a log_entry row per deletion, including the several thousand the first run writes —
|
||||||
|
// they age out with the six-month log retention.
|
||||||
|
$this->logger->info('Deleting account without roles', [
|
||||||
|
'uuid' => $user->getUuid(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
'roles' => Role::assignedOnly($user->getRoles()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->entityManager->remove($user);
|
||||||
|
|
||||||
|
if (0 === ($index + 1) % self::BATCH_SIZE) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$count = \count($candidates);
|
||||||
|
|
||||||
|
$this->logger->info('Deleted accounts without roles', [
|
||||||
|
'count' => $count,
|
||||||
|
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success(sprintf('Deleted %d account(s).', $count));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
|
||||||
|
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
|
||||||
|
* spelling accounts kept that have not signed in since the fallback landed.
|
||||||
|
*
|
||||||
|
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
|
||||||
|
* account carrying one stays until somebody has ruled on it.
|
||||||
|
*/
|
||||||
|
private static function isDisposable(User $user): bool
|
||||||
|
{
|
||||||
|
$roles = $user->getRoles();
|
||||||
|
|
||||||
|
return [] === Role::pendingOnly($roles)
|
||||||
|
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Account;
|
namespace App\Controller\Account;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
@@ -39,7 +39,7 @@ class PersonalDataController extends AbstractController
|
|||||||
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
* @param AccountClient $accountClient BusProNet account client
|
||||||
* @param Crypt $crypt Encryption service for password handling
|
* @param Crypt $crypt Encryption service for password handling
|
||||||
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
|
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
|
||||||
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
||||||
@@ -48,7 +48,7 @@ class PersonalDataController extends AbstractController
|
|||||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly AccountClient $accountClient,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly BookingEditDataLoader $dataLoader,
|
private readonly BookingEditDataLoader $dataLoader,
|
||||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||||
@@ -100,7 +100,7 @@ class PersonalDataController extends AbstractController
|
|||||||
|
|
||||||
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
|
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
|
||||||
try {
|
try {
|
||||||
$updateResult = $this->apiClient->updatePersonalData($email, $password, $personalData);
|
$updateResult = $this->accountClient->updatePersonalData($email, $password, $personalData);
|
||||||
|
|
||||||
if ($updateResult instanceof Notification) {
|
if ($updateResult instanceof Notification) {
|
||||||
$this->logger->error('Personal data update rejected by BusProNet', [
|
$this->logger->error('Personal data update rejected by BusProNet', [
|
||||||
@@ -223,7 +223,7 @@ class PersonalDataController extends AbstractController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$personalData = $this
|
$personalData = $this
|
||||||
->apiClient
|
->accountClient
|
||||||
->getPersonalData($email, $password);
|
->getPersonalData($email, $password);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Form\Admin\Groups\AccommodationBookingType;
|
|||||||
use App\Repository\Groups\AdditionalServiceRepository;
|
use App\Repository\Groups\AdditionalServiceRepository;
|
||||||
use App\Repository\Groups\BoardServiceRepository;
|
use App\Repository\Groups\BoardServiceRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -33,6 +34,7 @@ class EditController extends AbstractController
|
|||||||
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
||||||
private readonly UserRepository $userRepo,
|
private readonly UserRepository $userRepo,
|
||||||
private readonly AccommodationBookingService $bookingService,
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +130,7 @@ class EditController extends AbstractController
|
|||||||
// The submitted changes stay on the entity but are never flushed, so the form
|
// The submitted changes stay on the entity but are never flushed, so the form
|
||||||
// comes back filled in with the error on it. Logging is safe here: the database
|
// comes back filled in with the error on it. Logging is safe here: the database
|
||||||
// handler writes through DBAL, not the EntityManager.
|
// handler writes through DBAL, not the EntityManager.
|
||||||
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
return $this->renderForm($booking, $form);
|
||||||
'booking' => $booking,
|
|
||||||
'form' => $form,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing never moves the booking on and never mails the customer: every status
|
// Editing never moves the booking on and never mails the customer: every status
|
||||||
@@ -160,9 +159,22 @@ class EditController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
|
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->renderForm($booking, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sidebar shows the stored price, not one computed from the submitted values: after a
|
||||||
|
* refused save the entity carries the unsaved changes, and the price there is labelled as the
|
||||||
|
* state of the last save.
|
||||||
|
*
|
||||||
|
* @param FormInterface<AccommodationBooking> $form
|
||||||
|
*/
|
||||||
|
private function renderForm(AccommodationBooking $booking, FormInterface $form): Response
|
||||||
|
{
|
||||||
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
||||||
'booking' => $booking,
|
'booking' => $booking,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
|
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/** Changes the length and the lines of a split-off period; the main period absorbs the difference. */
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class EditPeriodController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
use RedirectsToEditFormTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking-period/{id}/edit', name: 'app_admin_accommodationbooking_period_edit')]
|
||||||
|
public function index(AccommodationBookingPeriod $period, Request $request): Response
|
||||||
|
{
|
||||||
|
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
|
||||||
|
|
||||||
|
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
|
||||||
|
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
|
||||||
|
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
|
||||||
|
'hx_post' => $request->getRequestUri(),
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
try {
|
||||||
|
$violations = $this->bookingService->updatePeriod($booking, $period, $dto);
|
||||||
|
|
||||||
|
foreach ($violations as $violation) {
|
||||||
|
$form->addError(new FormError((string) $violation->getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === count($violations)) {
|
||||||
|
$this->addFlash('success', 'Der Zeitraum wurde aktualisiert.');
|
||||||
|
|
||||||
|
$this->logger->info('Updated a period of accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'periodId' => $period->getId(),
|
||||||
|
'nights' => $dto->nights,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to update a period over an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'periodId' => $period->getId(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
'title' => 'Zeitraum bearbeiten',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,12 +21,8 @@ class PdfController extends AbstractController
|
|||||||
#[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')]
|
#[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')]
|
||||||
public function index(AccommodationBooking $booking): Response
|
public function index(AccommodationBooking $booking): Response
|
||||||
{
|
{
|
||||||
if (false === $booking->isConfirmed()) {
|
// Offered in every status, so the office can check the document before it goes out
|
||||||
$this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.');
|
// with the confirmation email; it labels itself Angebot or Buchung accordingly.
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->pdfGenerator->createDownloadResponse($booking);
|
return $this->pdfGenerator->createDownloadResponse($booking);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the modals opened from the edit form: they save on their own and send the browser back to
|
||||||
|
* it, rebuilt with the new price.
|
||||||
|
*/
|
||||||
|
trait RedirectsToEditFormTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The return url is forwarded still encoded, the way return_url() handed it over, so that
|
||||||
|
* the edit page keeps leading back to the list the booking was opened from.
|
||||||
|
*/
|
||||||
|
private function editUrl(AccommodationBooking $booking, Request $request): string
|
||||||
|
{
|
||||||
|
$parameters = ['id' => $booking->getId()];
|
||||||
|
$returnUrl = $request->query->getString('r');
|
||||||
|
if ('' !== $returnUrl) {
|
||||||
|
$parameters['r'] = $returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Gives a split-off period's nights back to the main period, which reprices the booking. */
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class RemovePeriodController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
use RedirectsToEditFormTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking-period/{id}/remove', name: 'app_admin_accommodationbooking_period_remove')]
|
||||||
|
public function index(AccommodationBookingPeriod $period, Request $request): Response
|
||||||
|
{
|
||||||
|
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
|
||||||
|
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('remove_accommodation_booking_period_'.$period->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodId = $period->getId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$violations = $this->bookingService->removePeriod($booking, $period);
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to remove a period over an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'periodId' => $periodId,
|
||||||
|
]);
|
||||||
|
$this->addFlash('error', ImplausibleBookingPriceException::FORM_ERROR_MESSAGE);
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing only ever lengthens the main period, so this is reached only when the
|
||||||
|
// stay's dates were already inconsistent with the periods.
|
||||||
|
foreach ($violations as $violation) {
|
||||||
|
$this->addFlash('error', (string) $violation->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === count($violations)) {
|
||||||
|
$this->addFlash('success', 'Der Zeitraum wurde entfernt.');
|
||||||
|
|
||||||
|
$this->logger->info('Removed a period of accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'periodId' => $periodId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_remove_period.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'period' => $period,
|
||||||
|
'csrf_token_id' => 'remove_accommodation_booking_period_'.$period->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits nights off the start or the end of the stay, for a part of the group that arrives
|
||||||
|
* early or leaves late and is quoted as free-form lines instead of the booking's parameters.
|
||||||
|
* Each side can be split once; the period can be resized afterwards through its edit modal.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class SplitPeriodController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
use RedirectsToEditFormTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/admin/accommodation-booking/{id}/period/split/{position}',
|
||||||
|
name: 'app_admin_accommodationbooking_period_split',
|
||||||
|
requirements: ['position' => 'leading|trailing'],
|
||||||
|
)]
|
||||||
|
public function index(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, Request $request): Response
|
||||||
|
{
|
||||||
|
// One period per side. The edit page only offers the free sides, so this catches a page
|
||||||
|
// that was open while somebody else split the same side.
|
||||||
|
if (!$booking->canSplitOffPeriod($position)) {
|
||||||
|
$this->addFlash('error', AccommodationBookingPeriodPosition::Leading === $position
|
||||||
|
? 'Am Anfang wurde bereits ein Zeitraum abgeteilt.'
|
||||||
|
: 'Am Ende wurde bereits ein Zeitraum abgeteilt.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto = new AccommodationBookingPeriodDto(1, [new AccommodationBookingPeriodLineDto()]);
|
||||||
|
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
|
||||||
|
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
|
||||||
|
'hx_post' => $request->getRequestUri(),
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
try {
|
||||||
|
$violations = $this->bookingService->splitOffPeriod($booking, $position, $dto);
|
||||||
|
|
||||||
|
foreach ($violations as $violation) {
|
||||||
|
$form->addError(new FormError((string) $violation->getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === count($violations)) {
|
||||||
|
$this->addFlash('success', 'Der Zeitraum wurde abgeteilt.');
|
||||||
|
|
||||||
|
$this->logger->info('Split off a period of accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'position' => $position->value,
|
||||||
|
'nights' => $dto->nights,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to split off a period over an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderModal($booking, $position, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param FormInterface<AccommodationBookingPeriodDto> $form */
|
||||||
|
private function renderModal(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, FormInterface $form): Response
|
||||||
|
{
|
||||||
|
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
'title' => AccommodationBookingPeriodPosition::Leading === $position
|
||||||
|
? 'Zeitraum am Anfang abteilen'
|
||||||
|
: 'Zeitraum am Ende abteilen',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\ContactFormSubmission;
|
use App\BusProNet\Model\ContactFormSubmission;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
@@ -21,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_OAUTH2_API')]
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
class ContactFormController extends AbstractController
|
class ContactFormController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
|
public function __construct(private readonly AccountClient $accountClient, private readonly LoggerInterface $logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class ContactFormController extends AbstractController
|
|||||||
$personalData = PersonalData::fromContactFormSubmission($dto);
|
$personalData = PersonalData::fromContactFormSubmission($dto);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->apiClient->createAddress($personalData);
|
$result = $this->accountClient->createAddress($personalData);
|
||||||
|
|
||||||
if ($result instanceof Notification) {
|
if ($result instanceof Notification) {
|
||||||
$this->logger->info('Unable to submit contact form', [
|
$this->logger->info('Unable to submit contact form', [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_OAUTH2_PROFILE')]
|
#[IsGranted('ROLE_OAUTH2_PROFILE')]
|
||||||
class CrmAttributeController extends AbstractController
|
class CrmAttributeController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
|
public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class CrmAttributeController extends AbstractController
|
|||||||
$password = $this->crypt->decrypt($user->getPassword());
|
$password = $this->crypt->decrypt($user->getPassword());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$data = $this->apiClient->getCrmAttributes($email, $password);
|
$data = $this->accountClient->getCrmAttributes($email, $password);
|
||||||
|
|
||||||
if ($data instanceof Notification) {
|
if ($data instanceof Notification) {
|
||||||
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Model\ExportQuery;
|
||||||
|
use App\Service\ProductExportBuilder;
|
||||||
|
use App\Service\TravelExportBuilder;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk exports, for consumers that mirror our data rather than query it per row.
|
||||||
|
*
|
||||||
|
* Deliberately its own resource rather than a verb hung off the collection it exports.
|
||||||
|
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
|
||||||
|
* share a namespace with every date code and could only be kept apart from one by a
|
||||||
|
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
|
||||||
|
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
|
||||||
|
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
|
||||||
|
*/
|
||||||
|
#[Route('/api/exports')]
|
||||||
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
|
class ExportController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelExportBuilder $travelExportBuilder,
|
||||||
|
private readonly ProductExportBuilder $productExportBuilder,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every travel date x hotel pair with its pickups resolved, paginated.
|
||||||
|
*
|
||||||
|
* The counters in meta describe the whole export, not the page, so they repeat
|
||||||
|
* unchanged on every page and a consumer can abort after the first one.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
|
||||||
|
public function travels(
|
||||||
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||||
|
ExportQuery $query = new ExportQuery(),
|
||||||
|
): JsonResponse {
|
||||||
|
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
|
||||||
|
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
|
||||||
|
// status codes, so a failure here has to surface as a 5xx.
|
||||||
|
return $this->paginate($this->travelExportBuilder->build(), $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
|
||||||
|
*
|
||||||
|
* The website's product import. Same grain, envelope and failure behaviour as travels();
|
||||||
|
* the rows carry rooms, prices, services and selection groups instead of the
|
||||||
|
* disposition subset, and nothing is dropped for the consumer.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
|
||||||
|
public function products(
|
||||||
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||||
|
ExportQuery $query = new ExportQuery(),
|
||||||
|
): JsonResponse {
|
||||||
|
return $this->paginate($this->productExportBuilder->build(), $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginate(Export $export, ExportQuery $query): JsonResponse
|
||||||
|
{
|
||||||
|
$total = count($export->rows);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
|
||||||
|
'meta' => [
|
||||||
|
'page' => $query->page,
|
||||||
|
'perPage' => $query->perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'totalPages' => (int) ceil($total / $query->perPage),
|
||||||
|
'skipped' => $export->skipped,
|
||||||
|
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
|
||||||
|
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
|
||||||
|
'generatedAt' => $export->generatedAt->format('c'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -16,23 +17,15 @@ class LastUpdateController extends AbstractController
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A missing or unreadable marker answers `{"timestamp": null}`, never a 500: consumers
|
||||||
|
* poll this for change detection and treat null as "unknown".
|
||||||
|
*/
|
||||||
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
|
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
if (false === $this->xmlExport->has('uebertragung.info')) {
|
$info = XmlExportInfo::fromStorage($this->xmlExport);
|
||||||
$lastUploadAt = null;
|
|
||||||
} else {
|
|
||||||
$file = $this->xmlExport->read('uebertragung.info');
|
|
||||||
$lines = explode("\n", $file);
|
|
||||||
$line = trim($lines[0]);
|
|
||||||
|
|
||||||
if (true === empty($line)) {
|
return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
|
||||||
$lastUploadAt = null;
|
|
||||||
} else {
|
|
||||||
$lastUploadAt = \DateTime::createFromFormat('d.m.Y H:i:s', $line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->json(['timestamp' => $lastUploadAt->format('Y-m-d H:i:s')]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\TravelClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\Cache\ItemInterface;
|
|||||||
class ProductController extends AbstractController
|
class ProductController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly TravelClient $travelClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ class ProductController extends AbstractController
|
|||||||
$products = $this->cache->get('api_products', function (ItemInterface $item) {
|
$products = $this->cache->get('api_products', function (ItemInterface $item) {
|
||||||
$item->expiresAfter(3600); // 1 hour cache
|
$item->expiresAfter(3600); // 1 hour cache
|
||||||
|
|
||||||
return $this->apiClient->getProducts();
|
return $this->travelClient->getProducts();
|
||||||
});
|
});
|
||||||
|
|
||||||
return $this->json($products);
|
return $this->json($products);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\TravelClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DateCodeUtility;
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class TravelController extends AbstractController
|
class TravelController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly TravelClient $travelClient,
|
||||||
private readonly TravelDataProvider $travelDataService,
|
private readonly TravelDataProvider $travelDataService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class TravelController extends AbstractController
|
|||||||
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
|
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
try {
|
try {
|
||||||
$result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo);
|
$result = $this->travelClient->getHotelAvailability($dateId, $hotelId, $dateTo);
|
||||||
|
|
||||||
return $this->json($result);
|
return $this->json($result);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_OAUTH2_EMAIL')]
|
#[IsGranted('ROLE_OAUTH2_EMAIL')]
|
||||||
class UserinfoController extends AbstractController
|
class UserinfoController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
|
public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class UserinfoController extends AbstractController
|
|||||||
$password = $this->crypt->decrypt($user->getPassword());
|
$password = $this->crypt->decrypt($user->getPassword());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$data = $this->apiClient->getPersonalData($email, $password);
|
$data = $this->accountClient->getPersonalData($email, $password);
|
||||||
|
|
||||||
if ($data instanceof Notification) {
|
if ($data instanceof Notification) {
|
||||||
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ use App\Exception\NoRoomsAvailableException;
|
|||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Model\BookingQueryParams;
|
use App\Model\BookingQueryParams;
|
||||||
|
use App\Model\PurchaseFlow;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
|
use App\Service\PurchaseFlowResolver;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
|
|||||||
private readonly BookingSessionManager $bookingSessionService,
|
private readonly BookingSessionManager $bookingSessionService,
|
||||||
private readonly AgencyLoader $agencyLoader,
|
private readonly AgencyLoader $agencyLoader,
|
||||||
private readonly BookingSummaryAssembler $summaryDataService,
|
private readonly BookingSummaryAssembler $summaryDataService,
|
||||||
|
private readonly PurchaseFlowResolver $purchaseFlowResolver,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +106,7 @@ class IndexController extends AbstractController
|
|||||||
|
|
||||||
// Create fresh booking session with the provided parameters
|
// Create fresh booking session with the provided parameters
|
||||||
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
|
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
|
||||||
|
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Booking);
|
||||||
|
|
||||||
// Warm the CMS cache early so data is available on the login page
|
// Warm the CMS cache early so data is available on the login page
|
||||||
$this->summaryDataService->getCmsDataForProduct(
|
$this->summaryDataService->getCmsDataForProduct(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\BookingClient;
|
||||||
use App\BusProNet\Exception\TimeoutException;
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
use App\BusProNet\Model\BookingResponse;
|
use App\BusProNet\Model\BookingResponse;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
@@ -40,7 +40,7 @@ class Step3Controller extends AbstractBookingCreateController
|
|||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
private readonly BookingPriceCalculator $priceCalculator,
|
private readonly BookingPriceCalculator $priceCalculator,
|
||||||
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
|
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly BookingClient $bookingClient,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -76,7 +76,7 @@ class Step3Controller extends AbstractBookingCreateController
|
|||||||
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
|
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
|
||||||
|
|
||||||
// Validate booking data with API by submitting an inquiry booking
|
// Validate booking data with API by submitting an inquiry booking
|
||||||
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
|
$inquiryResponse = $this->bookingClient->createBookingInquiry($bookingCreateDto);
|
||||||
|
|
||||||
// Surface any insurance corrections made during payload building (e.g. a
|
// Surface any insurance corrections made during payload building (e.g. a
|
||||||
// stale price tier reassigned/cleared right before submission)
|
// stale price tier reassigned/cleared right before submission)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\BookingClient;
|
||||||
use App\BusProNet\Exception\TimeoutException;
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
@@ -37,7 +37,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
private readonly BookingConfigurator $bookingService,
|
private readonly BookingConfigurator $bookingService,
|
||||||
private readonly BookingSessionManager $bookingSessionService,
|
private readonly BookingSessionManager $bookingSessionService,
|
||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly BookingClient $bookingClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
|
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
@@ -80,7 +80,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
|
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
|
||||||
|
|
||||||
// Submit final booking (already validated in Step 3)
|
// Submit final booking (already validated in Step 3)
|
||||||
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
|
$bookingResponse = $this->bookingClient->createBooking($bookingCreateDto);
|
||||||
|
|
||||||
// Surface any insurance corrections made during payload building (e.g. a
|
// Surface any insurance corrections made during payload building (e.g. a
|
||||||
// stale price tier reassigned/cleared right before submission)
|
// stale price tier reassigned/cleared right before submission)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Booking;
|
namespace App\Controller\Booking;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\BookingClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
@@ -20,7 +20,7 @@ use function Symfony\Component\String\u;
|
|||||||
class DownloadController extends AbstractController
|
class DownloadController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly BookingClient $bookingClient,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -60,7 +60,7 @@ class DownloadController extends AbstractController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$file = $this
|
$file = $this
|
||||||
->apiClient
|
->bookingClient
|
||||||
->getDocuments($email, $password, $id, $type);
|
->getDocuments($email, $password, $id, $type);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$file = null;
|
$file = null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Booking;
|
namespace App\Controller\Booking;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\BookingClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\BaseData;
|
use App\BusProNet\Model\BaseData;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly BookingClient $bookingClient,
|
||||||
private readonly TravelLoader $travelDataLoader,
|
private readonly TravelLoader $travelDataLoader,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
@@ -36,7 +36,7 @@ class IndexController extends AbstractController
|
|||||||
$password = $this->crypt->decrypt($user->getPassword());
|
$password = $this->crypt->decrypt($user->getPassword());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$bookings = $this->apiClient->getBookings($email, $password);
|
$bookings = $this->bookingClient->getBookings($email, $password);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$this->addFlash('error', 'Buchungen nicht abrufbar');
|
$this->addFlash('error', 'Buchungen nicht abrufbar');
|
||||||
$bookings = new BaseData([]);
|
$bookings = new BaseData([]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Form\Model\RegistrationDto;
|
use App\Form\Model\RegistrationDto;
|
||||||
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class RegistrationController extends AbstractController
|
class RegistrationController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly AccountClient $accountClient,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ class RegistrationController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
try {
|
try {
|
||||||
$response = $this->apiClient->register($registrationData);
|
$response = $this->accountClient->register($registrationData);
|
||||||
if ($response instanceof Notification && true === $response->isError()) {
|
if ($response instanceof Notification && true === $response->isError()) {
|
||||||
$this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.');
|
$this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\Client\AccountClient;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank;
|
|||||||
|
|
||||||
class ResetPasswordController extends AbstractController
|
class ResetPasswordController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
|
public function __construct(private readonly AccountClient $accountClient, private readonly LoggerInterface $logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class ResetPasswordController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
try {
|
try {
|
||||||
$this->apiClient->resetPassword($form->get('email')->getData());
|
$this->accountClient->resetPassword($form->get('email')->getData());
|
||||||
$this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.');
|
$this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.');
|
||||||
$this->logger->info('Initiated password reset', [
|
$this->logger->info('Initiated password reset', [
|
||||||
'email' => $form->get('email')->getData(),
|
'email' => $form->get('email')->getData(),
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Model\PurchaseFlow;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
|
use App\Service\PurchaseFlowResolver;
|
||||||
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -23,31 +25,31 @@ class SecurityController extends AbstractController
|
|||||||
BookingSessionManager $bookingSessionService,
|
BookingSessionManager $bookingSessionService,
|
||||||
BookingSummaryAssembler $summaryDataService,
|
BookingSummaryAssembler $summaryDataService,
|
||||||
ClientManagerInterface $clientManager,
|
ClientManagerInterface $clientManager,
|
||||||
|
PurchaseFlowResolver $purchaseFlowResolver,
|
||||||
): Response {
|
): Response {
|
||||||
// flag this request in the session as external OAuth2 request if
|
// flag this request in the session as external OAuth2 request if
|
||||||
// applicable to immediately logout the current user after successful
|
// applicable to immediately logout the current user after successful
|
||||||
// authentication (see App\EventListener\AuthorizationCodeListener).
|
// authentication (see App\EventListener\AuthorizationCodeListener).
|
||||||
// Read before the booking flow is decided, because it overrules it.
|
// Read before the purchase flow is decided, because it overrules it.
|
||||||
$session = $request->getSession();
|
$session = $request->getSession();
|
||||||
$targetPath = $session->get('_security.main.target_path');
|
$targetPath = $session->get('_security.main.target_path');
|
||||||
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
|
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
|
||||||
$session->set('_oauth2', $isOauth2);
|
$session->set('_oauth2', $isOauth2);
|
||||||
|
|
||||||
// Check if this is a booking flow (BookingDto exists in session)
|
// A purchase flow (booking or voucher) uses this page as its optional sign-in step
|
||||||
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
|
$pendingFlow = $purchaseFlowResolver->resolvePending($request);
|
||||||
$hasPendingBooking = null !== $bookingDto;
|
|
||||||
|
|
||||||
// A pending authorization wins over a half-finished booking. Otherwise the target path
|
// A pending authorization wins over a half-finished purchase. Otherwise the target path
|
||||||
// below is overwritten with booking step 1, the user is sent there after logging in, and
|
// below is overwritten with the flow's first step, the user is sent there after logging in,
|
||||||
// the application that asked for the authorization never receives its code.
|
// and the application that asked for the authorization never receives its code.
|
||||||
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
|
$purchaseFlow = false === $isOauth2 ? $pendingFlow : null;
|
||||||
|
|
||||||
// If authenticated and in booking flow, proceed to Step 1
|
// If authenticated and in a purchase flow, proceed to its first step
|
||||||
if (null !== $this->getUser() && true === $hasPendingBooking) {
|
if (null !== $this->getUser() && null !== $pendingFlow) {
|
||||||
return $this->redirectToRoute('app_booking_create_step_1');
|
return $this->redirectToRoute($pendingFlow->firstStepRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If authenticated but not in booking flow, redirect to dashboard
|
// If authenticated but not in a purchase flow, redirect to dashboard
|
||||||
if (null !== $this->getUser()) {
|
if (null !== $this->getUser()) {
|
||||||
return $this->redirectToRoute('app_account');
|
return $this->redirectToRoute('app_account');
|
||||||
}
|
}
|
||||||
@@ -55,22 +57,25 @@ class SecurityController extends AbstractController
|
|||||||
$error = $authenticationUtils->getLastAuthenticationError();
|
$error = $authenticationUtils->getLastAuthenticationError();
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
$lastUsername = $authenticationUtils->getLastUsername();
|
||||||
|
|
||||||
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
|
// For a purchase flow, continue with its first step after successful auth
|
||||||
if (true === $isBookingFlow) {
|
if (null !== $purchaseFlow) {
|
||||||
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
|
$session->set('_security.main.target_path', $this->generateUrl($purchaseFlow->firstStepRoute()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render booking login template if in booking flow, the self-explaining one when another
|
// Render the flow's login template if in a purchase flow, the self-explaining one when
|
||||||
// application sent them here to authorize it, otherwise standard login
|
// another application sent them here to authorize it, otherwise standard login
|
||||||
$template = match (true) {
|
$template = match (true) {
|
||||||
$isBookingFlow => 'booking/create/authenticate.html.twig',
|
null !== $purchaseFlow => $purchaseFlow->authenticateTemplate(),
|
||||||
$isOauth2 => 'security/login_oauth2.html.twig',
|
$isOauth2 => 'security/login_oauth2.html.twig',
|
||||||
default => 'security/login.html.twig',
|
default => 'security/login.html.twig',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch CMS data for booking flow (uses cache warmed in IndexController)
|
// Fetch travel and CMS data for the booking flow (uses cache warmed in IndexController)
|
||||||
|
$bookingDto = PurchaseFlow::Booking === $purchaseFlow
|
||||||
|
? $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE)
|
||||||
|
: null;
|
||||||
$cmsData = null;
|
$cmsData = null;
|
||||||
if (true === $isBookingFlow) {
|
if (null !== $bookingDto) {
|
||||||
$cmsData = $summaryDataService->getCmsDataForProduct(
|
$cmsData = $summaryDataService->getCmsDataForProduct(
|
||||||
$bookingDto->travel->productCode,
|
$bookingDto->travel->productCode,
|
||||||
$bookingDto->travel->hotel?->code
|
$bookingDto->travel->hotel?->code
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use App\Service\VoucherThumbnailResolver;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared base for the steps of the voucher purchase flow.
|
||||||
|
*/
|
||||||
|
abstract class AbstractVoucherPurchaseController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
|
public const array STEP_ROUTES = [
|
||||||
|
1 => 'app_voucher_step_1',
|
||||||
|
2 => 'app_voucher_step_2',
|
||||||
|
3 => 'app_voucher_step_3',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected readonly VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
protected readonly VoucherThumbnailResolver $thumbnailResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createSessionExpiredResponse(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateStepAccess(VoucherPurchaseDto $dto, int $expectedStep): ?RedirectResponse
|
||||||
|
{
|
||||||
|
if ($expectedStep > $dto->currentStep) {
|
||||||
|
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
|
||||||
|
|
||||||
|
return $this->redirectToStep($dto->currentStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirectToStep(int $step): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->redirectToRoute(self::STEP_ROUTES[$step] ?? self::STEP_ROUTES[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context shared by every step template: the sidebar summary and the step pagination.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function createStepContext(VoucherPurchaseDto $dto): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'voucher' => $dto,
|
||||||
|
'voucherThumbnail' => null !== $dto->template ? $this->thumbnailResolver->resolve($dto->template) : null,
|
||||||
|
'stepRoutes' => self::STEP_ROUTES,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\Exception\VoucherShopUnavailableException;
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Model\PurchaseFlow;
|
||||||
|
use App\Service\PurchaseFlowResolver;
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use App\Service\VoucherTemplateProvider;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry, cancellation and error pages of the voucher purchase flow, which replaces the voucher
|
||||||
|
* shop hosted by BusProNet (maske_gutschein.php).
|
||||||
|
*/
|
||||||
|
class IndexController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
private readonly VoucherTemplateProvider $templateProvider,
|
||||||
|
private readonly PurchaseFlowResolver $purchaseFlowResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a loading page that posts to init, as the booking flow does.
|
||||||
|
*/
|
||||||
|
#[Route('/gutschein', name: 'app_voucher', methods: ['GET'])]
|
||||||
|
public function index(#[MapQueryParameter('r')] ?string $returnUrl = null): Response
|
||||||
|
{
|
||||||
|
return $this->render('voucher/index.html.twig', [
|
||||||
|
'returnUrl' => $returnUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a fresh purchase and sends the visitor to the login page (sign in or continue as guest).
|
||||||
|
*
|
||||||
|
* POST-only for the same reason as app_booking_create_init: it resets session state and calls
|
||||||
|
* BusPro, so nothing that speculatively fetches URLs may trigger it.
|
||||||
|
*/
|
||||||
|
#[Route('/gutschein/init', name: 'app_voucher_init', methods: ['POST'])]
|
||||||
|
public function init(Request $request, #[MapQueryParameter('r')] ?string $returnUrl = null): Response
|
||||||
|
{
|
||||||
|
$this->sessionManager->clear($request);
|
||||||
|
$this->sessionManager->storeReturnUrl($request, $returnUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$templates = $this->templateProvider->getTemplates();
|
||||||
|
$paymentMethod = $this->templateProvider->getPaymentMethod();
|
||||||
|
} catch (VoucherShopUnavailableException) {
|
||||||
|
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto = new VoucherPurchaseDto();
|
||||||
|
$dto->minAmount = $templates->minAmount;
|
||||||
|
$dto->maxAmount = $templates->maxAmount;
|
||||||
|
$dto->paymentMethod = $paymentMethod;
|
||||||
|
if (1 === \count($templates->templates)) {
|
||||||
|
$dto->template = $templates->templates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Voucher);
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/abbrechen', name: 'app_voucher_cancel', methods: ['GET', 'POST'])]
|
||||||
|
public function cancel(Request $request): Response
|
||||||
|
{
|
||||||
|
if (Request::METHOD_POST !== $request->getMethod()) {
|
||||||
|
return $this->render('voucher/modal_cancel.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnUrl = $this->sessionManager->getReturnUrl($request);
|
||||||
|
$this->sessionManager->clear($request);
|
||||||
|
|
||||||
|
// Without this, a later login would send the user back into the cancelled purchase
|
||||||
|
$request->getSession()->remove('_security.main.target_path');
|
||||||
|
|
||||||
|
if (null !== $this->getUser()) {
|
||||||
|
$this->addFlash('info', 'Gutscheinkauf abgebrochen.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_account'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/fehler', name: 'app_voucher_error', methods: ['GET'])]
|
||||||
|
public function error(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->render('booking/create/error.html.twig', [
|
||||||
|
'returnUrl' => $this->sessionManager->getReturnUrl($request),
|
||||||
|
'defaultErrorMessage' => 'Es ist ein Fehler beim Gutscheinkauf aufgetreten.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\BusProNet\Client\VoucherClient;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\Exception\VoucherSessionNotFoundException;
|
||||||
|
use App\Exception\VoucherShopUnavailableException;
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use App\Form\VoucherPurchaseStep1Type;
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use App\Service\VoucherTemplateProvider;
|
||||||
|
use App\Service\VoucherThumbnailResolver;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: design, von/für and value, with a PDF preview rendered by BusPro.
|
||||||
|
*/
|
||||||
|
class Step1Controller extends AbstractVoucherPurchaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
VoucherThumbnailResolver $thumbnailResolver,
|
||||||
|
private readonly VoucherTemplateProvider $templateProvider,
|
||||||
|
private readonly VoucherClient $voucherClient,
|
||||||
|
private readonly ValidatorInterface $validator,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct($sessionManager, $thumbnailResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/auswahl', name: 'app_voucher_step_1')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$dto = $this->sessionManager->find($request);
|
||||||
|
if (null === $dto) {
|
||||||
|
return $this->createSessionExpiredResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$form = $this->createStepForm($dto);
|
||||||
|
} catch (VoucherShopUnavailableException) {
|
||||||
|
return $this->createShopUnavailableResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||||
|
$dto->currentStep = 2;
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_voucher_step_2');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes over the current input without validating it and refreshes the summary.
|
||||||
|
*/
|
||||||
|
#[Route('/gutschein/auswahl/aktualisieren', name: 'app_voucher_step_1_refresh', methods: ['POST'])]
|
||||||
|
public function refresh(Request $request): Response
|
||||||
|
{
|
||||||
|
$dto = $this->sessionManager->find($request);
|
||||||
|
if (null === $dto) {
|
||||||
|
return $this->createSessionExpiredResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$form = $this->createStepForm($dto, false);
|
||||||
|
} catch (VoucherShopUnavailableException) {
|
||||||
|
return $this->createShopUnavailableResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
|
||||||
|
return $this->htmxOobResponse('voucher/step_1.html.twig', ['voucher_summary'], [
|
||||||
|
...$this->createStepContext($dto),
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes over the current input like refresh() and opens the preview modal, whose frame loads
|
||||||
|
* the PDF from previewPdf().
|
||||||
|
*/
|
||||||
|
#[Route('/gutschein/auswahl/vorschau', name: 'app_voucher_preview', methods: ['POST'])]
|
||||||
|
public function preview(Request $request): Response
|
||||||
|
{
|
||||||
|
$dto = $this->sessionManager->find($request);
|
||||||
|
if (null === $dto) {
|
||||||
|
return $this->createSessionExpiredResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$form = $this->createStepForm($dto, false);
|
||||||
|
} catch (VoucherShopUnavailableException) {
|
||||||
|
return $this->createShopUnavailableResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
|
||||||
|
return $this->render('voucher/modal_preview.html.twig', [
|
||||||
|
'previewable' => $this->isPreviewable($dto),
|
||||||
|
// busts the browser cache of the frame, the URL is otherwise the same for every input
|
||||||
|
'previewVersion' => bin2hex(random_bytes(4)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/vorschau.pdf', name: 'app_voucher_preview_pdf', methods: ['GET'])]
|
||||||
|
public function previewPdf(Request $request): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dto = $this->sessionManager->get($request);
|
||||||
|
} catch (VoucherSessionNotFoundException) {
|
||||||
|
return $this->createPreviewErrorResponse('Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $this->isPreviewable($dto)) {
|
||||||
|
return $this->createPreviewErrorResponse('Für die Vorschau bitte zuerst Motiv, Von, Für und Gutscheinwert angeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only from here on: release the session lock, so the page's other requests do not
|
||||||
|
// wait the seconds BusPro takes to render the PDF.
|
||||||
|
$request->getSession()->save();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$preview = $this->voucherClient->getVoucherPreview($dto);
|
||||||
|
} catch (ApiClientException $e) {
|
||||||
|
$this->logger->error('Voucher preview failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return $this->createPreviewErrorResponse('Die Vorschau ist gerade nicht verfügbar. Bitte versuche es später noch einmal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($preview instanceof Notification) {
|
||||||
|
$this->logger->warning('Voucher preview rejected', ['code' => $preview->code, 'message' => $preview->message]);
|
||||||
|
|
||||||
|
return $this->createPreviewErrorResponse($preview->message ?? 'Die Vorschau ist gerade nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new Response($preview->pdf, Response::HTTP_OK, ['Content-Type' => 'application/pdf']);
|
||||||
|
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||||
|
'gutschein-vorschau.pdf'
|
||||||
|
));
|
||||||
|
$response->setPrivate();
|
||||||
|
$response->headers->addCacheControlDirective('no-store');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return FormInterface<VoucherPurchaseDto>
|
||||||
|
*
|
||||||
|
* @throws VoucherShopUnavailableException
|
||||||
|
*/
|
||||||
|
private function createStepForm(VoucherPurchaseDto $dto, bool $validate = true): FormInterface
|
||||||
|
{
|
||||||
|
$templates = $this->templateProvider->getTemplates();
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'templates' => $templates->templates,
|
||||||
|
'min_amount' => $dto->minAmount,
|
||||||
|
'max_amount' => $dto->maxAmount,
|
||||||
|
];
|
||||||
|
if (false === $validate) {
|
||||||
|
$options['validation_groups'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createForm(VoucherPurchaseStep1Type::class, $dto, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface<VoucherPurchaseDto> $form
|
||||||
|
*/
|
||||||
|
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form): Response
|
||||||
|
{
|
||||||
|
$templates = $this->templateProvider->getTemplates();
|
||||||
|
|
||||||
|
return $this->render('voucher/step_1.html.twig', [
|
||||||
|
...$this->createStepContext($dto),
|
||||||
|
'form' => $form,
|
||||||
|
'thumbnails' => $this->thumbnailResolver->resolveAll($templates->templates),
|
||||||
|
'previewable' => $this->isPreviewable($dto),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview takes what step 1 accepts: BusPro refuses it without the names (604/605), and
|
||||||
|
* this keeps the button and the route to one rule, the step's own validation group.
|
||||||
|
*/
|
||||||
|
private function isPreviewable(VoucherPurchaseDto $dto): bool
|
||||||
|
{
|
||||||
|
return 0 === \count($this->validator->validate($dto, null, ['voucher_step_1']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createShopUnavailableResponse(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPreviewErrorResponse(string $message): Response
|
||||||
|
{
|
||||||
|
return $this->render('voucher/preview_error.html.twig', ['message' => $message], new Response('', Response::HTTP_UNPROCESSABLE_ENTITY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\VoucherPurchaseStep2Type;
|
||||||
|
use App\Service\ParticipantDataPrefiller;
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use App\Service\VoucherThumbnailResolver;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: the buyer (rechnungsempfänger), prefilled for signed-in accounts. Payment is fixed to
|
||||||
|
* transfer.
|
||||||
|
*/
|
||||||
|
class Step2Controller extends AbstractVoucherPurchaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
VoucherThumbnailResolver $thumbnailResolver,
|
||||||
|
private readonly ParticipantDataPrefiller $personalDataPrefiller,
|
||||||
|
) {
|
||||||
|
parent::__construct($sessionManager, $thumbnailResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/kaeufer', name: 'app_voucher_step_2')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$dto = $this->sessionManager->find($request);
|
||||||
|
if (null === $dto) {
|
||||||
|
return $this->createSessionExpiredResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $redirect = $this->validateStepAccess($dto, 2)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->getUser();
|
||||||
|
if ($user instanceof User && false === $dto->hasBuyerData()) {
|
||||||
|
$personalData = $this->personalDataPrefiller->fetchPersonalData($user);
|
||||||
|
if (null !== $personalData) {
|
||||||
|
$dto->prefillBuyer($personalData, $user->getEmail());
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(VoucherPurchaseStep2Type::class, $dto);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||||
|
$dto->currentStep = 3;
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_voucher_step_3');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('voucher/step_2.html.twig', [
|
||||||
|
...$this->createStepContext($dto),
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\BusProNet\Client\VoucherClient;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use App\Form\VoucherPurchaseStep3Type;
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use App\Service\VoucherSaleRejectionMapper;
|
||||||
|
use App\Service\VoucherThumbnailResolver;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: overview, the two required confirmations and the purchase.
|
||||||
|
*
|
||||||
|
* Every visit runs GUTSCHEINBUCHUNG as a dry run (buchungsart Anfrage) first, which applies BusPro's
|
||||||
|
* full validation without issuing anything, and the buy button is only offered when it passes. The
|
||||||
|
* purchase sends the same payload with buchungsart Buchung.
|
||||||
|
*
|
||||||
|
* Every HINWEIS is a rejection on this path, 650 included, so Notification::isError() is not used.
|
||||||
|
*/
|
||||||
|
class Step3Controller extends AbstractVoucherPurchaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
VoucherThumbnailResolver $thumbnailResolver,
|
||||||
|
private readonly VoucherClient $voucherClient,
|
||||||
|
private readonly VoucherSaleRejectionMapper $rejectionMapper,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct($sessionManager, $thumbnailResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/uebersicht', name: 'app_voucher_step_3')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$dto = $this->sessionManager->find($request);
|
||||||
|
if (null === $dto) {
|
||||||
|
// also the second of two submits: the first one cleared the session before redirecting
|
||||||
|
return $this->createSessionExpiredResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $redirect = $this->validateStepAccess($dto, 3)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(VoucherPurchaseStep3Type::class, $dto, [
|
||||||
|
'amount' => $dto->amount ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (false === $request->isMethod(Request::METHOD_POST)) {
|
||||||
|
$dto->termsAccepted = false;
|
||||||
|
$dto->purchaseConfirmed = false;
|
||||||
|
|
||||||
|
return $this->runDryRun($request, $dto, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||||
|
return $this->purchase($request, $dto, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface<VoucherPurchaseDto> $form
|
||||||
|
*/
|
||||||
|
private function runDryRun(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->voucherClient->createVoucherSaleInquiry($dto);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Voucher sale inquiry failed', ['exception' => $e->getMessage()]);
|
||||||
|
$this->addFlash('error', 'Der Gutscheinkauf konnte gerade nicht geprüft werden. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof Notification) {
|
||||||
|
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale inquiry rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $response->isInquiryValid()) {
|
||||||
|
$this->logger->error('Voucher sale inquiry unsuccessful', ['status' => $response->status]);
|
||||||
|
$this->addFlash('error', 'Der Gutschein kann gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface<VoucherPurchaseDto> $form
|
||||||
|
*/
|
||||||
|
private function purchase(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->voucherClient->createVoucherSale($dto);
|
||||||
|
} catch (TimeoutException $e) {
|
||||||
|
// The purchase may have gone through on BusPro's side, so a blind retry could buy twice
|
||||||
|
$this->logger->error('Voucher sale timeout', ['exception' => $e->getMessage()]);
|
||||||
|
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Möglicherweise wurde der Gutschein trotzdem gekauft. Bitte wende dich an den Kundenservice, bevor du es erneut versuchst.');
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, false);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Voucher sale exception', ['exception' => $e->getMessage()]);
|
||||||
|
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof Notification) {
|
||||||
|
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $response->isSaleSuccessful()) {
|
||||||
|
$this->logger->error('Voucher sale unsuccessful', ['status' => $response->status]);
|
||||||
|
$this->addFlash('error', 'Der Gutschein konnte nicht gekauft werden. Bitte versuche es später noch einmal.');
|
||||||
|
|
||||||
|
return $this->renderStep($dto, $form, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleared before the redirect, so a second submit finds no purchase and cannot buy twice
|
||||||
|
$this->sessionManager->clear($request);
|
||||||
|
|
||||||
|
// The redemption code stays out of the log and the page: the voucher is only paid for once
|
||||||
|
// the transfer arrives, and BusPro mails it then.
|
||||||
|
$this->logger->info('Voucher successfully sold.', [
|
||||||
|
'voucher_id' => $response->voucherId,
|
||||||
|
'voucher_number' => $response->voucherNumber,
|
||||||
|
'invoice_number' => $response->invoiceNumber,
|
||||||
|
'template_id' => $dto->template?->id,
|
||||||
|
'amount' => $dto->amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->addFlash('voucher_number', $response->voucherNumber);
|
||||||
|
$this->addFlash('voucher_invoice_number', $response->invoiceNumber);
|
||||||
|
$this->addFlash('voucher_total', $response->price ?? (float) $dto->amount);
|
||||||
|
$this->addFlash('voucher_label', $dto->template?->label);
|
||||||
|
$this->addFlash('voucher_email', $response->email ?? $dto->email);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_voucher_success');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the visitor to the step that owns the complaint, or keeps them here without a buy
|
||||||
|
* button when the complaint is about nothing they can change.
|
||||||
|
*
|
||||||
|
* @param FormInterface<VoucherPurchaseDto> $form
|
||||||
|
*/
|
||||||
|
private function handleRejection(
|
||||||
|
Request $request,
|
||||||
|
VoucherPurchaseDto $dto,
|
||||||
|
FormInterface $form,
|
||||||
|
Notification $notification,
|
||||||
|
string $logMessage,
|
||||||
|
): Response {
|
||||||
|
$step = $this->rejectionMapper->stepFor($notification);
|
||||||
|
|
||||||
|
$this->logger->warning($logMessage, [
|
||||||
|
'code' => $notification->code,
|
||||||
|
'message' => $notification->message,
|
||||||
|
'step' => $step,
|
||||||
|
]);
|
||||||
|
$this->addFlash('error', $notification->message ?? 'Der Gutschein kann so nicht gekauft werden.');
|
||||||
|
|
||||||
|
if (VoucherSaleRejectionMapper::STEP_OVERVIEW === $step) {
|
||||||
|
return $this->renderStep($dto, $form, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto->currentStep = $step;
|
||||||
|
$this->sessionManager->save($request, $dto);
|
||||||
|
|
||||||
|
return $this->redirectToStep($step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface<VoucherPurchaseDto> $form
|
||||||
|
*/
|
||||||
|
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form, bool $purchasable): Response
|
||||||
|
{
|
||||||
|
return $this->render('voucher/step_3.html.twig', [
|
||||||
|
...$this->createStepContext($dto),
|
||||||
|
'form' => $form,
|
||||||
|
'purchasable' => $purchasable,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Voucher;
|
||||||
|
|
||||||
|
use App\Service\VoucherPurchaseSessionManager;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page after a completed voucher purchase. Its data comes as flashes, so a reload or a direct
|
||||||
|
* visit falls back to the return URL.
|
||||||
|
*/
|
||||||
|
class SuccessController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly VoucherPurchaseSessionManager $sessionManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/gutschein/erfolg', name: 'app_voucher_success')]
|
||||||
|
public function success(Request $request): Response
|
||||||
|
{
|
||||||
|
$returnUrl = $this->sessionManager->getReturnUrl($request);
|
||||||
|
$session = $request->getSession();
|
||||||
|
|
||||||
|
if (!$session instanceof FlashBagAwareSessionInterface) {
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flashBag = $session->getFlashBag();
|
||||||
|
$voucherNumber = $flashBag->get('voucher_number')[0] ?? null;
|
||||||
|
$invoiceNumber = $flashBag->get('voucher_invoice_number')[0] ?? null;
|
||||||
|
$total = $flashBag->get('voucher_total')[0] ?? null;
|
||||||
|
$label = $flashBag->get('voucher_label')[0] ?? null;
|
||||||
|
$email = $flashBag->get('voucher_email')[0] ?? null;
|
||||||
|
|
||||||
|
// the total is always set on a completed purchase, the voucher number only if BusPro sends it
|
||||||
|
if (null === $total) {
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('voucher/success.html.twig', [
|
||||||
|
'voucherNumber' => $voucherNumber,
|
||||||
|
'invoiceNumber' => $invoiceNumber,
|
||||||
|
'total' => $total,
|
||||||
|
'label' => $label,
|
||||||
|
'email' => $email,
|
||||||
|
'returnUrl' => $returnUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,13 @@ use App\Entity\TimestampableEntity;
|
|||||||
use App\Entity\TimestampableEntityInterface;
|
use App\Entity\TimestampableEntityInterface;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
use App\Enum\Groups\AccommodationBookingStatus;
|
use App\Enum\Groups\AccommodationBookingStatus;
|
||||||
use App\Enum\Groups\AdditionalServiceType;
|
use App\Enum\Groups\AdditionalServiceType;
|
||||||
use App\Exception\ImplausibleBookingPriceException;
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Repository\Groups\AccommodationBookingRepository;
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
@@ -195,9 +198,21 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||||
private ?User $managedBy = null;
|
private ?User $managedBy = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nights split off the start or the end of the stay and quoted as free-form lines, at most
|
||||||
|
* one on each side. dateFrom and dateTo keep spanning the whole stay; the booking's own
|
||||||
|
* parameters price only the main period left between the leading and the trailing period.
|
||||||
|
*
|
||||||
|
* @var Collection<int, AccommodationBookingPeriod>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(targetEntity: AccommodationBookingPeriod::class, mappedBy: 'booking', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
|
||||||
|
private Collection $periods;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->uuid = Uuid::v4()->toRfc4122();
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
|
$this->periods = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,10 +226,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
* The price snapshot, the service snapshots and all four discounts are deliberately carried
|
* The price snapshot, the service snapshots and all four discounts are deliberately carried
|
||||||
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
|
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
|
||||||
* the same numbers as the record it came from rather than a re-quote at today's catalog
|
* the same numbers as the record it came from rather than a re-quote at today's catalog
|
||||||
* prices; saving it through the edit form recomputes it.
|
* prices; saving it through the edit form recomputes it. The split-off periods belong to
|
||||||
|
* that quote and are copied along with it.
|
||||||
*/
|
*/
|
||||||
public function __clone()
|
public function __clone()
|
||||||
{
|
{
|
||||||
|
$periods = $this->periods->toArray();
|
||||||
|
$this->periods = new ArrayCollection();
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$this->addPeriod(clone $period);
|
||||||
|
}
|
||||||
|
|
||||||
$this->id = null;
|
$this->id = null;
|
||||||
$this->uuid = Uuid::v4()->toRfc4122();
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
$this->status = AccommodationBookingStatus::Draft;
|
$this->status = AccommodationBookingStatus::Draft;
|
||||||
@@ -302,6 +324,174 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At most one period on each side, attached to its edge of the stay, and at least one night
|
||||||
|
* left for the main period in between. A change to the stay's dates that no longer fits the
|
||||||
|
* periods is reported on that date rather than silently moving the periods along.
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validatePeriods(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null === $this->dateFrom || null === $this->dateTo || $this->periods->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (AccommodationBookingPeriodPosition::cases() as $position) {
|
||||||
|
if (count($this->periodsAt($position)) > 1) {
|
||||||
|
$context->buildViolation('Es kann nur einen Zeitraum am Anfang und einen am Ende geben.')
|
||||||
|
->addViolation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$leading = $this->getLeadingPeriod();
|
||||||
|
if (null !== $leading && (!self::sameDay($leading->getDateFrom(), $this->dateFrom) || $leading->getNights() < 1)) {
|
||||||
|
$context->buildViolation('Der Zeitraum am Anfang passt nicht mehr zur Anreise — bitte Anreise oder Zeitraum prüfen.')
|
||||||
|
->atPath('dateFrom')
|
||||||
|
->addViolation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trailing = $this->getTrailingPeriod();
|
||||||
|
if (null !== $trailing && (!self::sameDay($trailing->getDateTo(), $this->dateTo) || $trailing->getNights() < 1)) {
|
||||||
|
$context->buildViolation('Der Zeitraum am Ende passt nicht mehr zur Abreise — bitte Abreise oder Zeitraum prüfen.')
|
||||||
|
->atPath('dateTo')
|
||||||
|
->addViolation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->getMainNights() < 1) {
|
||||||
|
$context->buildViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.')
|
||||||
|
->atPath('dateTo')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, AccommodationBookingPeriod> */
|
||||||
|
public function getPeriods(): Collection
|
||||||
|
{
|
||||||
|
return $this->periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addPeriod(AccommodationBookingPeriod $period): self
|
||||||
|
{
|
||||||
|
if (!$this->periods->contains($period)) {
|
||||||
|
$this->periods->add($period);
|
||||||
|
$period->setBooking($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSplitOffPeriod(AccommodationBookingPeriodPosition $position): bool
|
||||||
|
{
|
||||||
|
return [] === $this->periodsAt($position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits $nights off the main period, at the edge of the stay that $position names. */
|
||||||
|
public function splitOffPeriod(AccommodationBookingPeriodPosition $position, int $nights): AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
if (null === $this->dateFrom || null === $this->dateTo) {
|
||||||
|
throw new \LogicException('A period can only be split off a booking with dates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->canSplitOffPeriod($position)) {
|
||||||
|
throw new \LogicException(sprintf('The booking already has a %s period.', $position->value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = new AccommodationBookingPeriod($position, $this->dateFrom, $this->dateTo);
|
||||||
|
$this->addPeriod($period);
|
||||||
|
$this->resizePeriod($period, $nights);
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives $period a new length, keeping it at its edge of the stay; the main period absorbs the difference. */
|
||||||
|
public function resizePeriod(AccommodationBookingPeriod $period, int $nights): self
|
||||||
|
{
|
||||||
|
if (null === $this->dateFrom || null === $this->dateTo) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$period->isLeading()
|
||||||
|
? $period->setDates($this->dateFrom, $this->dateFrom->modify(sprintf('+%d days', $nights)))
|
||||||
|
: $period->setDates($this->dateTo->modify(sprintf('-%d days', $nights)), $this->dateTo);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its nights go back to the main period. */
|
||||||
|
public function removePeriod(AccommodationBookingPeriod $period): self
|
||||||
|
{
|
||||||
|
if ($this->periods->removeElement($period) && $period->getBooking() === $this) {
|
||||||
|
$period->setBooking(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLeadingPeriod(): ?AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
return $this->periodsAt(AccommodationBookingPeriodPosition::Leading)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrailingPeriod(): ?AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
return $this->periodsAt(AccommodationBookingPeriodPosition::Trailing)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrival of the group the booking's own parameters price: the stay's, unless a period leads it. */
|
||||||
|
public function getMainDateFrom(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->getLeadingPeriod()?->getDateTo() ?? $this->dateFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Departure of the group the booking's own parameters price: the stay's, unless a period trails it. */
|
||||||
|
public function getMainDateTo(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->getTrailingPeriod()?->getDateFrom() ?? $this->dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMainNights(): int
|
||||||
|
{
|
||||||
|
$from = $this->getMainDateFrom();
|
||||||
|
$to = $this->getMainDateTo();
|
||||||
|
|
||||||
|
if (null === $from || null === $to || $from >= $to) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $from->diff($to)->days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In minor units, the sum of every line of every split-off period. */
|
||||||
|
public function getPeriodsTotal(): int
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($this->periods as $period) {
|
||||||
|
$total += $period->getTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<AccommodationBookingPeriod> */
|
||||||
|
private function periodsAt(AccommodationBookingPeriodPosition $position): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->periods->toArray(),
|
||||||
|
fn (AccommodationBookingPeriod $p) => $p->getPosition() === $position,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sameDay(\DateTimeImmutable $a, \DateTimeImmutable $b): bool
|
||||||
|
{
|
||||||
|
return $a->format('Y-m-d') === $b->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
public function getPaxCount(): int
|
public function getPaxCount(): int
|
||||||
{
|
{
|
||||||
return $this->paxCount;
|
return $this->paxCount;
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity\Groups;
|
||||||
|
|
||||||
|
use App\Entity\TimestampableEntityInterface;
|
||||||
|
use App\Entity\TimestampableParentAwareInterface;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stretch of nights split off the start or the end of a stay, such as the day an advance team
|
||||||
|
* arrives before the group. It carries no pax, board or services of its own and is never priced
|
||||||
|
* from the catalog: the office quotes it as free-form lines, which are added to the booking's
|
||||||
|
* total and fall under its fixed total discount. The booking's own parameters price only what
|
||||||
|
* remains between the leading and the trailing periods.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity]
|
||||||
|
class AccommodationBookingPeriod implements TimestampableParentAwareInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'periods')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?AccommodationBooking $booking = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, enumType: AccommodationBookingPeriodPosition::class)]
|
||||||
|
private AccommodationBookingPeriodPosition $position;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $dateFrom;
|
||||||
|
|
||||||
|
// Exclusive, like the booking's: the departure day, not the last night.
|
||||||
|
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $dateTo;
|
||||||
|
|
||||||
|
/** What the price breakdown calls the period, e.g. "Vorausteam"; without one it shows the dates alone. */
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amounts in minor units of the booking's pricing currency; a line has no currency of its own.
|
||||||
|
*
|
||||||
|
* The column is not called `lines`: that is a reserved word in MariaDB, and the ORM does
|
||||||
|
* not quote column names in the statements it writes.
|
||||||
|
*
|
||||||
|
* @var list<array{label: string, amount: int}>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'price_lines', type: Types::JSON)]
|
||||||
|
private array $lines = [];
|
||||||
|
|
||||||
|
public function __construct(AccommodationBookingPeriodPosition $position, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo)
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
$this->dateFrom = $dateFrom;
|
||||||
|
$this->dateTo = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __clone()
|
||||||
|
{
|
||||||
|
$this->id = null;
|
||||||
|
$this->booking = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBooking(): ?AccommodationBooking
|
||||||
|
{
|
||||||
|
return $this->booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBooking(?AccommodationBooking $booking): self
|
||||||
|
{
|
||||||
|
$this->booking = $booking;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimestampableParent(): ?TimestampableEntityInterface
|
||||||
|
{
|
||||||
|
return $this->booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): AccommodationBookingPeriodPosition
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLeading(): bool
|
||||||
|
{
|
||||||
|
return AccommodationBookingPeriodPosition::Leading === $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDateFrom(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->dateFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDateTo(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDates(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): self
|
||||||
|
{
|
||||||
|
$this->dateFrom = $dateFrom;
|
||||||
|
$this->dateTo = $dateTo;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(?string $label): self
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNights(): int
|
||||||
|
{
|
||||||
|
return $this->dateFrom < $this->dateTo ? $this->dateFrom->diff($this->dateTo)->days : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{label: string, amount: int}> */
|
||||||
|
public function getLines(): array
|
||||||
|
{
|
||||||
|
return $this->lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{label: string, amount: int}> $lines */
|
||||||
|
public function setLines(array $lines): self
|
||||||
|
{
|
||||||
|
$this->lines = $lines;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotal(): int
|
||||||
|
{
|
||||||
|
return array_sum(array_column($this->lines, 'amount'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enum\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which edge of the stay a split-off period sits at. Periods only ever come off the start or
|
||||||
|
* the end, so the main period — the one the booking's own parameters price — always stays a
|
||||||
|
* single contiguous range between them.
|
||||||
|
*/
|
||||||
|
enum AccommodationBookingPeriodPosition: string
|
||||||
|
{
|
||||||
|
case Leading = 'leading';
|
||||||
|
case Trailing = 'trailing';
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exception;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a voucher purchase step is reached without a running purchase, either directly or
|
||||||
|
* because the purchase was completed or the session expired.
|
||||||
|
*/
|
||||||
|
class VoucherSessionNotFoundException extends HttpException
|
||||||
|
{
|
||||||
|
public function __construct(?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct(404, 'No valid voucher purchase session found. Please start a new purchase.', $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when BusPro cannot tell us what is on sale (GUTSCHEINVORLAGEN) or how it can be paid
|
||||||
|
* (GUTSCHEINZAHLUNGSARTEN), so the voucher purchase cannot be offered.
|
||||||
|
*/
|
||||||
|
class VoucherShopUnavailableException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Admin\Groups;
|
||||||
|
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/** @extends AbstractType<AccommodationBookingPeriodLineDto> */
|
||||||
|
class AccommodationBookingPeriodLineType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('label', TextType::class, [
|
||||||
|
'label' => 'Bezeichnung',
|
||||||
|
])
|
||||||
|
->add('amount', MoneyType::class, [
|
||||||
|
'label' => 'Betrag',
|
||||||
|
'currency' => $options['currency'],
|
||||||
|
'divisor' => 100,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => AccommodationBookingPeriodLineDto::class,
|
||||||
|
'empty_data' => static fn () => new AccommodationBookingPeriodLineDto(),
|
||||||
|
'currency' => 'EUR',
|
||||||
|
]);
|
||||||
|
$resolver->setAllowedTypes('currency', 'string');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Admin\Groups;
|
||||||
|
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/** @extends AbstractType<AccommodationBookingPeriodDto> */
|
||||||
|
class AccommodationBookingPeriodType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
// "Überschrift" rather than "Bezeichnung", which the lines below use for their own field.
|
||||||
|
->add('label', TextType::class, [
|
||||||
|
'label' => 'Überschrift (optional)',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => ['placeholder' => 'z. B. Vorausteam'],
|
||||||
|
])
|
||||||
|
->add('nights', IntegerType::class, [
|
||||||
|
'label' => 'Nächte',
|
||||||
|
'attr' => ['min' => 1],
|
||||||
|
])
|
||||||
|
->add('lines', CollectionType::class, [
|
||||||
|
'label' => 'Positionen',
|
||||||
|
'entry_type' => AccommodationBookingPeriodLineType::class,
|
||||||
|
'entry_options' => ['label' => false, 'currency' => $options['currency']],
|
||||||
|
'allow_add' => true,
|
||||||
|
'allow_delete' => true,
|
||||||
|
'by_reference' => false,
|
||||||
|
'prototype' => true,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => AccommodationBookingPeriodDto::class,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
]);
|
||||||
|
$resolver->setAllowedTypes('currency', 'string');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Model;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the split and edit modals submit for a split-off period. Whether the group keeps a
|
||||||
|
* night for itself depends on the booking, so that is left to the booking's own validation.
|
||||||
|
*/
|
||||||
|
class AccommodationBookingPeriodDto
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, AccommodationBookingPeriodLineDto> $lines keyed as the form submits them, so not necessarily a list
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotNull(message: 'Bitte die Anzahl der Nächte angeben.')]
|
||||||
|
#[Assert\Range(min: 1, max: AccommodationBooking::MAX_NIGHTS)]
|
||||||
|
public ?int $nights = 1,
|
||||||
|
#[Assert\Valid]
|
||||||
|
public array $lines = [],
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $label = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromPeriod(AccommodationBookingPeriod $period): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$period->getNights(),
|
||||||
|
array_map(
|
||||||
|
fn (array $line) => new AccommodationBookingPeriodLineDto($line['label'], $line['amount']),
|
||||||
|
$period->getLines(),
|
||||||
|
),
|
||||||
|
$period->getLabel(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{label: string, amount: int}> */
|
||||||
|
public function linesSnapshot(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (AccommodationBookingPeriodLineDto $line) => ['label' => (string) $line->label, 'amount' => (int) $line->amount],
|
||||||
|
$this->lines,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/** One free-form price line of a split-off period, as the office keys it in. */
|
||||||
|
class AccommodationBookingPeriodLineDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank(message: 'Bitte eine Bezeichnung angeben.')]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $label = null,
|
||||||
|
/** In minor units of the booking's pricing currency. */
|
||||||
|
#[Assert\NotNull(message: 'Bitte einen Betrag angeben.')]
|
||||||
|
#[Assert\PositiveOrZero]
|
||||||
|
public ?int $amount = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Model;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Address;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||||
|
use App\BusProNet\Model\VoucherTemplate;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session state of the voucher purchase flow (/gutschein).
|
||||||
|
*
|
||||||
|
* The fields are exactly what the BusProNet voucher shop collects, with its mandatory flags rather
|
||||||
|
* than those of ADRESSFELDERGUTSCHEIN (which would also demand a phone number and a gender). There
|
||||||
|
* is no Zusatztext: GUTSCHEINBUCHUNG is sent an empty <text/>, as the live shop does.
|
||||||
|
*
|
||||||
|
* Step 2 is validated with the groups ['voucher_step_2', 'personal_data']: the buyer address reuses
|
||||||
|
* the Address model, whose constraints live in the personal_data group.
|
||||||
|
*/
|
||||||
|
class VoucherPurchaseDto
|
||||||
|
{
|
||||||
|
public const string SALUTATION_MR = 'Herr';
|
||||||
|
public const string SALUTATION_MS = 'Frau';
|
||||||
|
|
||||||
|
public int $currentStep = 1;
|
||||||
|
|
||||||
|
// BusPro's bounds from GUTSCHEINVORLAGEN at the start of the flow. BusPro re-checks them on
|
||||||
|
// every GUTSCHEINBUCHUNG (607/608), so a change in between still surfaces as a step 1 error.
|
||||||
|
public int $minAmount = 0;
|
||||||
|
public int $maxAmount = 0;
|
||||||
|
|
||||||
|
// Fixed at the start of the flow from GUTSCHEINZAHLUNGSARTEN; transfer is the only one offered.
|
||||||
|
public ?VoucherPaymentMethod $paymentMethod = null;
|
||||||
|
|
||||||
|
// Step 1: voucher
|
||||||
|
#[Assert\NotNull(message: 'Bitte wähle ein Motiv', groups: ['voucher_step_1'])]
|
||||||
|
public ?VoucherTemplate $template = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
|
||||||
|
public ?string $from = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
|
||||||
|
public ?string $to = null;
|
||||||
|
|
||||||
|
#[Assert\NotNull(message: 'Bitte gib einen Gutscheinwert an', groups: ['voucher_step_1'])]
|
||||||
|
#[Assert\Range(
|
||||||
|
notInRangeMessage: 'Der Gutscheinwert muss zwischen {{ min }} und {{ max }} Euro liegen',
|
||||||
|
minPropertyPath: 'minAmount',
|
||||||
|
maxPropertyPath: 'maxAmount',
|
||||||
|
groups: ['voucher_step_1'],
|
||||||
|
)]
|
||||||
|
public ?int $amount = null;
|
||||||
|
|
||||||
|
// Step 2: buyer (rechnungsempfänger)
|
||||||
|
#[Assert\Choice(choices: [self::SALUTATION_MR, self::SALUTATION_MS], groups: ['voucher_step_2'])]
|
||||||
|
public ?string $salutation = null;
|
||||||
|
|
||||||
|
public ?string $title = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
|
||||||
|
public ?string $lastName = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
|
||||||
|
public ?string $firstName = null;
|
||||||
|
|
||||||
|
#[Assert\Valid]
|
||||||
|
public Address $address;
|
||||||
|
|
||||||
|
public ?string $phone = null;
|
||||||
|
|
||||||
|
public ?string $mobile = 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;
|
||||||
|
|
||||||
|
// Step 3: confirmation, reset on every visit so a stale tick never carries over
|
||||||
|
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
|
||||||
|
public bool $termsAccepted = false;
|
||||||
|
|
||||||
|
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
|
||||||
|
public bool $purchaseConfirmed = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->address = new Address();
|
||||||
|
$this->address->country = 'D';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBuyerFullName(): string
|
||||||
|
{
|
||||||
|
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasBuyerData(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->lastName && '' !== $this->lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the buyer fields from the signed-in account's BusPro record.
|
||||||
|
*
|
||||||
|
* The e-mail is the one the account signed in with, not the record's communication address:
|
||||||
|
* a BusPro address can carry several e-mails for different persons.
|
||||||
|
*/
|
||||||
|
public function prefillBuyer(PersonalData $personalData, ?string $loginEmail): void
|
||||||
|
{
|
||||||
|
$this->salutation = \in_array($personalData->salutation, [self::SALUTATION_MR, self::SALUTATION_MS], true)
|
||||||
|
? $personalData->salutation
|
||||||
|
: null;
|
||||||
|
$this->title = $personalData->title;
|
||||||
|
$this->lastName = $personalData->name;
|
||||||
|
$this->firstName = $personalData->firstName;
|
||||||
|
$this->address = clone $personalData->address;
|
||||||
|
$this->address->normalize();
|
||||||
|
$this->address->country ??= 'D';
|
||||||
|
$this->phone = $personalData->communication->phone;
|
||||||
|
$this->mobile = $personalData->communication->mobile;
|
||||||
|
$this->email = $loginEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\VoucherTemplate;
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/** @extends AbstractType<VoucherPurchaseDto> */
|
||||||
|
class VoucherPurchaseStep1Type extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('template', ChoiceType::class, [
|
||||||
|
'label' => 'Motiv',
|
||||||
|
'choices' => $options['templates'],
|
||||||
|
'choice_value' => static fn (?VoucherTemplate $template) => $template?->id,
|
||||||
|
'choice_label' => static fn (VoucherTemplate $template) => $template->label,
|
||||||
|
'expanded' => true,
|
||||||
|
'placeholder' => false,
|
||||||
|
'invalid_message' => 'Bitte wähle ein Motiv',
|
||||||
|
])
|
||||||
|
->add('from', TextType::class, [
|
||||||
|
'label' => 'Von',
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('to', TextType::class, [
|
||||||
|
'label' => 'Für',
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('amount', IntegerType::class, [
|
||||||
|
'label' => 'Gutscheinwert in Euro',
|
||||||
|
'invalid_message' => 'Bitte gib einen ganzen Euro-Betrag an',
|
||||||
|
'attr' => [
|
||||||
|
'min' => $options['min_amount'],
|
||||||
|
'max' => $options['max_amount'],
|
||||||
|
'step' => 1,
|
||||||
|
'inputmode' => 'numeric',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => VoucherPurchaseDto::class,
|
||||||
|
'validation_groups' => ['voucher_step_1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver->setRequired(['templates', 'min_amount', 'max_amount']);
|
||||||
|
$resolver->setAllowedTypes('templates', VoucherTemplate::class.'[]');
|
||||||
|
$resolver->setAllowedTypes('min_amount', 'int');
|
||||||
|
$resolver->setAllowedTypes('max_amount', 'int');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buyer, sent to BusPro as rechnungsempfänger. Mandatory fields as in the live voucher shop.
|
||||||
|
*
|
||||||
|
* @extends AbstractType<VoucherPurchaseDto>
|
||||||
|
*/
|
||||||
|
class VoucherPurchaseStep2Type extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('salutation', ChoiceType::class, [
|
||||||
|
'label' => 'Anrede',
|
||||||
|
'choices' => [
|
||||||
|
'Herr' => VoucherPurchaseDto::SALUTATION_MR,
|
||||||
|
'Frau' => VoucherPurchaseDto::SALUTATION_MS,
|
||||||
|
],
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'placeholder' => false,
|
||||||
|
])
|
||||||
|
->add('title', TextType::class, [
|
||||||
|
'label' => 'Titel',
|
||||||
|
'required' => false,
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('firstName', TextType::class, [
|
||||||
|
'label' => 'Vorname',
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('lastName', TextType::class, [
|
||||||
|
'label' => 'Name',
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('address', AddressType::class, [
|
||||||
|
'label' => false,
|
||||||
|
'required' => true,
|
||||||
|
])
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'label' => 'E-Mail',
|
||||||
|
])
|
||||||
|
->add('phone', TextType::class, [
|
||||||
|
'label' => 'Telefon privat',
|
||||||
|
'required' => false,
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
->add('mobile', TextType::class, [
|
||||||
|
'label' => 'Mobil',
|
||||||
|
'required' => false,
|
||||||
|
'sanitize_html' => true,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => VoucherPurchaseDto::class,
|
||||||
|
// personal_data carries the constraints of the reused Address model
|
||||||
|
'validation_groups' => ['voucher_step_2', 'personal_data'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Form\Model\VoucherPurchaseDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/** @extends AbstractType<VoucherPurchaseDto> */
|
||||||
|
class VoucherPurchaseStep3Type extends AbstractType
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $termsAndConditionsUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$termsLink = sprintf(
|
||||||
|
'<a href="%s" target="_blank" rel="noopener" class="underline">Allgemeinen Geschäftsbedingungen</a>',
|
||||||
|
htmlspecialchars($this->termsAndConditionsUrl, ENT_QUOTES, 'UTF-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->add('termsAccepted', CheckboxType::class, [
|
||||||
|
'label' => sprintf('Ja, ich habe die %s gelesen und akzeptiere diese.', $termsLink),
|
||||||
|
'label_html' => true,
|
||||||
|
])
|
||||||
|
->add('purchaseConfirmed', CheckboxType::class, [
|
||||||
|
'label' => sprintf(
|
||||||
|
'Ja, ich möchte den Gutschein jetzt zum Preis von %s € kaufen.',
|
||||||
|
number_format($options['amount'], 2, ',', '.')
|
||||||
|
),
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => VoucherPurchaseDto::class,
|
||||||
|
'validation_groups' => ['voucher_step_3'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver->setRequired('amount');
|
||||||
|
$resolver->setAllowedTypes('amount', 'int');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole travel-date x hotel export plus the counters that describe how it was built.
|
||||||
|
*
|
||||||
|
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
|
||||||
|
* makes the counters identical on every page: they describe the export, not the page, so a
|
||||||
|
* consumer can read them once and abort the page walk on the first response.
|
||||||
|
*
|
||||||
|
* @see \App\Service\TravelExportBuilder
|
||||||
|
* @see \App\Service\ProductExportBuilder
|
||||||
|
*/
|
||||||
|
final readonly class Export
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||||
|
* @param int $skipped (travel, hotel) pairs dropped as unusable
|
||||||
|
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||||
|
* @param \DateTimeImmutable|null $sourceGeneratedAt the uebertragung.info timestamp of the export read, null if unknown
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $rows,
|
||||||
|
public int $skipped,
|
||||||
|
public int $pickupsWithoutMasterData,
|
||||||
|
public \DateTimeImmutable $generatedAt,
|
||||||
|
public ?\DateTimeImmutable $sourceGeneratedAt = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
final readonly class ExportQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\Positive]
|
||||||
|
public int $page = 1,
|
||||||
|
|
||||||
|
#[Assert\Range(min: 1, max: 1000)]
|
||||||
|
public int $perPage = 500,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The purchase flows that start on the login page (optional sign-in, or continue as a guest).
|
||||||
|
*/
|
||||||
|
enum PurchaseFlow: string
|
||||||
|
{
|
||||||
|
case Booking = 'booking';
|
||||||
|
case Voucher = 'voucher';
|
||||||
|
|
||||||
|
public function firstStepRoute(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Booking => 'app_booking_create_step_1',
|
||||||
|
self::Voucher => 'app_voucher_step_1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticateTemplate(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Booking => 'booking/create/authenticate.html.twig',
|
||||||
|
self::Voucher => 'voucher/authenticate.html.twig',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\Groups\Accommodation;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
|
use App\Entity\Groups\AdditionalService;
|
||||||
|
use App\Entity\Groups\BoardService;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\Model\Filter\UserFilterDto;
|
use App\Form\Model\Filter\UserFilterDto;
|
||||||
use App\Repository\Filter\AppliesListFiltersTrait;
|
use App\Repository\Filter\AppliesListFiltersTrait;
|
||||||
@@ -122,4 +127,87 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entities that point at a User and would silently lose that pointer if the account were
|
||||||
|
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
|
||||||
|
* stand in the way — it just erases who created, last touched or looks after a group booking.
|
||||||
|
* The cleanup therefore has to ask, rather than let the constraint decide.
|
||||||
|
*
|
||||||
|
* @var array<class-string, string[]>
|
||||||
|
*/
|
||||||
|
private const USER_REFERENCES = [
|
||||||
|
Accommodation::class => ['createdBy', 'updatedBy'],
|
||||||
|
AccommodationPrice::class => ['createdBy', 'updatedBy'],
|
||||||
|
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
|
||||||
|
AdditionalService::class => ['createdBy', 'updatedBy'],
|
||||||
|
BoardService::class => ['createdBy', 'updatedBy'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
|
||||||
|
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
|
||||||
|
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
|
||||||
|
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
|
||||||
|
*
|
||||||
|
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
|
||||||
|
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
|
||||||
|
* asking for ten deletions would get however many of the first ten candidates survived the
|
||||||
|
* role filter. Ordered oldest login first so a caller that does cap the result takes the
|
||||||
|
* least recently seen accounts.
|
||||||
|
*
|
||||||
|
* The roles are deliberately not part of this query. They live in a JSON array column with no
|
||||||
|
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
|
||||||
|
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
|
||||||
|
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
|
||||||
|
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
|
||||||
|
* a role set actually means.
|
||||||
|
*
|
||||||
|
* @return User[]
|
||||||
|
*/
|
||||||
|
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('u');
|
||||||
|
|
||||||
|
if (null !== $inactiveSince) {
|
||||||
|
$qb
|
||||||
|
->andWhere('u.lastLoginAt IS NOT NULL')
|
||||||
|
->andWhere('u.lastLoginAt < :inactiveSince')
|
||||||
|
->setParameter('inactiveSince', $inactiveSince)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
||||||
|
$this->getEntityManager()->createQueryBuilder()
|
||||||
|
->select('draft.id')
|
||||||
|
->from(BookingEditDraft::class, 'draft')
|
||||||
|
->andWhere('draft.user = u')
|
||||||
|
->getDQL()
|
||||||
|
)));
|
||||||
|
|
||||||
|
$index = 0;
|
||||||
|
|
||||||
|
foreach (self::USER_REFERENCES as $class => $fields) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$alias = sprintf('ref%d%s', $index++, $field);
|
||||||
|
|
||||||
|
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
||||||
|
$this->getEntityManager()->createQueryBuilder()
|
||||||
|
->select($alias.'.id')
|
||||||
|
->from($class, $alias)
|
||||||
|
->andWhere(sprintf('%s.%s = u', $alias, $field))
|
||||||
|
->getDQL()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User[] $users */
|
||||||
|
$users = $qb
|
||||||
|
->orderBy('u.lastLoginAt', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user