Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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)
|
||||||
|
|||||||
Generated
+236
-236
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
|
||||||
|
|||||||
+14
-5
@@ -207,6 +207,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,6 +223,16 @@ 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'
|
||||||
@@ -386,8 +400,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 |
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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,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,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'),
|
||||||
|
|||||||
@@ -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,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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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,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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,15 @@ class AccommodationBookingBreakdownCalculator
|
|||||||
{
|
{
|
||||||
$breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking);
|
$breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking);
|
||||||
|
|
||||||
return null !== $breakdown ? $this->withDiscounts($booking, $breakdown) : null;
|
if (null === $breakdown) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshots frozen before split-off periods existed (pricing version 1) read as a
|
||||||
|
// booking without any, so templates only ever handle one shape.
|
||||||
|
$breakdown += ['periods' => [], 'periodsTotal' => 0];
|
||||||
|
|
||||||
|
return $this->withDiscounts($booking, $breakdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +108,10 @@ class AccommodationBookingBreakdownCalculator
|
|||||||
* Calculates against the current catalog. Use only while creating or explicitly editing
|
* Calculates against the current catalog. Use only while creating or explicitly editing
|
||||||
* a booking, or as a compatibility fallback for records created before price snapshots.
|
* a booking, or as a compatibility fallback for records created before price snapshots.
|
||||||
*
|
*
|
||||||
|
* The catalog prices the main period only; the split-off periods add their lines on top,
|
||||||
|
* so `total` is the grand total the discounts are taken from. The price rows are loaded for
|
||||||
|
* the whole stay, which the includedPax floor and the short-term surcharge are judged on.
|
||||||
|
*
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
public function computeCurrent(AccommodationBooking $booking): ?array
|
public function computeCurrent(AccommodationBooking $booking): ?array
|
||||||
@@ -118,16 +130,40 @@ class AccommodationBookingBreakdownCalculator
|
|||||||
$dateTo,
|
$dateTo,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->priceCalculator->calculateFromSnapshots(
|
$breakdown = $this->priceCalculator->calculateFromSnapshots(
|
||||||
$booking->getPaxCount(),
|
$booking->getPaxCount(),
|
||||||
$booking->getMinorsCount(),
|
$booking->getMinorsCount(),
|
||||||
$booking->getNights(),
|
$booking->getMainNights(),
|
||||||
|
$booking->getMainDateFrom() ?? $dateFrom,
|
||||||
|
$booking->getMainDateTo() ?? $dateTo,
|
||||||
$dateFrom,
|
$dateFrom,
|
||||||
$dateTo,
|
$booking->getNights(),
|
||||||
$prices,
|
$prices,
|
||||||
$booking->getBoardServicePrice(),
|
$booking->getBoardServicePrice(),
|
||||||
$booking->getAdditionalServices(),
|
$booking->getAdditionalServices(),
|
||||||
$accommodation->getCurrency(),
|
$accommodation->getCurrency(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$periods = [];
|
||||||
|
foreach (array_filter([$booking->getLeadingPeriod(), $booking->getTrailingPeriod()]) as $period) {
|
||||||
|
$periods[] = [
|
||||||
|
'position' => $period->getPosition()->value,
|
||||||
|
'label' => $period->getLabel(),
|
||||||
|
'dateFrom' => $period->getDateFrom()->format('Y-m-d'),
|
||||||
|
'dateTo' => $period->getDateTo()->format('Y-m-d'),
|
||||||
|
'nights' => $period->getNights(),
|
||||||
|
'lines' => $period->getLines(),
|
||||||
|
'total' => $period->getTotal(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodsTotal = $booking->getPeriodsTotal();
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$breakdown,
|
||||||
|
'periods' => $periods,
|
||||||
|
'periodsTotal' => $periodsTotal,
|
||||||
|
'total' => $breakdown['total'] + $periodsTotal,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ class AccommodationBookingPdfGenerator
|
|||||||
$parts = array_filter([
|
$parts = array_filter([
|
||||||
$booking->getDateFrom()?->format('Y-m-d'),
|
$booking->getDateFrom()?->format('Y-m-d'),
|
||||||
$booking->getAccommodation()?->getCalendarCode(),
|
$booking->getAccommodation()?->getCalendarCode(),
|
||||||
'Buchung',
|
$booking->recordLabel(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return implode(' ', $parts).'.pdf';
|
return implode(' ', $parts).'.pdf';
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ use App\Email\EmailAttachmentInterface;
|
|||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Entity\Groups\Accommodation;
|
use App\Entity\Groups\Accommodation;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
use App\Entity\Groups\AccommodationPrice;
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Entity\Groups\BoardService;
|
use App\Entity\Groups\BoardService;
|
||||||
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\Exception\ImplausibleBookingPriceException;
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
use App\Model\AccommodationBookingContext;
|
use App\Model\AccommodationBookingContext;
|
||||||
use App\Model\AccommodationBookingQueryParams;
|
use App\Model\AccommodationBookingQueryParams;
|
||||||
use App\Model\CmsHotelData;
|
use App\Model\CmsHotelData;
|
||||||
@@ -25,11 +28,16 @@ use App\Repository\Groups\AdditionalServiceRepository;
|
|||||||
use App\Repository\Groups\BoardServiceRepository;
|
use App\Repository\Groups\BoardServiceRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
class AccommodationBookingService
|
class AccommodationBookingService
|
||||||
{
|
{
|
||||||
private const int PRICING_VERSION = 1;
|
/**
|
||||||
|
* 2: the breakdown carries the split-off `periods` and `periodsTotal`, and `total` includes
|
||||||
|
* them. Version 1 snapshots are read as bookings without periods.
|
||||||
|
*/
|
||||||
|
private const int PRICING_VERSION = 2;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AccommodationRepository $accommodationRepo,
|
private readonly AccommodationRepository $accommodationRepo,
|
||||||
@@ -381,6 +389,54 @@ class AccommodationBookingService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three period changes below each take effect at once, like the house change: they
|
||||||
|
* reprice the booking, and the office wants to see the new price before it goes on.
|
||||||
|
* On violations nothing is written and the violations are returned for the modal.
|
||||||
|
*
|
||||||
|
* @throws ImplausibleBookingPriceException if the new total is too large to be a real price
|
||||||
|
*/
|
||||||
|
public function splitOffPeriod(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
|
||||||
|
{
|
||||||
|
$booking->splitOffPeriod($position, (int) $dto->nights)
|
||||||
|
->setLabel($dto->label)
|
||||||
|
->setLines($dto->linesSnapshot());
|
||||||
|
|
||||||
|
return $this->commitPeriodChange($booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
|
||||||
|
public function updatePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
|
||||||
|
{
|
||||||
|
$booking->resizePeriod($period, (int) $dto->nights);
|
||||||
|
$period->setLabel($dto->label)->setLines($dto->linesSnapshot());
|
||||||
|
|
||||||
|
return $this->commitPeriodChange($booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
|
||||||
|
public function removePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period): ConstraintViolationListInterface
|
||||||
|
{
|
||||||
|
$booking->removePeriod($period);
|
||||||
|
|
||||||
|
return $this->commitPeriodChange($booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commitPeriodChange(AccommodationBooking $booking): ConstraintViolationListInterface
|
||||||
|
{
|
||||||
|
// Default only: the `edit` group asks for contact data, which a period change neither
|
||||||
|
// shows nor touches, and which the edit form already enforces where it belongs.
|
||||||
|
$violations = $this->validator->validate($booking, null, ['Default']);
|
||||||
|
if (count($violations) > 0) {
|
||||||
|
return $violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshPriceSnapshot($booking);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $violations;
|
||||||
|
}
|
||||||
|
|
||||||
public function sendNotificationEmail(AccommodationBooking $booking): void
|
public function sendNotificationEmail(AccommodationBooking $booking): void
|
||||||
{
|
{
|
||||||
$this->sendBookingEmail(
|
$this->sendBookingEmail(
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class GroupsPriceCalculator
|
|||||||
$nights,
|
$nights,
|
||||||
$dateFrom,
|
$dateFrom,
|
||||||
$dateTo,
|
$dateTo,
|
||||||
|
$dateFrom,
|
||||||
|
$nights,
|
||||||
$prices,
|
$prices,
|
||||||
$boardService?->getPrice(),
|
$boardService?->getPrice(),
|
||||||
array_map(
|
array_map(
|
||||||
@@ -104,7 +106,12 @@ class GroupsPriceCalculator
|
|||||||
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
|
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
|
||||||
* instead of live catalog entities.
|
* instead of live catalog entities.
|
||||||
*
|
*
|
||||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
* $dateFrom/$dateTo/$nights are the nights being priced, which is the main period of a booking
|
||||||
|
* with split-off periods. $stayDateFrom and $stayNights describe the whole stay, which the
|
||||||
|
* includedPax floor and the short-term surcharge are judged on, so that splitting nights off
|
||||||
|
* never changes either.
|
||||||
|
*
|
||||||
|
* @param AccommodationPrice[] $prices all prices overlapping the whole stay
|
||||||
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
|
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -131,6 +138,8 @@ class GroupsPriceCalculator
|
|||||||
int $nights,
|
int $nights,
|
||||||
\DateTimeImmutable $dateFrom,
|
\DateTimeImmutable $dateFrom,
|
||||||
\DateTimeImmutable $dateTo,
|
\DateTimeImmutable $dateTo,
|
||||||
|
\DateTimeImmutable $stayDateFrom,
|
||||||
|
int $stayNights,
|
||||||
array $prices,
|
array $prices,
|
||||||
?int $boardServicePricePerPersonNight,
|
?int $boardServicePricePerPersonNight,
|
||||||
array $additionalServiceSnapshots,
|
array $additionalServiceSnapshots,
|
||||||
@@ -151,6 +160,8 @@ class GroupsPriceCalculator
|
|||||||
$nights,
|
$nights,
|
||||||
$dateFrom,
|
$dateFrom,
|
||||||
$dateTo,
|
$dateTo,
|
||||||
|
$stayDateFrom,
|
||||||
|
$stayNights,
|
||||||
$prices,
|
$prices,
|
||||||
$boardServicePricePerPersonNight,
|
$boardServicePricePerPersonNight,
|
||||||
$items,
|
$items,
|
||||||
@@ -186,15 +197,19 @@ class GroupsPriceCalculator
|
|||||||
int $nights,
|
int $nights,
|
||||||
\DateTimeImmutable $dateFrom,
|
\DateTimeImmutable $dateFrom,
|
||||||
\DateTimeImmutable $dateTo,
|
\DateTimeImmutable $dateTo,
|
||||||
|
\DateTimeImmutable $stayDateFrom,
|
||||||
|
int $stayNights,
|
||||||
array $prices,
|
array $prices,
|
||||||
?int $boardServicePricePerPersonNight,
|
?int $boardServicePricePerPersonNight,
|
||||||
array $additionalItems,
|
array $additionalItems,
|
||||||
string $currency,
|
string $currency,
|
||||||
): array {
|
): array {
|
||||||
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
|
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax.
|
||||||
|
// The floor comes from the stay's first night, not the priced range's, so that splitting
|
||||||
|
// nights off the start cannot swap in another price row's floor.
|
||||||
$firstCandidates = array_values(array_filter(
|
$firstCandidates = array_values(array_filter(
|
||||||
$prices,
|
$prices,
|
||||||
fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
|
fn (AccommodationPrice $p) => $p->getDateFrom() <= $stayDateFrom && $p->getDateTo() >= $stayDateFrom,
|
||||||
));
|
));
|
||||||
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
|
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
|
||||||
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
|
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
|
||||||
@@ -239,10 +254,10 @@ class GroupsPriceCalculator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 3: short-term surcharge
|
// Rule 3: short-term surcharge, judged on the whole stay but charged on the priced nights
|
||||||
$shortTermSurcharge = 0;
|
$shortTermSurcharge = 0;
|
||||||
if (isset(self::SHORT_TERM_FACTORS[$nights])) {
|
if (isset(self::SHORT_TERM_FACTORS[$stayNights])) {
|
||||||
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100);
|
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$stayNights] / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board is only billable — and only triggers the catering surcharge — when it costs
|
// Board is only billable — and only triggers the catering surcharge — when it costs
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Pickup;
|
||||||
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
|
use App\BusProNet\Traits\TypeConversionTrait;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
use App\BusProNet\XmlLoader\PickupLoader;
|
||||||
|
use App\BusProNet\XmlParser\ProductExportParser;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use App\Model\Export;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use League\Flysystem\StorageAttributes;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the BusPro XML export into one row per travel date x hotel, the whole record.
|
||||||
|
*
|
||||||
|
* The sibling of TravelExportBuilder at the same grain, for the website's product import
|
||||||
|
* rather than the disposition. It filters nothing: return legs, `EIN` services,
|
||||||
|
* `Buchungsstop` rooms and pickups without an id all reach the consumer, whose display
|
||||||
|
* rules decide what to drop. Folding those rules in here would make every change to the
|
||||||
|
* website a change to this endpoint.
|
||||||
|
*
|
||||||
|
* Hotels are identified by the code and id on the travel XML's own `<hotel>` node, so
|
||||||
|
* unlike the travel export no row depends on hotel.xml master data.
|
||||||
|
*
|
||||||
|
* Boarding-point prices are the export's, verbatim. APP_BPN_LEVEL_BOARDING_POINT_PRICES
|
||||||
|
* is a booking-path compensation and is deliberately not applied: TravelParser::getPickups()
|
||||||
|
* does not level, only TravelParser::parse() does.
|
||||||
|
*/
|
||||||
|
class ProductExportBuilder
|
||||||
|
{
|
||||||
|
use TypeConversionTrait;
|
||||||
|
|
||||||
|
private const CACHE_KEY = 'bpn_products_export';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PickupLoader $pickupLoader,
|
||||||
|
private readonly TravelParser $travelParser,
|
||||||
|
private readonly ProductExportParser $productExportParser,
|
||||||
|
private readonly FilesystemOperator $xmlExport,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the whole export, cached as one unit alongside the sibling loaders.
|
||||||
|
*/
|
||||||
|
public function build(): Export
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||||
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
|
return $this->assemble();
|
||||||
|
});
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new \RuntimeException('Cache key error in ProductExportBuilder', 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assemble(): Export
|
||||||
|
{
|
||||||
|
// Read before the walk and cached with the rows; see TravelExportBuilder::assemble().
|
||||||
|
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
|
||||||
|
$pickupMasterData = $this->pickupLoader->loadAll();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> $rows keyed by dateId-hotelId */
|
||||||
|
$rows = [];
|
||||||
|
$skipped = 0;
|
||||||
|
/** @var array<int, true> $pickupsWithoutMasterData */
|
||||||
|
$pickupsWithoutMasterData = [];
|
||||||
|
|
||||||
|
$files = $this
|
||||||
|
->xmlExport
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
|
||||||
|
|
||||||
|
$crawler->filterXPath('//reisen/reise')->each(
|
||||||
|
function (Crawler $productNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData) {
|
||||||
|
$product = [
|
||||||
|
'code' => $productNode->attr('code'),
|
||||||
|
'busProId' => null !== $productNode->attr('idbuspro') ? (int) $productNode->attr('idbuspro') : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->productExportParser->children($productNode, 'termin')->each(
|
||||||
|
function (Crawler $dateNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData, $product) {
|
||||||
|
$this->collectDate($dateNode, $product, $rows, $skipped, $pickupsWithoutMasterData, $pickupMasterData);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Export(
|
||||||
|
array_values($rows),
|
||||||
|
$skipped,
|
||||||
|
count($pickupsWithoutMasterData),
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
$sourceGeneratedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{code: ?string, busProId: ?int} $product
|
||||||
|
* @param array<string, array<string, mixed>> $rows
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
* @param array<int|string, Pickup> $pickupMasterData
|
||||||
|
*/
|
||||||
|
private function collectDate(
|
||||||
|
Crawler $dateNode,
|
||||||
|
array $product,
|
||||||
|
array &$rows,
|
||||||
|
int &$skipped,
|
||||||
|
array &$pickupsWithoutMasterData,
|
||||||
|
array $pickupMasterData,
|
||||||
|
): void {
|
||||||
|
$hotelNodes = $this->productExportParser->children($dateNode, 'hotel');
|
||||||
|
|
||||||
|
$dateFrom = $this->stringToDate($dateNode->attr('termin'));
|
||||||
|
$dateTo = $this->stringToDate($dateNode->attr('bis'));
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
$this->logger->warning('Product export: unparseable travel date', [
|
||||||
|
'travel_id' => $dateNode->attr('idbuspro'),
|
||||||
|
'termin' => $dateNode->attr('termin'),
|
||||||
|
'bis' => $dateNode->attr('bis'),
|
||||||
|
]);
|
||||||
|
$skipped += $hotelNodes->count();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateId = (int) $dateNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// Everything above the hotel is shared by the date's hotel rows, so it is read once.
|
||||||
|
$shared = [
|
||||||
|
'product' => $product,
|
||||||
|
'date' => [
|
||||||
|
'code' => $dateNode->attr('code'),
|
||||||
|
'busProId' => $dateId,
|
||||||
|
'from' => $dateFrom->format('Y-m-d'),
|
||||||
|
'to' => $dateTo->format('Y-m-d'),
|
||||||
|
'type' => $dateNode->attr('reiseart'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$dateLevel = [
|
||||||
|
'selectionGroups' => $this->productExportParser->getSelectionGroups($dateNode),
|
||||||
|
'transportServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_befoerderung/leistung')),
|
||||||
|
'dateServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_sonstiges/leistung')),
|
||||||
|
];
|
||||||
|
$pickups = $this->buildPickups($dateNode, $dateFrom, $pickupMasterData, $pickupsWithoutMasterData);
|
||||||
|
$guide = $this->productExportParser->getGuide($dateNode);
|
||||||
|
|
||||||
|
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, $shared, $dateLevel, $pickups, $guide, $dateId) {
|
||||||
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// Keyed by the pair the consumer upserts on, so a date repeated across files
|
||||||
|
// collapses last-wins instead of reaching it as a duplicate.
|
||||||
|
$rows[$dateId.'-'.$hotelId] = $shared + [
|
||||||
|
'hotel' => [
|
||||||
|
'code' => $hotelNode->attr('code'),
|
||||||
|
'busProId' => $hotelId,
|
||||||
|
],
|
||||||
|
'pseudoPrice' => $this->productExportParser->getPseudoPrice($hotelNode),
|
||||||
|
] + $dateLevel + [
|
||||||
|
// The list nested in this hotel, not the date-level one: the consumer
|
||||||
|
// merges the two per hotel.
|
||||||
|
'hotelServices' => $this->productExportParser->getServices($this->productExportParser->children($hotelNode, 'lei_sonstiges/leistung')),
|
||||||
|
'rooms' => $this->productExportParser->getRooms($hotelNode),
|
||||||
|
'pickups' => $pickups,
|
||||||
|
'guide' => $guide,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound boarding points joined against zustiege.xml, in source order.
|
||||||
|
*
|
||||||
|
* getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the
|
||||||
|
* next day. Entries with id 0 are kept: the consumer drops them itself.
|
||||||
|
*
|
||||||
|
* `datetime` is BusPro's clock reading, without an offset. Resolving in Europe/Berlin
|
||||||
|
* would move a bare 02:45 on the spring-forward night to 03:45, so the dates are
|
||||||
|
* resolved in UTC, where no time is skipped or repeated.
|
||||||
|
*
|
||||||
|
* @param array<int|string, Pickup> $masterData
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
|
||||||
|
{
|
||||||
|
$resolved = [];
|
||||||
|
$clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, true) as $pickup) {
|
||||||
|
$pickupId = (int) $pickup->id;
|
||||||
|
$details = $masterData[$pickupId] ?? null;
|
||||||
|
|
||||||
|
if (null === $details && 0 !== $pickupId) {
|
||||||
|
$pickupsWithoutMasterData[$pickupId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = [
|
||||||
|
'busProId' => $pickupId,
|
||||||
|
'code' => $details?->code,
|
||||||
|
'city' => $details?->city,
|
||||||
|
'street' => $details?->street,
|
||||||
|
'price' => $pickup->price,
|
||||||
|
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Hotel;
|
||||||
|
use App\BusProNet\Model\Pickup;
|
||||||
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
|
use App\BusProNet\Traits\TypeConversionTrait;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\PickupLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use App\Model\Export;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use League\Flysystem\StorageAttributes;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
|
||||||
|
*
|
||||||
|
* Built from the XML file map rather than the travel_snapshot table. The table sits at
|
||||||
|
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
|
||||||
|
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
|
||||||
|
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
|
||||||
|
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
|
||||||
|
*
|
||||||
|
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
|
||||||
|
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
|
||||||
|
*/
|
||||||
|
class TravelExportBuilder
|
||||||
|
{
|
||||||
|
use TypeConversionTrait;
|
||||||
|
|
||||||
|
private const CACHE_KEY = 'bpn_travels_export';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HotelLoader $hotelLoader,
|
||||||
|
private readonly PickupLoader $pickupLoader,
|
||||||
|
private readonly TravelParser $travelParser,
|
||||||
|
private readonly FilesystemOperator $xmlExport,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the whole export, cached as one unit alongside the sibling loaders.
|
||||||
|
*/
|
||||||
|
public function build(): Export
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||||
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
|
return $this->assemble();
|
||||||
|
});
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assemble(): Export
|
||||||
|
{
|
||||||
|
// Read before the walk and cached with the rows, so every page served from this
|
||||||
|
// copy names the same source export. A client that sees it change between pages
|
||||||
|
// knows xml-sync swapped the export underneath its walk.
|
||||||
|
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
|
||||||
|
|
||||||
|
$hotels = $this->hotelLoader->loadAll();
|
||||||
|
$pickupMasterData = $this->pickupLoader->loadAll();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
|
||||||
|
$rows = [];
|
||||||
|
$skipped = 0;
|
||||||
|
/** @var array<int, true> $pickupsWithoutMasterData */
|
||||||
|
$pickupsWithoutMasterData = [];
|
||||||
|
|
||||||
|
$files = $this
|
||||||
|
->xmlExport
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
|
||||||
|
|
||||||
|
$crawler->filterXPath('//reisen/reise/termin')->each(
|
||||||
|
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
|
||||||
|
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Export(
|
||||||
|
array_values($rows),
|
||||||
|
$skipped,
|
||||||
|
count($pickupsWithoutMasterData),
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
$sourceGeneratedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $rows
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
* @param array<int, Hotel> $hotels
|
||||||
|
* @param array<int|string, Pickup> $pickupMasterData
|
||||||
|
*/
|
||||||
|
private function collectTravel(
|
||||||
|
Crawler $node,
|
||||||
|
array &$rows,
|
||||||
|
int &$skipped,
|
||||||
|
array &$pickupsWithoutMasterData,
|
||||||
|
array $hotels,
|
||||||
|
array $pickupMasterData,
|
||||||
|
): void {
|
||||||
|
$hotelNodes = $node->filterXPath('.//hotel');
|
||||||
|
|
||||||
|
// Dates first: getPickups() needs a non-null default date for the time-only zeit
|
||||||
|
// shape, and parsing them up front is also what turns an unusable date into a
|
||||||
|
// dropped row rather than a row with a null date the consumer cannot key on.
|
||||||
|
$dateFrom = $this->stringToDate($node->attr('termin'));
|
||||||
|
$dateTo = $this->stringToDate($node->attr('bis'));
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
$this->logger->warning('Travel export: unparseable travel date', [
|
||||||
|
'travel_id' => $node->attr('idbuspro'),
|
||||||
|
'termin' => $node->attr('termin'),
|
||||||
|
'bis' => $node->attr('bis'),
|
||||||
|
]);
|
||||||
|
$skipped += $hotelNodes->count();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travelId = (int) $node->attr('idbuspro');
|
||||||
|
|
||||||
|
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
||||||
|
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
|
||||||
|
// calendar day. Resolved once per travel date, then shared by its hotel rows.
|
||||||
|
//
|
||||||
|
// Resolved in UTC: the times are BusPro's clock readings, and in Europe/Berlin a
|
||||||
|
// bare 02:45 on the spring-forward night would move to 03:45.
|
||||||
|
$pickups = $this->buildPickups(
|
||||||
|
$this->travelParser->getPickups(
|
||||||
|
$node->filterXPath('.//zustiege/zustieg'),
|
||||||
|
new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC')),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
$pickupMasterData,
|
||||||
|
$pickupsWithoutMasterData,
|
||||||
|
);
|
||||||
|
|
||||||
|
$travel = [
|
||||||
|
'busProId' => $travelId,
|
||||||
|
'code' => $node->attr('code'),
|
||||||
|
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
|
||||||
|
'dateFrom' => $dateFrom->format('Y-m-d'),
|
||||||
|
'dateTo' => $dateTo->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
|
||||||
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// Defensive: no travel in the current snapshot references a hotel that
|
||||||
|
// hotel.xml does not carry. A row without master data would reach the consumer
|
||||||
|
// with no name and no country, so it is dropped and counted instead.
|
||||||
|
if (false === isset($hotels[$hotelId])) {
|
||||||
|
$this->logger->warning('Travel export: hotel missing from master data', [
|
||||||
|
'travel_id' => $travelId,
|
||||||
|
'hotel_id' => $hotelId,
|
||||||
|
]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hotel = $hotels[$hotelId];
|
||||||
|
|
||||||
|
// Keyed by the pair the consumer upserts on, so a date repeated across files
|
||||||
|
// collapses last-wins instead of reaching it as a duplicate.
|
||||||
|
$rows[$travelId.'-'.$hotelId] = $travel + [
|
||||||
|
'hotel' => [
|
||||||
|
'busProId' => $hotelId,
|
||||||
|
'code' => $hotel->code,
|
||||||
|
'name' => $hotel->name,
|
||||||
|
'costUnit' => $hotel->costUnit,
|
||||||
|
'country' => $hotel->country,
|
||||||
|
'countryCode' => $hotel->countryCode,
|
||||||
|
],
|
||||||
|
'pickups' => $pickups,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the travel XML's pickups against zustiege.xml.
|
||||||
|
*
|
||||||
|
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
|
||||||
|
* contract is source order, which is the order the disposition lists them in. Nor
|
||||||
|
* enrichPickupDetails(), which is private and passes over a miss silently.
|
||||||
|
*
|
||||||
|
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
|
||||||
|
* @param array<int|string, Pickup> $masterData
|
||||||
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
|
||||||
|
{
|
||||||
|
$resolved = [];
|
||||||
|
|
||||||
|
foreach ($pickups as $pickup) {
|
||||||
|
$pickupId = $pickup->id;
|
||||||
|
|
||||||
|
// Defensive: none exist today. An entry with no id is not addressable by the
|
||||||
|
// consumer, so drop the entry -- never the row it belongs to.
|
||||||
|
if (null === $pickupId || 0 === $pickupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = $masterData[$pickupId] ?? null;
|
||||||
|
|
||||||
|
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
|
||||||
|
// entry is emitted anyway: dropping it would hide a real departure point from
|
||||||
|
// the disposition UI, where a nameless stop is still better than a missing one.
|
||||||
|
if (null === $details) {
|
||||||
|
$pickupsWithoutMasterData[$pickupId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = [
|
||||||
|
'busProId' => $pickupId,
|
||||||
|
'code' => $details?->code,
|
||||||
|
'city' => $details?->city,
|
||||||
|
'street' => $details?->street,
|
||||||
|
'time' => $pickup->time?->format('H:i'),
|
||||||
|
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
||||||
|
// worked out the real day, and a null here would throw that away. A clock
|
||||||
|
// reading without an offset, like `time`.
|
||||||
|
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStringOrNullValue(Crawler $node): ?string
|
||||||
|
{
|
||||||
|
return 0 < $node->count() ? $node->text() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trimOrNull(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{#- One split-off period on the edit page, with its actions. Context: booking, period. -#}
|
||||||
|
{% set period_params = { id: period.id } %}
|
||||||
|
{% if app.request.query.get('r') %}
|
||||||
|
{% set period_params = period_params | merge({ r: app.request.query.get('r') }) %}
|
||||||
|
{% endif %}
|
||||||
|
<tr class="border-t border-gray-200 align-top">
|
||||||
|
<td class="py-1 whitespace-nowrap">{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }}</td>
|
||||||
|
<td class="py-1 whitespace-nowrap">{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}</td>
|
||||||
|
<td class="py-1">
|
||||||
|
{% if period.label %}
|
||||||
|
<div class="font-medium">{{ period.label }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for line in period.lines %}
|
||||||
|
<div>{{ line.label }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-gray-500">keine Positionen</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">
|
||||||
|
{{ (period.total / 100) | format_currency(booking.pricingCurrency ?? booking.accommodation.currency) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-1 text-right">
|
||||||
|
<twig:dropdown>
|
||||||
|
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_edit', period_params) }}">
|
||||||
|
bearbeiten
|
||||||
|
</twig:dropdown:hxbutton>
|
||||||
|
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_remove', period_params) }}" warning>
|
||||||
|
entfernen
|
||||||
|
</twig:dropdown:hxbutton>
|
||||||
|
</twig:dropdown>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{#- One split-off period of the price table on the detail page. Context: period, currency. -#}
|
||||||
|
<tr class="border-t border-gray-200 font-medium">
|
||||||
|
<td class="pt-2 pb-1" colspan="2">
|
||||||
|
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||||
|
{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }},
|
||||||
|
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for line in period.lines %}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">{{ line.label }}</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">{{ (line.amount / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
@@ -22,78 +22,199 @@
|
|||||||
{% form_theme form 'forms_admin.html.twig' %}
|
{% form_theme form 'forms_admin.html.twig' %}
|
||||||
|
|
||||||
{{ form_start(form) }}
|
{{ form_start(form) }}
|
||||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
{{ form_errors(form) }}
|
||||||
<h2 class="lg:col-span-2 text-base font-bold">Kontaktdaten</h2>
|
{# Ordered by what gets negotiated: the stay first, the contact data last. The sidebar keeps
|
||||||
{{ form_row(form.groupName) }}
|
status, price and the save button in view on wide screens. #}
|
||||||
{{ form_row(form.salutation) }}
|
<div class="xl:grid xl:grid-cols-3 xl:gap-6 xl:items-start space-y-6 xl:space-y-0">
|
||||||
{{ form_row(form.firstName) }}
|
<div class="xl:col-span-2 space-y-6">
|
||||||
{{ form_row(form.lastName) }}
|
<twig:form:section title="Aufenthalt">
|
||||||
{{ form_row(form.email) }}
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
{{ form_row(form.phone) }}
|
{{ form_row(form.dateFrom) }}
|
||||||
{{ form_row(form.street) }}
|
{{ form_row(form.dateTo) }}
|
||||||
{{ form_row(form.zip) }}
|
</div>
|
||||||
{{ form_row(form.city) }}
|
<div class="grid sm:grid-cols-3 gap-4 pt-4">
|
||||||
|
{{ form_row(form.paxCount) }}
|
||||||
|
{{ form_row(form.minorsCount) }}
|
||||||
|
{{ form_row(form.childrenCount) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Reisedaten</h2>
|
{# Saved by their own modals, which reprice the booking on the spot — see SplitPeriodController. #}
|
||||||
{{ form_row(form.dateFrom) }}
|
<div class="mt-6 border-t border-gray-200 pt-4">
|
||||||
{{ form_row(form.dateTo) }}
|
<div class="font-bold pb-1">Zeiträume</div>
|
||||||
{{ form_row(form.paxCount) }}
|
<p class="text-xs text-gray-500 pb-2">
|
||||||
{{ form_row(form.minorsCount) }}
|
Anreise und Abreise umfassen den ganzen Aufenthalt. Abgeteilte Zeiträume, etwa für ein
|
||||||
{{ form_row(form.childrenCount) }}
|
Vorausteam, werden mit eigenen Positionen berechnet; Personen und Leistungen gelten
|
||||||
|
nur für den Zeitraum der Gruppe.
|
||||||
|
</p>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
{% if booking.leadingPeriod %}
|
||||||
|
{% include 'admin/accommodation_booking/_period_row.html.twig' with { period: booking.leadingPeriod } %}
|
||||||
|
{% endif %}
|
||||||
|
<tr class="border-t border-gray-200">
|
||||||
|
<td class="py-1 whitespace-nowrap">{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}</td>
|
||||||
|
<td class="py-1 whitespace-nowrap">{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}</td>
|
||||||
|
<td class="py-1" colspan="2">Gruppe</td>
|
||||||
|
<td class="py-1 text-right">
|
||||||
|
{# One period per side, so only the free sides are offered. #}
|
||||||
|
{% if booking.leadingPeriod is null or booking.trailingPeriod is null %}
|
||||||
|
<twig:dropdown>
|
||||||
|
{% if booking.leadingPeriod is null %}
|
||||||
|
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'leading' })) }}">
|
||||||
|
am Anfang abteilen
|
||||||
|
</twig:dropdown:hxbutton>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.trailingPeriod is null %}
|
||||||
|
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'trailing' })) }}">
|
||||||
|
am Ende abteilen
|
||||||
|
</twig:dropdown:hxbutton>
|
||||||
|
{% endif %}
|
||||||
|
</twig:dropdown>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if booking.trailingPeriod %}
|
||||||
|
{% include 'admin/accommodation_booking/_period_row.html.twig' with { period: booking.trailingPeriod } %}
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</twig:form:section>
|
||||||
|
|
||||||
{% if form.boardService is defined or form.selectedAdditionalServices is defined %}
|
{% if form.boardService is defined or form.selectedAdditionalServices is defined %}
|
||||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Leistungen</h2>
|
<twig:form:section title="Leistungen">
|
||||||
{% if form.boardService is defined %}
|
<div class="space-y-4">
|
||||||
{{ form_row(form.boardService) }}
|
{% if form.boardService is defined %}
|
||||||
|
{{ form_row(form.boardService) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.selectedAdditionalServices is defined %}
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.selectedAdditionalServices) }}
|
||||||
|
{# Rows written out here rather than through the theme's choice_widget_expanded,
|
||||||
|
whose first:pt-0 would shift only the first cell of the two columns. #}
|
||||||
|
<div class="grid sm:grid-cols-2 gap-x-8">
|
||||||
|
{% for child in form.selectedAdditionalServices %}
|
||||||
|
<div class="relative flex items-start py-1">
|
||||||
|
<div class="min-w-0 flex-1 text-sm leading-4">
|
||||||
|
<label for="{{ child.vars.id }}" class="select-none font-medium text-gray-900">
|
||||||
|
{{ child.vars.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex h-4 items-center">
|
||||||
|
{{ form_widget(child) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form_errors(form.selectedAdditionalServices) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</twig:form:section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if form.selectedAdditionalServices is defined %}
|
|
||||||
{{ form_row(form.selectedAdditionalServices) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
|
<twig:form:section title="Rabatte">
|
||||||
{# Read-only: the status only ever moves through a named action on the detail page,
|
<div class="grid sm:grid-cols-3 gap-4">
|
||||||
so that it cannot be set without the email and timestamp that belong to it. #}
|
{{ form_row(form.accommodationDiscount) }}
|
||||||
<p class="lg:col-span-2 text-sm">
|
{{ form_row(form.boardServiceDiscount) }}
|
||||||
<span class="font-medium">Status:</span>
|
{{ form_row(form.additionalServicesDiscount) }}
|
||||||
{% include 'admin/accommodation_booking/_status_label.html.twig' %}
|
</div>
|
||||||
<span class="text-gray-500">· Herkunft: {{ booking.origin.label }}</span>
|
<div class="grid sm:grid-cols-3 gap-4 pt-4">
|
||||||
</p>
|
{{ form_row(form.totalDiscountLabel, { row_attr: { class: 'sm:col-span-2' } }) }}
|
||||||
{% if booking.acceptedAt is not null %}
|
{{ form_row(form.totalDiscountAmount) }}
|
||||||
<p class="lg:col-span-2 text-xs text-gray-500">
|
</div>
|
||||||
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
|
</twig:form:section>
|
||||||
</p>
|
|
||||||
{% endif %}
|
<twig:form:section title="Kontakt">
|
||||||
{% if booking.confirmedAt is not null %}
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
<p class="lg:col-span-2 text-xs text-gray-500">
|
{{ form_row(form.groupName, { row_attr: { class: 'sm:col-span-2' } }) }}
|
||||||
Bestätigt am {{ booking.confirmedAt | date('d.m.Y, H:i') }}
|
{{ form_row(form.salutation) }}
|
||||||
</p>
|
<div class="hidden sm:block"></div>
|
||||||
{% endif %}
|
{{ form_row(form.firstName) }}
|
||||||
{% if form.managedBy is defined %}
|
{{ form_row(form.lastName) }}
|
||||||
<div class="lg:col-span-2">
|
{{ form_row(form.email) }}
|
||||||
{{ form_row(form.managedBy) }}
|
{{ form_row(form.phone) }}
|
||||||
|
{{ form_row(form.street, { row_attr: { class: 'sm:col-span-2' } }) }}
|
||||||
|
{{ form_row(form.zip) }}
|
||||||
|
{{ form_row(form.city) }}
|
||||||
|
</div>
|
||||||
|
</twig:form:section>
|
||||||
|
|
||||||
|
<twig:form:section title="Bemerkungen">
|
||||||
|
{{ form_widget(form.remarks) }}
|
||||||
|
{{ form_errors(form.remarks) }}
|
||||||
|
</twig:form:section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-6 xl:sticky xl:top-8">
|
||||||
|
<twig:form:section title="Status">
|
||||||
|
{# Read-only: the status only ever moves through a named action on the detail page,
|
||||||
|
so that it cannot be set without the email and timestamp that belong to it. #}
|
||||||
|
<dl class="grid grid-cols-2 gap-y-1 text-sm">
|
||||||
|
<dt class="text-gray-500">Status</dt>
|
||||||
|
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
|
||||||
|
<dt class="text-gray-500">Herkunft</dt>
|
||||||
|
<dd>{{ booking.origin.label }}</dd>
|
||||||
|
{% if booking.acceptedAt is not null %}
|
||||||
|
<dt class="text-gray-500">Angenommen</dt>
|
||||||
|
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.confirmedAt is not null %}
|
||||||
|
<dt class="text-gray-500">Bestätigt</dt>
|
||||||
|
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% if form.managedBy is defined %}
|
||||||
|
<div class="pt-4">
|
||||||
|
{{ form_row(form.managedBy) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</twig:form:section>
|
||||||
|
|
||||||
|
{% if priceBreakdown is not null %}
|
||||||
|
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
||||||
|
<twig:form:section title="Preis">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
{% if priceBreakdown.periods is not empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">Gruppe</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">{{ ((priceBreakdown.total - priceBreakdown.periodsTotal) / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">Zeiträume</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.periodsTotal / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.discountedTotal != priceBreakdown.total %}
|
||||||
|
{% if priceBreakdown.periods is empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">Preis</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="text-green-700">
|
||||||
|
<td class="py-1">Rabatte</td>
|
||||||
|
<td class="py-1 text-right whitespace-nowrap">– {{ ((priceBreakdown.total - priceBreakdown.discountedTotal) / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="border-t border-gray-200 font-bold">
|
||||||
|
<td class="pt-2">Gesamtpreis</td>
|
||||||
|
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-gray-500 pt-2">
|
||||||
|
Stand der letzten Speicherung. Änderungen oben werden erst beim Speichern neu berechnet.
|
||||||
|
</p>
|
||||||
|
</twig:form:section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button type="submit" class="button button--primary button--small w-full">
|
||||||
|
speichern
|
||||||
|
</button>
|
||||||
|
<a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="text-sm text-gray-500 hover:text-gray-900 hover:underline">
|
||||||
|
← zurück ohne speichern
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</aside>
|
||||||
<div class="lg:col-span-2 grid lg:grid-cols-3 gap-y-4 lg:gap-x-8">
|
|
||||||
{{ form_row(form.accommodationDiscount) }}
|
|
||||||
{{ form_row(form.boardServiceDiscount) }}
|
|
||||||
{{ form_row(form.additionalServicesDiscount) }}
|
|
||||||
</div>
|
|
||||||
<div class="lg:col-span-2 grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
|
||||||
{{ form_row(form.totalDiscountLabel) }}
|
|
||||||
{{ form_row(form.totalDiscountAmount) }}
|
|
||||||
</div>
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
{{ form_row(form.remarks) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between pt-8">
|
|
||||||
<a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="button button--secondary button--small">
|
|
||||||
zurück
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="button button--primary button--small">
|
|
||||||
speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{{ form_rest(form) }}
|
{{ form_rest(form) }}
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
|
|||||||
@@ -102,11 +102,9 @@
|
|||||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_duplicate', { 'id': booking.id, 'r': return_url() }) }}">
|
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_duplicate', { 'id': booking.id, 'r': return_url() }) }}">
|
||||||
duplizieren
|
duplizieren
|
||||||
</twig:dropdown:hxbutton>
|
</twig:dropdown:hxbutton>
|
||||||
{% if booking.confirmed %}
|
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
|
||||||
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
|
PDF herunterladen
|
||||||
PDF herunterladen
|
</twig:dropdown:link>
|
||||||
</twig:dropdown:link>
|
|
||||||
{% endif %}
|
|
||||||
{# A record that has been in front of the customer gets discarded,
|
{# A record that has been in front of the customer gets discarded,
|
||||||
not removed — see DeleteController. #}
|
not removed — see DeleteController. #}
|
||||||
{% if booking.draft %}
|
{% if booking.draft %}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends 'htmx_modal_admin.html.twig' %}
|
||||||
|
{% form_theme form 'forms_admin.html.twig' %}
|
||||||
|
|
||||||
|
{# One line as a row of bare widgets; the column headings above name them once for all rows.
|
||||||
|
Plain flex rather than an arbitrary grid template: the modal is loaded into a page whose
|
||||||
|
stylesheet may predate any class used only here. #}
|
||||||
|
{% macro line_row(line) %}
|
||||||
|
<div class="flex gap-2 items-start" data-form-collection-target="field">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{{ form_widget(line.label, { attr: { placeholder: 'z. B. Übernachtung' } }) }}
|
||||||
|
{{ form_errors(line.label) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-32 shrink-0">
|
||||||
|
{{ form_widget(line.amount) }}
|
||||||
|
{{ form_errors(line.amount) }}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="shrink-0 mt-2 text-gray-400 hover:text-red-500"
|
||||||
|
data-action="form-collection#removeItem"
|
||||||
|
title="Position entfernen">
|
||||||
|
{{ icon('close', 'w-5 h-5') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import _self as period %}
|
||||||
|
<div class="text-xl font-bold pb-1">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 pb-6">
|
||||||
|
Die Nächte werden vom Zeitraum der Gruppe abgezogen, der neu berechnet wird. Die Positionen
|
||||||
|
kommen zum Gesamtpreis hinzu und fallen nur unter den Rabatt auf den Gesamtpreis.
|
||||||
|
</p>
|
||||||
|
{{ form_start(form) }}
|
||||||
|
{{ form_errors(form) }}
|
||||||
|
<div class="flex gap-4 items-start">
|
||||||
|
{{ form_row(form.label, { row_attr: { class: 'flex-1 min-w-0' } }) }}
|
||||||
|
{{ form_row(form.nights, { row_attr: { class: 'w-32 shrink-0' } }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set prototype %}{{ period.line_row(form.lines.vars.prototype) }}{% endset %}
|
||||||
|
<div class="mt-6 border border-gray-200 rounded-md" {{ stimulus_controller('form-collection', { prototype: prototype|json_encode }) }}>
|
||||||
|
<div class="flex gap-2 px-4 py-2 bg-gray-100 border-b border-gray-200 rounded-t-md text-sm font-bold">
|
||||||
|
<div class="flex-1">Position</div>
|
||||||
|
{# w-5 matches the remove button's icon, so the heading sits over the amounts. #}
|
||||||
|
<div class="w-32 shrink-0">Betrag</div>
|
||||||
|
<div class="w-5 shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{{ form_errors(form.lines) }}
|
||||||
|
<div class="space-y-2" data-form-collection-target="fields">
|
||||||
|
{% for line in form.lines %}
|
||||||
|
{{ period.line_row(line) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="mt-3 text-sm font-medium text-primary hover:underline"
|
||||||
|
data-action="form-collection#addItem">
|
||||||
|
+ Position hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% do form.lines.setRendered %}
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-6">
|
||||||
|
<button type="submit" class="button button--primary button--small">
|
||||||
|
speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Zeitraum entfernen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div>
|
||||||
|
Möchtest du den Zeitraum {{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }}
|
||||||
|
mit allen Positionen entfernen? Die Nächte gehen an den Zeitraum der Gruppe zurück, und der
|
||||||
|
Preis wird neu berechnet.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block button_confirm %}entfernen{% endblock %}
|
||||||
@@ -1,94 +1,81 @@
|
|||||||
{% extends 'layout_admin.html.twig' %}
|
{% extends 'layout_admin.html.twig' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set edit_params = { 'id': booking.id } %}
|
||||||
|
{% if app.request.query.get('r') %}
|
||||||
|
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<twig:page:heading>
|
<twig:page:heading>
|
||||||
{{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
|
{{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
|
||||||
{{ booking.groupName }}
|
{{ booking.groupName }}
|
||||||
</twig:page:heading>
|
</twig:page:heading>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-2 gap-8">
|
{# Same arrangement as the edit form: what was booked in the main column, status, the next
|
||||||
<div>
|
step and the actions in a sidebar that stays in view on wide screens. #}
|
||||||
<h2 class="text-lg font-bold mb-4">Kontakt</h2>
|
<div class="xl:grid xl:grid-cols-3 xl:gap-6 xl:items-start space-y-6 xl:space-y-0">
|
||||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
<div class="xl:col-span-2 space-y-6">
|
||||||
<dt class="font-medium">Gruppenname</dt>
|
<twig:form:section title="Aufenthalt">
|
||||||
<dd>{{ booking.groupName }}</dd>
|
|
||||||
<dt class="font-medium">Anrede</dt>
|
|
||||||
<dd>{{ booking.salutation | default('–') }}</dd>
|
|
||||||
<dt class="font-medium">Name</dt>
|
|
||||||
<dd>{{ booking.firstName }} {{ booking.lastName }}</dd>
|
|
||||||
<dt class="font-medium">E-Mail</dt>
|
|
||||||
<dd>{{ booking.email }}</dd>
|
|
||||||
<dt class="font-medium">Telefon</dt>
|
|
||||||
<dd>{{ booking.phone | default('–') }}</dd>
|
|
||||||
<dt class="font-medium">Adresse</dt>
|
|
||||||
<dd>
|
|
||||||
{% if booking.street %}
|
|
||||||
{{ booking.street }}<br>
|
|
||||||
{{ booking.zip }} {{ booking.city }}
|
|
||||||
{% else %}
|
|
||||||
–
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-bold mb-4">Reisedaten</h2>
|
|
||||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
|
||||||
<dt class="font-medium">Gruppenhaus</dt>
|
|
||||||
<dd>{{ booking.accommodation.name }}</dd>
|
|
||||||
<dt class="font-medium">Anreise</dt>
|
|
||||||
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
|
|
||||||
<dt class="font-medium">Abreise</dt>
|
|
||||||
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
|
|
||||||
<dt class="font-medium">Nächte</dt>
|
|
||||||
<dd>{{ booking.nights }}</dd>
|
|
||||||
<dt class="font-medium">Anzahl Personen</dt>
|
|
||||||
<dd>{{ booking.paxCount }}</dd>
|
|
||||||
{% if booking.minorsCount > 0 %}
|
|
||||||
<dt class="font-medium">davon Kinder (0–3 Jahre)</dt>
|
|
||||||
<dd>{{ booking.minorsCount }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.childrenCount > 0 %}
|
|
||||||
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
|
|
||||||
<dd>{{ booking.childrenCount }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
<dt class="font-medium">Herkunft</dt>
|
|
||||||
<dd>{{ booking.origin.label }}</dd>
|
|
||||||
<dt class="font-medium">Status</dt>
|
|
||||||
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
|
|
||||||
{% if booking.acceptedAt is not null %}
|
|
||||||
<dt class="font-medium">Angenommen am</dt>
|
|
||||||
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.confirmedAt is not null %}
|
|
||||||
<dt class="font-medium">Bestätigt am</dt>
|
|
||||||
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-bold mb-4">Gebuchte Leistungen</h2>
|
|
||||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||||
{% if booking.boardServiceLabel %}
|
<dt class="font-medium">Gruppenhaus</dt>
|
||||||
<dt class="font-medium">Verpflegung</dt>
|
<dd>{{ booking.accommodation.name }}</dd>
|
||||||
<dd>{{ booking.boardServiceLabel }}</dd>
|
<dt class="font-medium">Anreise</dt>
|
||||||
|
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
|
||||||
|
<dt class="font-medium">Abreise</dt>
|
||||||
|
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
|
||||||
|
<dt class="font-medium">Nächte</dt>
|
||||||
|
<dd>{{ booking.nights }}</dd>
|
||||||
|
{% if booking.periods is not empty %}
|
||||||
|
<dt class="font-medium">Zeitraum Gruppe</dt>
|
||||||
|
<dd>
|
||||||
|
{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
|
||||||
|
{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt class="font-medium">Anzahl Personen</dt>
|
||||||
|
<dd>{{ booking.paxCount }}</dd>
|
||||||
|
{% if booking.minorsCount > 0 %}
|
||||||
|
<dt class="font-medium">davon Kinder (0–3 Jahre)</dt>
|
||||||
|
<dd>{{ booking.minorsCount }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.childrenCount > 0 %}
|
||||||
|
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
|
||||||
|
<dd>{{ booking.childrenCount }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for service in booking.additionalServices %}
|
|
||||||
<dt class="font-medium">{{ loop.first ? 'Zusatzleistungen' : '' }}</dt>
|
|
||||||
<dd>{{ service.label }}</dd>
|
|
||||||
{% endfor %}
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</twig:form:section>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if priceBreakdown is not null %}
|
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
|
||||||
<div>
|
<twig:form:section title="Leistungen">
|
||||||
<h2 class="text-lg font-bold mb-4">Preise</h2>
|
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||||
|
{% if booking.boardServiceLabel %}
|
||||||
|
<dt class="font-medium">Verpflegung</dt>
|
||||||
|
<dd>{{ booking.boardServiceLabel }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% for service in booking.additionalServices %}
|
||||||
|
<dt class="font-medium">{{ loop.first ? 'Zusatzleistungen' : '' }}</dt>
|
||||||
|
<dd>{{ service.label }}</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</twig:form:section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if priceBreakdown is not null %}
|
||||||
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
||||||
|
<twig:form:section title="Preise">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
|
{% for period in priceBreakdown.periods | filter(p => p.position == 'leading') %}
|
||||||
|
{% include 'admin/accommodation_booking/_price_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if priceBreakdown.periods is not empty %}
|
||||||
|
<tr class="border-t border-gray-200 font-medium">
|
||||||
|
<td class="pt-2 pb-1" colspan="2">
|
||||||
|
Gruppe · {{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
|
||||||
|
{{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if priceBreakdown.basePrice > 0 %}
|
{% if priceBreakdown.basePrice > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-1">
|
<td class="py-1">
|
||||||
@@ -139,6 +126,9 @@
|
|||||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}</td>
|
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% for period in priceBreakdown.periods | filter(p => p.position == 'trailing') %}
|
||||||
|
{% include 'admin/accommodation_booking/_price_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
<tr class="border-t border-gray-200 font-semibold">
|
<tr class="border-t border-gray-200 font-semibold">
|
||||||
<td class="pt-2">
|
<td class="pt-2">
|
||||||
Gesamtpreis
|
Gesamtpreis
|
||||||
@@ -171,136 +161,179 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</twig:form:section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if booking.remarks %}
|
<twig:form:section title="Kontakt">
|
||||||
<div class="lg:col-span-2">
|
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||||
<h2 class="text-lg font-bold mb-4">Bemerkungen</h2>
|
<dt class="font-medium">Gruppenname</dt>
|
||||||
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
|
<dd>{{ booking.groupName }}</dd>
|
||||||
</div>
|
<dt class="font-medium">Anrede</dt>
|
||||||
{% endif %}
|
<dd>{{ booking.salutation | default('–') }}</dd>
|
||||||
</div>
|
<dt class="font-medium">Name</dt>
|
||||||
|
<dd>{{ booking.firstName }} {{ booking.lastName }}</dd>
|
||||||
|
<dt class="font-medium">E-Mail</dt>
|
||||||
|
<dd>{{ booking.email }}</dd>
|
||||||
|
<dt class="font-medium">Telefon</dt>
|
||||||
|
<dd>{{ booking.phone | default('–') }}</dd>
|
||||||
|
<dt class="font-medium">Adresse</dt>
|
||||||
|
<dd>
|
||||||
|
{% if booking.street %}
|
||||||
|
{{ booking.street }}<br>
|
||||||
|
{{ booking.zip }} {{ booking.city }}
|
||||||
|
{% else %}
|
||||||
|
–
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</twig:form:section>
|
||||||
|
|
||||||
<div class="text-xs text-gray-400 mt-6">
|
{% if booking.remarks %}
|
||||||
Eingegangen: {{ booking.createdAt | date('d.m.Y, H:i') }}
|
<twig:form:section title="Bemerkungen">
|
||||||
{% if booking.updatedAt is not null and booking.updatedAt != booking.createdAt %}
|
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
|
||||||
· Aktualisiert: {{ booking.updatedAt | date('d.m.Y, H:i') }}
|
</twig:form:section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if booking.draft or booking.requested %}
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
|
||||||
<h2 class="text-lg font-bold mb-2">Angebot</h2>
|
|
||||||
<p class="text-sm text-gray-500 mb-2">
|
|
||||||
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink
|
|
||||||
erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button type="button"
|
|
||||||
class="button button--primary button--small"
|
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
|
|
||||||
hx-target="body"
|
|
||||||
hx-swap="beforeend">
|
|
||||||
Angebot per E-Mail senden
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
class="button button--secondary button--small"
|
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
|
|
||||||
hx-target="body"
|
|
||||||
hx-swap="beforeend">
|
|
||||||
Link erzeugen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if booking.received %}
|
<aside class="space-y-6 xl:sticky xl:top-8">
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
<twig:form:section title="Status">
|
||||||
<h2 class="text-lg font-bold mb-2">Bestätigung</h2>
|
<dl class="grid grid-cols-2 gap-y-1 text-sm">
|
||||||
<p class="text-sm text-gray-500 mb-2">
|
<dt class="text-gray-500">Status</dt>
|
||||||
Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.
|
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
|
||||||
</p>
|
<dt class="text-gray-500">Herkunft</dt>
|
||||||
<button type="button"
|
<dd>{{ booking.origin.label }}</dd>
|
||||||
class="button button--primary button--small"
|
{% if booking.managedBy is not null %}
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
|
<dt class="text-gray-500">Bearbeiter:in</dt>
|
||||||
hx-target="body"
|
<dd>{{ booking.managedBy.displayName }}</dd>
|
||||||
hx-swap="beforeend">
|
{% endif %}
|
||||||
Buchung per E-Mail bestätigen
|
{% if booking.acceptedAt is not null %}
|
||||||
</button>
|
<dt class="text-gray-500">Angenommen</dt>
|
||||||
</div>
|
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if booking.confirmedAt is not null %}
|
||||||
|
<dt class="text-gray-500">Bestätigt</dt>
|
||||||
|
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt class="text-gray-500">Eingegangen</dt>
|
||||||
|
<dd>{{ booking.createdAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% if booking.updatedAt is not null and booking.updatedAt != booking.createdAt %}
|
||||||
|
<dt class="text-gray-500">Aktualisiert</dt>
|
||||||
|
<dd>{{ booking.updatedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</twig:form:section>
|
||||||
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
{# One pink button per page, for the next step; everything else is grey, and the one
|
||||||
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
|
destructive action is set apart below the others. #}
|
||||||
{% if not booking.customerAccessible %}
|
{% set has_next_step = booking.draft or booking.requested or booking.received %}
|
||||||
<p class="text-sm text-gray-500">
|
{% if booking.draft or booking.requested %}
|
||||||
Ein Zugangslink wird beim Versand des Angebots erzeugt.
|
<twig:form:section title="Nächster Schritt: Angebot">
|
||||||
</p>
|
<p class="text-sm text-gray-500 mb-3">
|
||||||
{% elseif accessLink %}
|
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink
|
||||||
<div class="flex items-center gap-2">
|
erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
|
||||||
<button type="button"
|
</p>
|
||||||
class="button button--secondary button--small"
|
<div class="flex flex-col gap-2">
|
||||||
data-controller="clipboard"
|
<button type="button"
|
||||||
data-clipboard-content-value="{{ accessLink }}"
|
class="button button--primary button--small w-full"
|
||||||
data-clipboard-label-value="Kopiert!"
|
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
|
||||||
data-action="clipboard#copy">
|
hx-target="body"
|
||||||
{{ icon('copy', 'w-4 h-4') }} Link kopieren
|
hx-swap="beforeend">
|
||||||
</button>
|
Angebot per E-Mail senden
|
||||||
<button type="button"
|
</button>
|
||||||
class="button button--secondary button--small"
|
<button type="button"
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
|
class="button button--neutral button--small w-full"
|
||||||
hx-target="body"
|
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
|
||||||
hx-swap="beforeend">
|
hx-target="body"
|
||||||
Link senden
|
hx-swap="beforeend">
|
||||||
</button>
|
nur Link erzeugen
|
||||||
</div>
|
</button>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
</div>
|
||||||
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
|
</twig:form:section>
|
||||||
{% if accessLinkExpiresAt < date() %}
|
{% elseif booking.received %}
|
||||||
<span class="text-red-600 font-medium">(abgelaufen)</span>
|
<twig:form:section title="Nächster Schritt: Bestätigung">
|
||||||
|
<p class="text-sm text-gray-500 mb-3">
|
||||||
|
Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.
|
||||||
|
</p>
|
||||||
|
<button type="button"
|
||||||
|
class="button button--primary button--small w-full"
|
||||||
|
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Buchung per E-Mail bestätigen
|
||||||
|
</button>
|
||||||
|
</twig:form:section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<twig:form:section title="Zugangslink">
|
||||||
|
{% if not booking.customerAccessible %}
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Ein Zugangslink wird beim Versand des Angebots erzeugt.
|
||||||
|
</p>
|
||||||
|
{% elseif accessLink %}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button type="button"
|
||||||
|
class="button button--neutral button--small sm:text-xs w-full"
|
||||||
|
data-controller="clipboard"
|
||||||
|
data-clipboard-content-value="{{ accessLink }}"
|
||||||
|
data-clipboard-label-value="Kopiert!"
|
||||||
|
data-action="clipboard#copy">
|
||||||
|
{{ icon('copy', 'w-4 h-4') }} Link kopieren
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="button button--neutral button--small sm:text-xs w-full"
|
||||||
|
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Link senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
|
||||||
|
{% if accessLinkExpiresAt < date() %}
|
||||||
|
<span class="text-red-600 font-medium">(abgelaufen)</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Der Zugangslink wird beim Versand des Angebots erzeugt.
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</twig:form:section>
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Der Zugangslink wird beim Versand des Angebots erzeugt.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-between pt-8">
|
<twig:form:section title="Aktionen">
|
||||||
<a href="{{ returnUrl }}" class="button button--secondary button--small">
|
<div class="flex flex-col gap-2">
|
||||||
zurück
|
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}"
|
||||||
</a>
|
class="button {{ has_next_step ? 'button--neutral' : 'button--primary' }} button--small w-full">
|
||||||
<div class="flex items-center gap-2">
|
bearbeiten
|
||||||
{% if not booking.confirmed and not booking.discarded %}
|
</a>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="button button--warning button--small"
|
class="button button--neutral button--small w-full"
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_discard', { id: booking.id }) }}"
|
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
Absagen
|
duplizieren
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}"
|
||||||
{% if booking.confirmed %}
|
class="button button--neutral button--small w-full">
|
||||||
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}" class="button button--secondary button--small">
|
PDF herunterladen
|
||||||
PDF herunterladen
|
</a>
|
||||||
</a>
|
</div>
|
||||||
{% endif %}
|
{% if not booking.confirmed and not booking.discarded %}
|
||||||
{% set edit_params = { 'id': booking.id } %}
|
<div class="mt-4 pt-3 border-t border-gray-200 text-center">
|
||||||
{% if app.request.query.get('r') %}
|
<button type="button"
|
||||||
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
|
class="text-sm font-medium text-red-600 hover:underline"
|
||||||
{% endif %}
|
hx-get="{{ path('app_admin_accommodationbooking_discard', { id: booking.id }) }}"
|
||||||
<button type="button"
|
hx-target="body"
|
||||||
class="button button--secondary button--small"
|
hx-swap="beforeend">
|
||||||
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
|
{{ booking.recordLabel }} absagen
|
||||||
hx-target="body"
|
</button>
|
||||||
hx-swap="beforeend">
|
</div>
|
||||||
duplizieren
|
{% endif %}
|
||||||
</button>
|
</twig:form:section>
|
||||||
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
|
|
||||||
bearbeiten
|
<a href="{{ returnUrl }}" class="inline-block text-sm text-gray-500 hover:text-gray-900 hover:underline">
|
||||||
|
← zurück
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{#- A titled panel grouping the fields of a long admin form. No overflow-hidden: the dots
|
||||||
|
dropdowns inside must be able to open past its edge. -#}
|
||||||
|
<div {{ attributes.defaults({class: 'bg-white border border-gray-200 rounded-md'}).without('title') }}>
|
||||||
|
<div class="flex items-center justify-between gap-4 px-4 py-2 bg-gray-100 border-b border-gray-200 rounded-t-md font-bold">
|
||||||
|
{{ title }}
|
||||||
|
{% block actions %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -11,6 +11,17 @@
|
|||||||
<h2>Preise</h2>
|
<h2>Preise</h2>
|
||||||
|
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
||||||
|
{% for period in priceBreakdown.periods|filter(p => p.position == 'leading') %}
|
||||||
|
{% include 'email/_price_breakdown_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if priceBreakdown.periods is not empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
|
Gruppe · {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }},
|
||||||
|
{{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if priceBreakdown.basePrice > 0 %}
|
{% if priceBreakdown.basePrice > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="{{ cell }}">
|
<td style="{{ cell }}">
|
||||||
@@ -64,6 +75,9 @@
|
|||||||
<td style="{{ amount }}">{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
<td style="{{ amount }}">{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% for period in priceBreakdown.periods|filter(p => p.position == 'trailing') %}
|
||||||
|
{% include 'email/_price_breakdown_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
Gesamtpreis
|
Gesamtpreis
|
||||||
@@ -103,3 +117,4 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{#- One split-off period of _price_breakdown.html.twig: its range as a heading, then its lines.
|
||||||
|
Context: period, cell, amount, currency. -#}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
|
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||||
|
{{ period.dateFrom|date('d.m.Y') }} – {{ period.dateTo|date('d.m.Y') }},
|
||||||
|
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for line in period.lines %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">{{ line.label }}</td>
|
||||||
|
<td style="{{ amount }}">{{ (line.amount / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
@@ -19,6 +19,12 @@
|
|||||||
<td><strong>Nächte</strong></td>
|
<td><strong>Nächte</strong></td>
|
||||||
<td>{{ booking.nights }}</td>
|
<td>{{ booking.nights }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if booking.periods is not empty %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Zeitraum Gruppe</strong></td>
|
||||||
|
<td>{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Anzahl Personen</strong></td>
|
<td><strong>Anzahl Personen</strong></td>
|
||||||
<td>{{ booking.paxCount }}</td>
|
<td>{{ booking.paxCount }}</td>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:18px;text-align:left;color:#FFFFFF;">E&P Reisen und Events GmbH <br /> Aachener Str. 326-328 <br /> 50933 Köln</div>
|
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:18px;text-align:left;color:#FFFFFF;">E&P Reisen und Events GmbH <br /> Neue Weyerstraße 6<br />50676 Köln</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{{ block('form_widget_simple') }}
|
{{ block('form_widget_simple') }}
|
||||||
<span class="{{ currency_class }}">€</span>
|
<span class="{{ currency_class }}">{{ money_pattern|replace({'{{ widget }}': ''})|trim }}</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endblock money_widget -%}
|
{%- endblock money_widget -%}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,17 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% for period in ctx.priceBreakdown.periods|filter(p => p.position == 'leading') %}
|
||||||
|
{% include 'groups/offer/_summary_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if ctx.priceBreakdown.periods is not empty %}
|
||||||
|
<tr class="border-t border-primary-bg font-semibold">
|
||||||
|
<td class="p-2" colspan="2">
|
||||||
|
Gruppe · {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }},
|
||||||
|
{{ ctx.priceBreakdown.nights }} {{ ctx.priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if ctx.priceBreakdown.basePrice > 0 %}
|
{% if ctx.priceBreakdown.basePrice > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="p-2">
|
<td class="p-2">
|
||||||
@@ -141,6 +152,9 @@
|
|||||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% for period in ctx.priceBreakdown.periods|filter(p => p.position == 'trailing') %}
|
||||||
|
{% include 'groups/offer/_summary_period.html.twig' %}
|
||||||
|
{% endfor %}
|
||||||
<tr class="border-t border-primary-bg font-semibold text-base">
|
<tr class="border-t border-primary-bg font-semibold text-base">
|
||||||
<td class="p-2">
|
<td class="p-2">
|
||||||
Gesamtpreis
|
Gesamtpreis
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{#- One split-off period of the offer summary: its range as a heading, then its lines.
|
||||||
|
Context: period, currency. -#}
|
||||||
|
<tr class="border-t border-primary-bg font-semibold">
|
||||||
|
<td class="p-2" colspan="2">
|
||||||
|
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||||
|
{{ period.dateFrom|date('d.m.Y') }} – {{ period.dateTo|date('d.m.Y') }},
|
||||||
|
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for line in period.lines %}
|
||||||
|
<tr>
|
||||||
|
<td class="p-2">{{ line.label }}</td>
|
||||||
|
<td class="p-2 text-right whitespace-nowrap">{{ (line.amount / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
@@ -83,6 +83,9 @@
|
|||||||
<tr><th>Anreise</th><td>{{ booking.dateFrom|date('d.m.Y') }}</td></tr>
|
<tr><th>Anreise</th><td>{{ booking.dateFrom|date('d.m.Y') }}</td></tr>
|
||||||
<tr><th>Abreise</th><td>{{ booking.dateTo|date('d.m.Y') }}</td></tr>
|
<tr><th>Abreise</th><td>{{ booking.dateTo|date('d.m.Y') }}</td></tr>
|
||||||
<tr><th>Nächte</th><td>{{ booking.nights }}</td></tr>
|
<tr><th>Nächte</th><td>{{ booking.nights }}</td></tr>
|
||||||
|
{% if booking.periods is not empty %}
|
||||||
|
<tr><th>Zeitraum Gruppe</th><td>{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
<tr><th>Anzahl Personen</th><td>{{ booking.paxCount }}</td></tr>
|
<tr><th>Anzahl Personen</th><td>{{ booking.paxCount }}</td></tr>
|
||||||
{% if booking.minorsCount > 0 %}
|
{% if booking.minorsCount > 0 %}
|
||||||
<tr><th>davon Kinder (0–3 J.)</th><td>{{ booking.minorsCount }}</td></tr>
|
<tr><th>davon Kinder (0–3 J.)</th><td>{{ booking.minorsCount }}</td></tr>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Tests\BusProNet\DataLoader;
|
namespace App\Tests\BusProNet\DataLoader;
|
||||||
|
|
||||||
use App\BusProNet\Model\Hotel;
|
use App\BusProNet\Model\Hotel;
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
use App\BusProNet\XmlLoader\HotelLoader;
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ class HotelDataLoaderTest extends TestCase
|
|||||||
->willReturn($xmlContent);
|
->willReturn($xmlContent);
|
||||||
|
|
||||||
$cache = new TagAwareAdapter(new ArrayAdapter());
|
$cache = new TagAwareAdapter(new ArrayAdapter());
|
||||||
$loader = new HotelLoader($cache, $filesystem);
|
$loader = new HotelLoader(new CountryCodeMapper(new NullLogger()), $cache, $filesystem);
|
||||||
|
|
||||||
$hotels = $loader->loadAll();
|
$hotels = $loader->loadAll();
|
||||||
|
|
||||||
@@ -50,5 +52,7 @@ class HotelDataLoaderTest extends TestCase
|
|||||||
$this->assertEquals('F', $hotel->country);
|
$this->assertEquals('F', $hotel->country);
|
||||||
$this->assertEquals('Rue des Lacs', $hotel->street);
|
$this->assertEquals('Rue des Lacs', $hotel->street);
|
||||||
$this->assertEquals('', $hotel->phone);
|
$this->assertEquals('', $hotel->phone);
|
||||||
|
$this->assertEquals('fr', $hotel->countryCode);
|
||||||
|
$this->assertNull($hotel->costUnit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\Utility;
|
||||||
|
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class CountryCodeMapperTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string, string}>
|
||||||
|
*/
|
||||||
|
public static function tokenProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'iso-2 passes through' => ['CH', 'ch'],
|
||||||
|
'vehicle code D' => ['D', 'de'],
|
||||||
|
'iso-3 DEU' => ['DEU', 'de'],
|
||||||
|
'vehicle code A' => ['A', 'at'],
|
||||||
|
'iso-2 AT' => ['AT', 'at'],
|
||||||
|
'iso-3 AUT' => ['AUT', 'at'],
|
||||||
|
'vehicle code F' => ['F', 'fr'],
|
||||||
|
'vehicle code I' => ['I', 'it'],
|
||||||
|
'iso-2 IT' => ['IT', 'it'],
|
||||||
|
'vehicle code E' => ['E', 'es'],
|
||||||
|
'vehicle code B' => ['B', 'be'],
|
||||||
|
'vehicle code S is Sweden' => ['S', 'se'],
|
||||||
|
'vehicle code N is Norway' => ['N', 'no'],
|
||||||
|
'vehicle code FL is Liechtenstein' => ['FL', 'li'],
|
||||||
|
'IR is Ireland, not Iran' => ['IR', 'ie'],
|
||||||
|
'UK maps to the iso GB' => ['UK', 'gb'],
|
||||||
|
'iso-3 PRT' => ['PRT', 'pt'],
|
||||||
|
'iso-3 GRE' => ['GRE', 'gr'],
|
||||||
|
'iso-3 HRV' => ['HRV', 'hr'],
|
||||||
|
'iso-3 RUS' => ['RUS', 'ru'],
|
||||||
|
'iso-3 USA' => ['USA', 'us'],
|
||||||
|
'SI is Slovenia' => ['SI', 'si'],
|
||||||
|
'SLO is Slovakia' => ['SLO', 'sk'],
|
||||||
|
'ZA is South Africa' => ['ZA', 'za'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('tokenProvider')]
|
||||||
|
public function testMapsKnownToken(string $token, string $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, (new CountryCodeMapper(new NullLogger()))->map($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryMappedCodeIsLowercaseIsoTwo(): void
|
||||||
|
{
|
||||||
|
$mapper = new CountryCodeMapper(new NullLogger());
|
||||||
|
|
||||||
|
foreach (array_keys(self::tokenProvider()) as $name) {
|
||||||
|
[$token] = self::tokenProvider()[$name];
|
||||||
|
$code = $mapper->map($token);
|
||||||
|
|
||||||
|
$this->assertNotNull($code);
|
||||||
|
$this->assertMatchesRegularExpression('/^[a-z]{2}$/', $code, $token.' must map to lowercase ISO-2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCaseAndWhitespaceInsensitive(): void
|
||||||
|
{
|
||||||
|
$mapper = new CountryCodeMapper(new NullLogger());
|
||||||
|
|
||||||
|
$this->assertSame('ch', $mapper->map('ch'));
|
||||||
|
$this->assertSame('at', $mapper->map(' aut '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownTokenMapsToNullAndWarnsOnce(): void
|
||||||
|
{
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger
|
||||||
|
->expects($this->once())
|
||||||
|
->method('warning')
|
||||||
|
->with('Unmapped BusPro country token', ['token' => 'XX']);
|
||||||
|
|
||||||
|
$mapper = new CountryCodeMapper($logger);
|
||||||
|
|
||||||
|
$this->assertNull($mapper->map('XX'));
|
||||||
|
$this->assertNull($mapper->map('XX'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAbsentTokenMapsToNullWithoutWarning(): void
|
||||||
|
{
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger
|
||||||
|
->expects($this->never())
|
||||||
|
->method('warning');
|
||||||
|
|
||||||
|
$mapper = new CountryCodeMapper($logger);
|
||||||
|
|
||||||
|
$this->assertNull($mapper->map(null));
|
||||||
|
$this->assertNull($mapper->map(''));
|
||||||
|
$this->assertNull($mapper->map(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\BusProNet\XmlLoader;
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\BusProNet\XmlLoader\HotelLoader;
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
class HotelLoaderTest extends TestCase
|
class HotelLoaderTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -19,7 +23,7 @@ class HotelLoaderTest extends TestCase
|
|||||||
->expects($this->never())
|
->expects($this->never())
|
||||||
->method('read');
|
->method('read');
|
||||||
|
|
||||||
$loader = new HotelLoader(new ArrayAdapter(), $filesystem);
|
$loader = $this->createLoader($filesystem);
|
||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
$travel->hotelId = null;
|
$travel->hotelId = null;
|
||||||
$travel->hotel = null;
|
$travel->hotel = null;
|
||||||
@@ -28,4 +32,64 @@ class HotelLoaderTest extends TestCase
|
|||||||
|
|
||||||
$this->assertNull($travel->hotel);
|
$this->assertNull($travel->hotel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testParseXmlMapsCostUnitAndCountryCode(): void
|
||||||
|
{
|
||||||
|
$hotel = $this->createLoader()->parseXml($this->hotelNode(
|
||||||
|
'<kostenstelle>83</kostenstelle><land>CH</land>'
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame('83', $hotel->costUnit);
|
||||||
|
$this->assertSame('CH', $hotel->country, 'the raw token is the join key and must not be rewritten');
|
||||||
|
$this->assertSame('ch', $hotel->countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseXmlKeepsAListOfCostUnitsAsAString(): void
|
||||||
|
{
|
||||||
|
$hotel = $this->createLoader()->parseXml($this->hotelNode(
|
||||||
|
'<kostenstelle>84 85</kostenstelle><land>D</land>'
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame('84 85', $hotel->costUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseXmlLeavesCostUnitNullWhenAbsent(): void
|
||||||
|
{
|
||||||
|
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>D</land>'));
|
||||||
|
|
||||||
|
$this->assertNull($hotel->costUnit);
|
||||||
|
$this->assertSame('de', $hotel->countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseXmlLeavesBothCountryFieldsNullWhenLandIsAbsent(): void
|
||||||
|
{
|
||||||
|
$hotel = $this->createLoader()->parseXml($this->hotelNode('<name>Ohne Land</name>'));
|
||||||
|
|
||||||
|
$this->assertNull($hotel->country);
|
||||||
|
$this->assertNull($hotel->countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseXmlLeavesCountryCodeNullForAnUnmappableToken(): void
|
||||||
|
{
|
||||||
|
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>XX</land>'));
|
||||||
|
|
||||||
|
$this->assertSame('XX', $hotel->country);
|
||||||
|
$this->assertNull($hotel->countryCode, 'an unknown token must never be guessed into a code');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hotelNode(string $children): Crawler
|
||||||
|
{
|
||||||
|
$xml = sprintf('<hotels><hotel idbuspro="1" code="TEST">%s</hotel></hotels>', $children);
|
||||||
|
|
||||||
|
return XmlCrawlerFactory::create($xml)->filterXPath('//hotels/hotel');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createLoader(?FilesystemOperator $filesystem = null): HotelLoader
|
||||||
|
{
|
||||||
|
return new HotelLoader(
|
||||||
|
new CountryCodeMapper(new NullLogger()),
|
||||||
|
new ArrayAdapter(),
|
||||||
|
$filesystem ?? $this->createStub(FilesystemOperator::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Hotel;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use League\Flysystem\DirectoryListing;
|
||||||
|
use League\Flysystem\FileAttributes;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
|
class TravelLoaderFilesMapTest extends TestCase
|
||||||
|
{
|
||||||
|
private const XML = '<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<reisen>
|
||||||
|
<reise code="SBW">
|
||||||
|
<termin idbuspro="900" code="SBW250101" termin="01.01.2025" bis="08.01.2025">
|
||||||
|
<text>Skiwoche</text>
|
||||||
|
<hotel idbuspro="1" code="KNOWN"/>
|
||||||
|
<hotel idbuspro="999" code="GHOST"/>
|
||||||
|
</termin>
|
||||||
|
</reise>
|
||||||
|
</reisen>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A travel may reference a hotel id that hotel.xml does not carry. Before the guard the
|
||||||
|
* miss was dereferenced, which fataled the whole cached mapping rather than one entry --
|
||||||
|
* so /api/travels went down entirely, not just the affected travel.
|
||||||
|
*/
|
||||||
|
public function testGenerateFilesMapSkipsHotelsMissingFromMasterData(): void
|
||||||
|
{
|
||||||
|
$known = new Hotel();
|
||||||
|
$known->id = 1;
|
||||||
|
$known->name = 'Hotel Bekannt';
|
||||||
|
|
||||||
|
$hotelLoader = $this->createStub(HotelLoader::class);
|
||||||
|
$hotelLoader->method('loadAll')->willReturn([1 => $known]);
|
||||||
|
|
||||||
|
$filesystem = $this->createStub(FilesystemOperator::class);
|
||||||
|
$filesystem->method('listContents')->willReturn(
|
||||||
|
new DirectoryListing([new FileAttributes('Ziel_1.xml')])
|
||||||
|
);
|
||||||
|
$filesystem->method('read')->willReturn(self::XML);
|
||||||
|
|
||||||
|
$loader = new TravelLoader(
|
||||||
|
$hotelLoader,
|
||||||
|
$this->createStub(TravelParser::class),
|
||||||
|
'https://example.test',
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
$filesystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
$mapping = $loader->generateFilesMap();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey(900, $mapping);
|
||||||
|
$this->assertSame([1], array_keys($mapping[900]['hotels']));
|
||||||
|
$this->assertSame('Hotel Bekannt', $mapping[900]['hotels'][1]['label']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Command;
|
||||||
|
|
||||||
|
use App\Command\CleanupUsersCommand;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
class CleanupUsersCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDeletesAccountsWithoutAnEffectiveRole(): void
|
||||||
|
{
|
||||||
|
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
|
||||||
|
$roleless = $this->user('[email protected]', []);
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$removed = [];
|
||||||
|
$entityManager->expects(self::exactly(2))
|
||||||
|
->method('remove')
|
||||||
|
->willReturnCallback(static function (User $user) use (&$removed): void {
|
||||||
|
$removed[] = $user->getEmail();
|
||||||
|
});
|
||||||
|
$entityManager->expects(self::once())->method('flush');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
|
||||||
|
$tester->execute(['--force' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
self::assertSame(['[email protected]', '[email protected]'], $removed);
|
||||||
|
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate query cannot read the roles, so everything the role policy protects has to
|
||||||
|
* survive this filter — including an account whose only effective role sits beside the
|
||||||
|
* ROLE_CUSTOMER fallback that older rows still carry.
|
||||||
|
*
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
#[DataProvider('protectedRoleSets')]
|
||||||
|
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
|
||||||
|
{
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::never())->method('remove');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
|
||||||
|
$tester->execute(['--force' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string[]}>
|
||||||
|
*/
|
||||||
|
public static function protectedRoleSets(): iterable
|
||||||
|
{
|
||||||
|
yield 'teamer' => [[Role::TEAMER]];
|
||||||
|
yield 'employee' => [[Role::EMPLOYEE]];
|
||||||
|
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
|
||||||
|
yield 'house manager' => [[Role::HOUSE_MANAGER]];
|
||||||
|
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
|
||||||
|
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
|
||||||
|
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
|
||||||
|
* definition ever forces the caller to state an intent — this does.
|
||||||
|
*/
|
||||||
|
public function testRefusesToDeleteWithoutForce(): void
|
||||||
|
{
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::never())->method('remove');
|
||||||
|
$entityManager->expects(self::never())->method('flush');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||||
|
$tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||||
|
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDryRunDeletesNothing(): void
|
||||||
|
{
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::never())->method('remove');
|
||||||
|
$entityManager->expects(self::never())->method('flush');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||||
|
$tester->execute(['--dry-run' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||||
|
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
|
||||||
|
self::assertStringContainsString('[email protected]', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
|
||||||
|
* account among protected ones still deletes it rather than spending the budget on rows it
|
||||||
|
* then discards.
|
||||||
|
*/
|
||||||
|
public function testLimitCountsDeletionsRatherThanCandidates(): void
|
||||||
|
{
|
||||||
|
$users = [
|
||||||
|
$this->user('[email protected]', [Role::TEAMER]),
|
||||||
|
$this->user('[email protected]', [Role::CUSTOMER]),
|
||||||
|
];
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::once())
|
||||||
|
->method('remove')
|
||||||
|
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->command($users, $entityManager));
|
||||||
|
$tester->execute(['--limit' => 1, '--force' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInactivityWindowIsOffUnlessAskedFor(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(UserRepository::class);
|
||||||
|
$repository->expects(self::once())
|
||||||
|
->method('findCleanupCandidates')
|
||||||
|
->with(null)
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||||
|
$tester->execute(['--force' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInactivityWindowIsPassedOnAsADate(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(UserRepository::class);
|
||||||
|
$repository->expects(self::once())
|
||||||
|
->method('findCleanupCandidates')
|
||||||
|
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
|
||||||
|
$expected = new \DateTimeImmutable('-6 months');
|
||||||
|
|
||||||
|
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
|
||||||
|
}))
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||||
|
$tester->execute(['--inactive-for' => 6, '--force' => true]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsANonPositiveWindow(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(UserRepository::class);
|
||||||
|
$repository->expects(self::never())->method('findCleanupCandidates');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||||
|
$tester->execute(['--inactive-for' => 0]);
|
||||||
|
|
||||||
|
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function user(string $email, array $roles): User
|
||||||
|
{
|
||||||
|
return (new User($email))
|
||||||
|
->setRoles($roles)
|
||||||
|
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User[] $candidates
|
||||||
|
*/
|
||||||
|
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||||
|
{
|
||||||
|
$repository = $this->createStub(UserRepository::class);
|
||||||
|
$repository->method('findCleanupCandidates')->willReturn($candidates);
|
||||||
|
|
||||||
|
return $this->commandWith($repository, $entityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||||
|
{
|
||||||
|
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Exception\ImplausibleBookingPriceException;
|
|||||||
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 PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
@@ -72,6 +73,7 @@ class EditControllerTest extends TestCase
|
|||||||
$this->createStub(AdditionalServiceRepository::class),
|
$this->createStub(AdditionalServiceRepository::class),
|
||||||
$this->createStub(UserRepository::class),
|
$this->createStub(UserRepository::class),
|
||||||
$bookingService,
|
$bookingService,
|
||||||
|
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||||
$form,
|
$form,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ class EditControllerTest extends TestCase
|
|||||||
$this->createStub(AdditionalServiceRepository::class),
|
$this->createStub(AdditionalServiceRepository::class),
|
||||||
$this->createStub(UserRepository::class),
|
$this->createStub(UserRepository::class),
|
||||||
$bookingService,
|
$bookingService,
|
||||||
|
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||||
$form,
|
$form,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,6 +174,7 @@ class EditControllerTest extends TestCase
|
|||||||
$this->createStub(AdditionalServiceRepository::class),
|
$this->createStub(AdditionalServiceRepository::class),
|
||||||
$userRepo,
|
$userRepo,
|
||||||
$this->createStub(AccommodationBookingService::class),
|
$this->createStub(AccommodationBookingService::class),
|
||||||
|
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||||
$form,
|
$form,
|
||||||
$granted,
|
$granted,
|
||||||
);
|
);
|
||||||
@@ -189,10 +193,11 @@ final class TestableEditController extends EditController
|
|||||||
AdditionalServiceRepository $additionalServiceRepo,
|
AdditionalServiceRepository $additionalServiceRepo,
|
||||||
UserRepository $userRepo,
|
UserRepository $userRepo,
|
||||||
AccommodationBookingService $bookingService,
|
AccommodationBookingService $bookingService,
|
||||||
|
AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||||
private readonly FormInterface $form,
|
private readonly FormInterface $form,
|
||||||
private readonly bool $granted = false,
|
private readonly bool $granted = false,
|
||||||
) {
|
) {
|
||||||
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService);
|
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService, $breakdownCalculator);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Admin\AccommodationBooking\EditPeriodController;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
/** The modal's error handling is shared with SplitPeriodController and covered there. */
|
||||||
|
class EditPeriodControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAValidSubmitUpdatesThePeriodOfItsOwnBooking(): void
|
||||||
|
{
|
||||||
|
$booking = (new AccommodationBooking())
|
||||||
|
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::once())->method('updatePeriod')
|
||||||
|
->with($booking, $period)
|
||||||
|
->willReturn(new ConstraintViolationList());
|
||||||
|
|
||||||
|
$form = $this->createStub(FormInterface::class);
|
||||||
|
$form->method('handleRequest')->willReturn($form);
|
||||||
|
$form->method('isSubmitted')->willReturn(true);
|
||||||
|
$form->method('isValid')->willReturn(true);
|
||||||
|
|
||||||
|
$controller = new TestableEditPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||||
|
|
||||||
|
$response = $controller->index($period, Request::create('/admin/accommodation-booking-period/1/edit', 'POST', server: ['HTTP_HX-Request' => 'true']));
|
||||||
|
|
||||||
|
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||||
|
self::assertSame('success', $controller->flashes[0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||||
|
{
|
||||||
|
$controller = new TestableEditPeriodController(
|
||||||
|
$this->createStub(AccommodationBookingService::class),
|
||||||
|
$this->createStub(LoggerInterface::class),
|
||||||
|
$this->createStub(FormInterface::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
$controller->index(
|
||||||
|
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||||
|
Request::create('/admin/accommodation-booking-period/1/edit'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TestableEditPeriodController extends EditPeriodController
|
||||||
|
{
|
||||||
|
/** @var list<array{type: string, message: mixed}> */
|
||||||
|
public array $flashes = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
AccommodationBookingService $bookingService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
private readonly FormInterface $form,
|
||||||
|
) {
|
||||||
|
parent::__construct($bookingService, $logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||||
|
{
|
||||||
|
return $this->form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||||
|
{
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addFlash(string $type, mixed $message): void
|
||||||
|
{
|
||||||
|
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||||
|
{
|
||||||
|
return '/'.$route.'?'.http_build_query($parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,10 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
*/
|
*/
|
||||||
class PdfControllerTest extends TestCase
|
class PdfControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testAConfirmedBookingIsDownloadedAsPdf(): void
|
#[DataProvider('statuses')]
|
||||||
|
public function testABookingInAnyStatusIsDownloadedAsPdf(AccommodationBookingStatus $status): void
|
||||||
{
|
{
|
||||||
$booking = $this->booking(AccommodationBookingStatus::Confirmed);
|
$booking = $this->booking($status);
|
||||||
|
|
||||||
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
|
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
|
||||||
$generator->expects(self::once())
|
$generator->expects(self::once())
|
||||||
@@ -33,29 +34,14 @@ class PdfControllerTest extends TestCase
|
|||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[DataProvider('nonConfirmedStatuses')]
|
|
||||||
public function testABookingThatIsNotConfirmedCannotBeDownloaded(AccommodationBookingStatus $status): void
|
|
||||||
{
|
|
||||||
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
|
|
||||||
$generator->expects(self::never())->method('createDownloadResponse');
|
|
||||||
|
|
||||||
$controller = new TestablePdfController($generator);
|
|
||||||
|
|
||||||
$response = $controller->index($this->booking($status));
|
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
|
|
||||||
self::assertSame(['warning'], array_column($controller->flashes, 'type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return iterable<string, array{AccommodationBookingStatus}>
|
* @return iterable<string, array{AccommodationBookingStatus}>
|
||||||
*/
|
*/
|
||||||
public static function nonConfirmedStatuses(): iterable
|
public static function statuses(): iterable
|
||||||
{
|
{
|
||||||
yield 'draft' => [AccommodationBookingStatus::Draft];
|
foreach (AccommodationBookingStatus::cases() as $status) {
|
||||||
yield 'open' => [AccommodationBookingStatus::Open];
|
yield $status->name => [$status];
|
||||||
yield 'received' => [AccommodationBookingStatus::Received];
|
}
|
||||||
yield 'discarded' => [AccommodationBookingStatus::Discarded];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAFailingGeneratorRedirectsWithAFlash(): void
|
public function testAFailingGeneratorRedirectsWithAFlash(): void
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Admin\AccommodationBooking\RemovePeriodController;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
class RemovePeriodControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetAsksForConfirmation(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('removePeriod');
|
||||||
|
|
||||||
|
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||||
|
|
||||||
|
$controller->index($this->period(), $this->request());
|
||||||
|
|
||||||
|
self::assertSame('admin/accommodation_booking/modal_remove_period.html.twig', $controller->renderedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmingRemovesThePeriodAndReturnsToTheEditForm(): void
|
||||||
|
{
|
||||||
|
$period = $this->period();
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::once())->method('removePeriod')
|
||||||
|
->with($period->getBooking(), $period)
|
||||||
|
->willReturn(new ConstraintViolationList());
|
||||||
|
|
||||||
|
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||||
|
|
||||||
|
$response = $controller->index($period, $this->request('POST'));
|
||||||
|
|
||||||
|
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||||
|
self::assertSame('success', $controller->flashes[0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnInvalidTokenRemovesNothing(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('removePeriod');
|
||||||
|
|
||||||
|
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||||
|
$controller->tokenValid = false;
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
|
||||||
|
$controller->index($this->period(), $this->request('POST'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||||
|
{
|
||||||
|
$controller = new TestableRemovePeriodController($this->createStub(AccommodationBookingService::class), $this->createStub(LoggerInterface::class));
|
||||||
|
|
||||||
|
$this->expectException(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
$controller->index(
|
||||||
|
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||||
|
$this->request(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(): AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
$booking = (new AccommodationBooking())
|
||||||
|
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||||
|
|
||||||
|
return $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method = 'GET'): Request
|
||||||
|
{
|
||||||
|
return Request::create('/admin/accommodation-booking-period/1/remove', $method, server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TestableRemovePeriodController extends RemovePeriodController
|
||||||
|
{
|
||||||
|
public ?string $renderedView = null;
|
||||||
|
|
||||||
|
public bool $tokenValid = true;
|
||||||
|
|
||||||
|
/** @var list<array{type: string, message: mixed}> */
|
||||||
|
public array $flashes = [];
|
||||||
|
|
||||||
|
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||||
|
{
|
||||||
|
return $this->tokenValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||||
|
{
|
||||||
|
$this->renderedView = $view;
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addFlash(string $type, mixed $message): void
|
||||||
|
{
|
||||||
|
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||||
|
{
|
||||||
|
return '/'.$route.'?'.http_build_query($parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Admin\AccommodationBooking\SplitPeriodController;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers how the modal answers — the split itself is tested in AccommodationBookingServiceTest
|
||||||
|
* and AccommodationBookingTest.
|
||||||
|
*/
|
||||||
|
class SplitPeriodControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetRendersTheModal(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||||
|
|
||||||
|
$controller = $this->buildController($bookingService, $this->form(submitted: false));
|
||||||
|
|
||||||
|
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request());
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASideThatWasAlreadySplitIsNotSplitAgain(): void
|
||||||
|
{
|
||||||
|
// The edit page only offers free sides; this is a page left open while the side was taken.
|
||||||
|
$booking = (new AccommodationBooking())
|
||||||
|
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||||
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||||
|
|
||||||
|
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||||
|
|
||||||
|
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||||
|
|
||||||
|
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||||
|
self::assertNull($controller->renderedView);
|
||||||
|
self::assertSame('error', $controller->flashes[0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAValidSplitSendsTheBrowserBackToTheEditForm(): void
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::once())->method('splitOffPeriod')
|
||||||
|
->with($booking, AccommodationBookingPeriodPosition::Trailing)
|
||||||
|
->willReturn(new ConstraintViolationList());
|
||||||
|
|
||||||
|
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||||
|
|
||||||
|
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Trailing, $this->request('POST'));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'/app_admin_accommodationbooking_edit?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||||
|
$response->headers->get('HX-Redirect'),
|
||||||
|
);
|
||||||
|
self::assertSame('success', $controller->flashes[0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAViolationOfTheBookingIsReportedInTheModal(): void
|
||||||
|
{
|
||||||
|
$violations = new ConstraintViolationList([
|
||||||
|
new ConstraintViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.', null, [], null, 'dateTo', null),
|
||||||
|
]);
|
||||||
|
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||||
|
$bookingService->method('splitOffPeriod')->willReturn($violations);
|
||||||
|
|
||||||
|
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||||
|
|
||||||
|
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||||
|
|
||||||
|
self::assertFalse($response->headers->has('HX-Redirect'), 'the modal stays open on the error');
|
||||||
|
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||||
|
self::assertSame([], $controller->flashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnImplausibleTotalIsReportedInTheModal(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||||
|
$bookingService->method('splitOffPeriod')
|
||||||
|
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
|
||||||
|
|
||||||
|
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||||
|
|
||||||
|
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||||
|
|
||||||
|
self::assertFalse($response->headers->has('HX-Redirect'));
|
||||||
|
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method = 'GET'): Request
|
||||||
|
{
|
||||||
|
return Request::create(
|
||||||
|
'/admin/accommodation-booking/1/period/split/leading?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||||
|
$method,
|
||||||
|
server: ['HTTP_HX-Request' => 'true'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function form(bool $submitted, bool $expectsError = false): FormInterface
|
||||||
|
{
|
||||||
|
$form = $expectsError ? $this->createMock(FormInterface::class) : $this->createStub(FormInterface::class);
|
||||||
|
if ($form instanceof \PHPUnit\Framework\MockObject\MockObject) {
|
||||||
|
$form->expects(self::once())->method('addError');
|
||||||
|
}
|
||||||
|
$form->method('handleRequest')->willReturn($form);
|
||||||
|
$form->method('isSubmitted')->willReturn($submitted);
|
||||||
|
$form->method('isValid')->willReturn(true);
|
||||||
|
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildController(AccommodationBookingService $bookingService, FormInterface $form): TestableSplitPeriodController
|
||||||
|
{
|
||||||
|
return new TestableSplitPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TestableSplitPeriodController extends SplitPeriodController
|
||||||
|
{
|
||||||
|
public ?string $renderedView = null;
|
||||||
|
|
||||||
|
/** @var list<array{type: string, message: mixed}> */
|
||||||
|
public array $flashes = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
AccommodationBookingService $bookingService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
private readonly FormInterface $form,
|
||||||
|
) {
|
||||||
|
parent::__construct($bookingService, $logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||||
|
{
|
||||||
|
return $this->form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||||
|
{
|
||||||
|
$this->renderedView = $view;
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addFlash(string $type, mixed $message): void
|
||||||
|
{
|
||||||
|
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||||
|
{
|
||||||
|
return '/'.$route.'?'.http_build_query($parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Api\ExportController;
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Model\ExportQuery;
|
||||||
|
use App\Service\ProductExportBuilder;
|
||||||
|
use App\Service\TravelExportBuilder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ExportControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPaginatesAndReportsWholeExportFigures(): void
|
||||||
|
{
|
||||||
|
$meta = $this->call(new ExportQuery(1, 5))['meta'];
|
||||||
|
|
||||||
|
self::assertSame(1, $meta['page']);
|
||||||
|
self::assertSame(5, $meta['perPage']);
|
||||||
|
self::assertSame(12, $meta['total'], 'total is the whole export, not this page');
|
||||||
|
self::assertSame(3, $meta['totalPages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountersAreIdenticalOnEveryPage(): void
|
||||||
|
{
|
||||||
|
// Whole-export figures repeated per page, so a consumer can abort after page 1.
|
||||||
|
$first = $this->call(new ExportQuery(1, 5))['meta'];
|
||||||
|
$last = $this->call(new ExportQuery(3, 5))['meta'];
|
||||||
|
|
||||||
|
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) {
|
||||||
|
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConsecutivePagesDoNotOverlap(): void
|
||||||
|
{
|
||||||
|
$first = array_column($this->call(new ExportQuery(1, 5))['data'], 'busProId');
|
||||||
|
$second = array_column($this->call(new ExportQuery(2, 5))['data'], 'busProId');
|
||||||
|
|
||||||
|
self::assertSame([1, 2, 3, 4, 5], $first);
|
||||||
|
self::assertSame([6, 7, 8, 9, 10], $second);
|
||||||
|
self::assertSame([], array_intersect($first, $second));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheLastPageIsShort(): void
|
||||||
|
{
|
||||||
|
$data = $this->call(new ExportQuery(3, 5))['data'];
|
||||||
|
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
self::assertSame([11, 12], array_column($data, 'busProId'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
|
||||||
|
{
|
||||||
|
$body = $this->call(new ExportQuery(99, 5));
|
||||||
|
|
||||||
|
self::assertSame([], $body['data']);
|
||||||
|
self::assertSame(12, $body['meta']['total']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultsToTheFirstPageOfFiveHundred(): void
|
||||||
|
{
|
||||||
|
$body = $this->call();
|
||||||
|
|
||||||
|
self::assertSame(1, $body['meta']['page']);
|
||||||
|
self::assertSame(500, $body['meta']['perPage']);
|
||||||
|
self::assertSame(1, $body['meta']['totalPages']);
|
||||||
|
self::assertCount(12, $body['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCarriesTheBuilderCounters(): void
|
||||||
|
{
|
||||||
|
$meta = $this->call(null, 7, 21)['meta'];
|
||||||
|
|
||||||
|
self::assertSame(7, $meta['skipped']);
|
||||||
|
self::assertSame(21, $meta['pickupsWithoutMasterData']);
|
||||||
|
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
|
||||||
|
self::assertSame('2026-09-28T23:10:15+02:00', $meta['sourceGeneratedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void
|
||||||
|
{
|
||||||
|
$builder = $this->createStub(TravelExportBuilder::class);
|
||||||
|
$builder->method('build')->willReturn(new Export([], 0, 0, new \DateTimeImmutable()));
|
||||||
|
|
||||||
|
$meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->getContent(), true)['meta'];
|
||||||
|
|
||||||
|
self::assertArrayHasKey('sourceGeneratedAt', $meta);
|
||||||
|
self::assertNull($meta['sourceGeneratedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body
|
||||||
|
* shape TravelController::hotelAvailability() uses: the retry logic keys on the status code.
|
||||||
|
*/
|
||||||
|
public function testABuilderFailureIsNotDowngradedToATwoHundred(): void
|
||||||
|
{
|
||||||
|
$builder = $this->createStub(TravelExportBuilder::class);
|
||||||
|
$builder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
|
$this->controller($builder)->travels(new ExportQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProductsUseTheSameEnvelopeAndPaging(): void
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
for ($i = 1; $i <= 3; ++$i) {
|
||||||
|
$rows[] = ['date' => ['busProId' => $i], 'hotel' => ['busProId' => 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$productBuilder = $this->createStub(ProductExportBuilder::class);
|
||||||
|
$productBuilder->method('build')->willReturn(new Export(
|
||||||
|
$rows,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
|
||||||
|
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
|
||||||
|
));
|
||||||
|
|
||||||
|
$response = $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)
|
||||||
|
->products(new ExportQuery(2, 2));
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame([3], array_column(array_column($body['data'], 'date'), 'busProId'));
|
||||||
|
self::assertSame([
|
||||||
|
'page' => 2,
|
||||||
|
'perPage' => 2,
|
||||||
|
'total' => 3,
|
||||||
|
'totalPages' => 2,
|
||||||
|
'skipped' => 2,
|
||||||
|
'pickupsWithoutMasterData' => 5,
|
||||||
|
'sourceGeneratedAt' => '2026-09-28T23:10:15+02:00',
|
||||||
|
'generatedAt' => '2026-09-29T03:20:00+02:00',
|
||||||
|
], $body['meta']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAProductBuilderFailureIsNotDowngradedToATwoHundred(): void
|
||||||
|
{
|
||||||
|
$productBuilder = $this->createStub(ProductExportBuilder::class);
|
||||||
|
$productBuilder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
|
$this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)->products(new ExportQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function call(?ExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
for ($i = 1; $i <= 12; ++$i) {
|
||||||
|
$rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->createStub(TravelExportBuilder::class);
|
||||||
|
$builder->method('build')->willReturn(new Export(
|
||||||
|
$rows,
|
||||||
|
$skipped,
|
||||||
|
$withoutMasterData,
|
||||||
|
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
|
||||||
|
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
|
||||||
|
));
|
||||||
|
|
||||||
|
$response = $this->controller($builder)->travels($query ?? new ExportQuery());
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
|
||||||
|
return json_decode((string) $response->getContent(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController
|
||||||
|
{
|
||||||
|
$controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class));
|
||||||
|
|
||||||
|
// No 'serializer' service registered, so AbstractController::json() falls back to
|
||||||
|
// JsonResponse -- which is what this endpoint produces in production anyway.
|
||||||
|
$controller->setContainer(new Container());
|
||||||
|
|
||||||
|
return $controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Api\LastUpdateController;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
|
|
||||||
|
class LastUpdateControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testReturnsTheMarkerTimestamp(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2026-09-22 11:10:15', $this->timestampFor(
|
||||||
|
"22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingMarkerIsNullRatherThanAServerError(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->timestampFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyMarkerIsNull(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->timestampFor(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnparseableMarkerIsNull(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->timestampFor("gestern\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timestampFor(?string $marker): ?string
|
||||||
|
{
|
||||||
|
$storage = $this->createStub(FilesystemOperator::class);
|
||||||
|
$storage->method('fileExists')->willReturn(null !== $marker);
|
||||||
|
$storage->method('read')->willReturn((string) $marker);
|
||||||
|
|
||||||
|
$controller = new LastUpdateController($storage);
|
||||||
|
$controller->setContainer(new Container());
|
||||||
|
|
||||||
|
$body = json_decode((string) $controller->index()->getContent(), true);
|
||||||
|
self::assertArrayHasKey('timestamp', $body);
|
||||||
|
|
||||||
|
return $body['timestamp'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Api\ExportController;
|
||||||
|
use App\Controller\Api\TravelController;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
|
||||||
|
use Symfony\Component\Routing\Matcher\UrlMatcher;
|
||||||
|
use Symfony\Component\Routing\RequestContext;
|
||||||
|
use Symfony\Component\Routing\Route;
|
||||||
|
use Symfony\Component\Routing\RouteCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
|
||||||
|
*
|
||||||
|
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
|
||||||
|
* `/api/travels/` competes with every date code, and a path falling through to the wrong
|
||||||
|
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
|
||||||
|
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
|
||||||
|
* by reflecting over the real attributes, so this breaks if the declarations change.
|
||||||
|
*/
|
||||||
|
class TravelControllerRoutingTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string, string}>
|
||||||
|
*/
|
||||||
|
public static function pathProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
|
||||||
|
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
|
||||||
|
// the segment across slashes, and this four-segment path is then swallowed by byCode().
|
||||||
|
'availability is not shadowed by the code lookup' => [
|
||||||
|
'/api/travels/12794/225444/2026-12-30/availability',
|
||||||
|
'api_travel_hotel_availability',
|
||||||
|
],
|
||||||
|
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
|
||||||
|
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
|
||||||
|
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
|
||||||
|
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('pathProvider')]
|
||||||
|
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
|
||||||
|
{
|
||||||
|
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
|
||||||
|
|
||||||
|
self::assertSame($expected, $matcher->match($path)['_route']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDateCodeNeverSpansSlashes(): void
|
||||||
|
{
|
||||||
|
$route = $this->collection()->get('api_travel_single_code');
|
||||||
|
|
||||||
|
self::assertNotNull($route);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'.+',
|
||||||
|
(string) $route->getRequirement('dateCode'),
|
||||||
|
'dateCode must keep the single-segment class, or it shadows the routes below it',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The export must stay out of `/api/travels/`, where it would be one date code away from
|
||||||
|
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
|
||||||
|
*/
|
||||||
|
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
|
||||||
|
{
|
||||||
|
foreach ($this->collection()->all() as $name => $route) {
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'/api/travels/export',
|
||||||
|
$route->getPath(),
|
||||||
|
$name.' must not sit in the date code namespace',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflects the controller's own #[Route] attributes, in declaration order.
|
||||||
|
*/
|
||||||
|
private function collection(): RouteCollection
|
||||||
|
{
|
||||||
|
$collection = new RouteCollection();
|
||||||
|
|
||||||
|
foreach ([TravelController::class, ExportController::class] as $controller) {
|
||||||
|
$this->addRoutes($collection, $controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param class-string $controller
|
||||||
|
*/
|
||||||
|
private function addRoutes(RouteCollection $collection, string $controller): void
|
||||||
|
{
|
||||||
|
$class = new \ReflectionClass($controller);
|
||||||
|
|
||||||
|
$prefix = '';
|
||||||
|
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
|
||||||
|
$prefix = (string) $attribute->newInstance()->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||||
|
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
|
||||||
|
$route = $attribute->newInstance();
|
||||||
|
|
||||||
|
$collection->add((string) $route->name, new Route(
|
||||||
|
$prefix.$route->path,
|
||||||
|
$route->defaults,
|
||||||
|
$route->requirements,
|
||||||
|
[],
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
$route->methods,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Entity\Groups;
|
namespace App\Tests\Entity\Groups;
|
||||||
|
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
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\Exception\ImplausibleBookingPriceException;
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||||
|
use Symfony\Component\Validator\Validation;
|
||||||
|
|
||||||
class AccommodationBookingTest extends TestCase
|
class AccommodationBookingTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -209,6 +213,197 @@ class AccommodationBookingTest extends TestCase
|
|||||||
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testACopyCarriesItsOwnSplitOffPeriods(): void
|
||||||
|
{
|
||||||
|
// The periods are part of the quote being varied, but editing them on the copy must
|
||||||
|
// not reach back into the original.
|
||||||
|
$booking = $this->confirmedBooking();
|
||||||
|
$booking->addPeriod((new AccommodationBookingPeriod(
|
||||||
|
AccommodationBookingPeriodPosition::Leading,
|
||||||
|
new \DateTimeImmutable('2026-03-01'),
|
||||||
|
new \DateTimeImmutable('2026-03-02'),
|
||||||
|
))->setLines([['label' => 'Vorausteam', 'amount' => 30000]]));
|
||||||
|
$original = $booking->getPeriods()->first();
|
||||||
|
|
||||||
|
$copy = clone $booking;
|
||||||
|
$copied = $copy->getPeriods()->first();
|
||||||
|
|
||||||
|
self::assertCount(1, $copy->getPeriods());
|
||||||
|
self::assertNotSame($original, $copied);
|
||||||
|
self::assertSame($copy, $copied->getBooking());
|
||||||
|
self::assertNull($copied->getId());
|
||||||
|
self::assertSame($original->getLines(), $copied->getLines());
|
||||||
|
self::assertSame($booking, $original->getBooking());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutPeriodsTheMainPeriodIsTheWholeStay(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-03-01'), $booking->getMainDateFrom());
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-03-08'), $booking->getMainDateTo());
|
||||||
|
self::assertSame(7, $booking->getMainNights());
|
||||||
|
self::assertSame(0, $booking->getPeriodsTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheMainPeriodIsWhatRemainsBetweenTheSplitOffPeriods(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 2000));
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 3000));
|
||||||
|
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-03-03'), $booking->getMainDateFrom());
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-03-07'), $booking->getMainDateTo());
|
||||||
|
self::assertSame(4, $booking->getMainNights());
|
||||||
|
self::assertSame(7, $booking->getNights(), 'the stay itself is unchanged');
|
||||||
|
self::assertSame(5000, $booking->getPeriodsTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPeriodsThatTileTheEdgesOfTheStayAreValid(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||||
|
|
||||||
|
self::assertCount(0, $this->validate($booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMovingTheArrivalAwayFromALeadingPeriodIsReportedOnTheArrival(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-02', '2026-03-08');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||||
|
|
||||||
|
$violations = $this->validate($booking);
|
||||||
|
|
||||||
|
self::assertCount(1, $violations);
|
||||||
|
self::assertSame('dateFrom', $violations[0]->getPropertyPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASecondPeriodOnTheSameSideIsRefused(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||||
|
|
||||||
|
self::assertCount(1, $this->validate($booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMovingTheDepartureAwayFromATrailingPeriodIsReportedOnTheDeparture(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-09');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||||
|
|
||||||
|
$violations = $this->validate($booking);
|
||||||
|
|
||||||
|
self::assertCount(1, $violations);
|
||||||
|
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheGroupMustKeepAtLeastOneNight(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-03');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-02', '2026-03-03', 0));
|
||||||
|
|
||||||
|
$violations = $this->validate($booking);
|
||||||
|
|
||||||
|
self::assertCount(1, $violations);
|
||||||
|
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPeriodWithoutANightIsRefused(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-01', 0));
|
||||||
|
|
||||||
|
self::assertCount(1, $this->validate($booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplittingOffAttachesThePeriodToItsEdgeOfTheStay(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
|
||||||
|
$first = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||||
|
$last = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||||
|
|
||||||
|
self::assertSame(['2026-03-01', '2026-03-03'], $this->range($first));
|
||||||
|
self::assertSame(['2026-03-07', '2026-03-08'], $this->range($last));
|
||||||
|
self::assertSame(4, $booking->getMainNights());
|
||||||
|
self::assertCount(0, $this->validate($booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEachSideCanOnlyBeSplitOnce(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||||
|
|
||||||
|
self::assertFalse($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||||
|
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Trailing));
|
||||||
|
|
||||||
|
$this->expectException(\LogicException::class);
|
||||||
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResizingKeepsAPeriodAtItsEdge(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-10');
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||||
|
|
||||||
|
$booking->resizePeriod($period, 3);
|
||||||
|
|
||||||
|
self::assertSame(['2026-03-07', '2026-03-10'], $this->range($period));
|
||||||
|
self::assertSame(6, $booking->getMainNights());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemovingAPeriodGivesItsNightsBackAndFreesTheSide(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||||
|
|
||||||
|
$booking->removePeriod($period);
|
||||||
|
|
||||||
|
self::assertNull($period->getBooking());
|
||||||
|
self::assertSame(7, $booking->getMainNights());
|
||||||
|
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGrowingAPeriodIntoTheLastNightOfTheGroupIsRefused(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay('2026-03-01', '2026-03-04');
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||||
|
|
||||||
|
$booking->resizePeriod($period, 3);
|
||||||
|
|
||||||
|
self::assertCount(1, $this->validate($booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{string, string} */
|
||||||
|
private function range(AccommodationBookingPeriod $period): array
|
||||||
|
{
|
||||||
|
return [$period->getDateFrom()->format('Y-m-d'), $period->getDateTo()->format('Y-m-d')];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stay(string $from, string $to): AccommodationBooking
|
||||||
|
{
|
||||||
|
return (new AccommodationBooking())
|
||||||
|
->setDateFrom(new \DateTimeImmutable($from))
|
||||||
|
->setDateTo(new \DateTimeImmutable($to));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, int $amount): AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
||||||
|
->setLines([['label' => 'Vorausteam', 'amount' => $amount]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(AccommodationBooking $booking): ConstraintViolationListInterface
|
||||||
|
{
|
||||||
|
return Validation::createValidatorBuilder()
|
||||||
|
->enableAttributeMapping()
|
||||||
|
->getValidator()
|
||||||
|
->validate($booking);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A record that has been all the way through the lifecycle, so a copy of it has the most
|
* A record that has been all the way through the lifecycle, so a copy of it has the most
|
||||||
* to reset.
|
* to reset.
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Model;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AccommodationBookingPeriodDtoTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheEditModalStartsFromWhatThePeriodHolds(): void
|
||||||
|
{
|
||||||
|
$period = (new AccommodationBookingPeriod(
|
||||||
|
AccommodationBookingPeriodPosition::Leading,
|
||||||
|
new \DateTimeImmutable('2026-07-01'),
|
||||||
|
new \DateTimeImmutable('2026-07-03'),
|
||||||
|
))
|
||||||
|
->setLabel('Vorausteam')
|
||||||
|
->setLines([['label' => 'Übernachtung', 'amount' => 25000]]);
|
||||||
|
|
||||||
|
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
|
||||||
|
|
||||||
|
self::assertSame('Vorausteam', $dto->label);
|
||||||
|
self::assertSame(2, $dto->nights);
|
||||||
|
self::assertSame([['label' => 'Übernachtung', 'amount' => 25000]], $dto->linesSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheLinesAreStoredAsAListWhateverKeysTheFormSubmitted(): void
|
||||||
|
{
|
||||||
|
// A collection form keeps the indexes of the rows it was sent, so after a removed row
|
||||||
|
// the keys have gaps — the JSON column must still get a list, not an object.
|
||||||
|
$dto = new AccommodationBookingPeriodDto(1, [
|
||||||
|
2 => new AccommodationBookingPeriodLineDto('Übernachtung', 25000),
|
||||||
|
5 => new AccommodationBookingPeriodLineDto('Verpflegung', 6000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
['label' => 'Übernachtung', 'amount' => 25000],
|
||||||
|
['label' => 'Verpflegung', 'amount' => 6000],
|
||||||
|
], $dto->linesSnapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<reisen>
|
||||||
|
<reise id="1" idbuspro="1127" code="DKIWE">
|
||||||
|
<!-- Two hotels on one date: one row per pair, date-level data shared, hotel-level data not. -->
|
||||||
|
<termin id="1" idbuspro="900" idprodukt="1127" termin="04.12.2026" bis="06.12.2026" code="DKIWE041226" reiseart="F">
|
||||||
|
<selektiongruppe id="1" idbuspro="1" bezeichnung="Reisezeitraum">
|
||||||
|
<selektion id="1" idbuspro="863" bezeichnung="Wintersportreise" />
|
||||||
|
</selektiongruppe>
|
||||||
|
<selektiongruppe id="2" idbuspro="7" bezeichnung="Leistungen Skipass">
|
||||||
|
<selektion id="1" idbuspro="92" bezeichnung="3 Tage Skipass" />
|
||||||
|
<selektion id="2" idbuspro="93" bezeichnung="Skipass Region" />
|
||||||
|
</selektiongruppe>
|
||||||
|
<selektiongruppe id="3" idbuspro="99" bezeichnung="Leistungen Personal" />
|
||||||
|
<text>Davos Klosters</text>
|
||||||
|
<lei_befoerderung>
|
||||||
|
<leistung id="1" idbuspro="193804" unterart="PKW">
|
||||||
|
<text>Eigene Anreise mit Rabatt</text>
|
||||||
|
<preis>-50,00</preis>
|
||||||
|
<status>Frei</status>
|
||||||
|
<richtung>HIN</richtung>
|
||||||
|
</leistung>
|
||||||
|
<!-- A return-leg bus with a guide: the guide must not be taken from it. -->
|
||||||
|
<leistung id="2" idbuspro="193811" unterart="BUS">
|
||||||
|
<text>Bus Rückfahrt</text>
|
||||||
|
<preis>0,00</preis>
|
||||||
|
<status>Buchungsstop</status>
|
||||||
|
<richtung>RUECK</richtung>
|
||||||
|
<zustiegsplanung>
|
||||||
|
<reiseleiter><name>Rück Leiter</name><telefon>000</telefon></reiseleiter>
|
||||||
|
</zustiegsplanung>
|
||||||
|
</leistung>
|
||||||
|
<leistung id="3" idbuspro="193802" unterart="BUS">
|
||||||
|
<text>Bus Hinfahrt</text>
|
||||||
|
<preis>0,00</preis>
|
||||||
|
<status>Frei</status>
|
||||||
|
<richtung>HIN</richtung>
|
||||||
|
<zustiegsplanung>
|
||||||
|
<reiseleiter><name>Hin Leiterin</name><telefon>0171 123</telefon></reiseleiter>
|
||||||
|
</zustiegsplanung>
|
||||||
|
</leistung>
|
||||||
|
<leistung id="4" idbuspro="193820" unterart="BAH">
|
||||||
|
<text>Zug</text>
|
||||||
|
<preis>1.234,50</preis>
|
||||||
|
<status>Anfrage</status>
|
||||||
|
<richtung>HIN</richtung>
|
||||||
|
</leistung>
|
||||||
|
</lei_befoerderung>
|
||||||
|
<lei_sonstiges>
|
||||||
|
<leistung id="1" idbuspro="193805" unterart="SON">
|
||||||
|
<text>Keycard-Pfand (Pflichtabgabe)</text>
|
||||||
|
<preis>5,40</preis>
|
||||||
|
<status>Frei</status>
|
||||||
|
</leistung>
|
||||||
|
<leistung id="2" idbuspro="193830" unterart="EIN">
|
||||||
|
<text>Einzelzimmerzuschlag</text>
|
||||||
|
<status>Frei</status>
|
||||||
|
</leistung>
|
||||||
|
</lei_sonstiges>
|
||||||
|
<hotel id="1" code="DKIWEKT" idbuspro="222931">
|
||||||
|
<!-- Same per-list id as the date-level Keycard entry, different service. -->
|
||||||
|
<lei_sonstiges>
|
||||||
|
<leistung id="1" idbuspro="193840" unterart="SPA">
|
||||||
|
<text>Skipass Hotel</text>
|
||||||
|
<preis>60,00</preis>
|
||||||
|
<status>Frei</status>
|
||||||
|
</leistung>
|
||||||
|
</lei_sonstiges>
|
||||||
|
<zimmer>
|
||||||
|
<preis zimmercode="MBZET " idbuspro_zimmer="74" zimmertext="Bett im Mehrbettzimmer" MinPax="1" MaxPax="1" naechte="2" kat="GR" vpcode="Lt. Auss." idbuspro_vp="2" preis="229,50" status="Frei" verfuegbar="24" />
|
||||||
|
<preis zimmercode="PDGS" idbuspro_zimmer="999" zimmertext="Pseudo" MinPax="1" MaxPax="1" naechte="2" preis="299,00" status="Frei" verfuegbar="0" />
|
||||||
|
<preis zimmercode="3erET" idbuspro_zimmer="98" zimmertext="3er Zimmer" MinPax="3" MaxPax="3" naechte="2" kat="GR" vpcode="HP" idbuspro_vp="3" preis="259,00" status="Buchungsstop" verfuegbar="0" />
|
||||||
|
</zimmer>
|
||||||
|
</hotel>
|
||||||
|
<hotel id="2" code="DKIWEK2" idbuspro="222932">
|
||||||
|
<zimmer>
|
||||||
|
<preis zimmercode="DZ" idbuspro_zimmer="11" zimmertext="Doppelzimmer" MinPax="2" MaxPax="2" naechte="2" kat="DZ" vpcode="HP" idbuspro_vp="3" preis="" status="Anfrage" verfuegbar="" />
|
||||||
|
</zimmer>
|
||||||
|
</hotel>
|
||||||
|
<zustiege>
|
||||||
|
<zustieg id="1" idbuspro="4" zeit="03.12.2026 23:30:00" preis="5,90" />
|
||||||
|
<!-- Bare time after the 23:30 above: rolls onto the next calendar day. -->
|
||||||
|
<zustieg id="2" idbuspro="27" zeit="01:45" />
|
||||||
|
<!-- Referenced but absent from zustiege.xml. -->
|
||||||
|
<zustieg id="3" idbuspro="55" zeit="03.12.2026 21:00:00" preis="0,00" />
|
||||||
|
<!-- No id: the consumer drops it, not the export. -->
|
||||||
|
<zustieg id="4" idbuspro="0" zeit="03.12.2026 20:00:00" />
|
||||||
|
</zustiege>
|
||||||
|
<zustiege_rueck>
|
||||||
|
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
|
||||||
|
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
|
||||||
|
</zustiege_rueck>
|
||||||
|
</termin>
|
||||||
|
<!-- Unparseable date: its hotel row is dropped and counted. -->
|
||||||
|
<termin id="2" idbuspro="901" termin="kaputt" bis="06.12.2026" code="DKIWE000000">
|
||||||
|
<hotel id="1" code="DKIWEKT" idbuspro="222931" />
|
||||||
|
</termin>
|
||||||
|
</reise>
|
||||||
|
</reisen>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<reisen>
|
||||||
|
<reise code="SBW">
|
||||||
|
<!-- Two hotels on one date: the cross product must produce one row per pair. -->
|
||||||
|
<termin id="1" idbuspro="900" idprodukt="1" termin="26.12.2026" bis="30.12.2026" code="SBW261226" reiseart="F">
|
||||||
|
<text> Semesterende - Portes du Soleil </text>
|
||||||
|
<hotel idbuspro="1" code="H1"/>
|
||||||
|
<hotel idbuspro="2" code="H2"/>
|
||||||
|
<zustiege>
|
||||||
|
<zustieg id="1" idbuspro="10" zeit="26.12.2026 23:59:00"/>
|
||||||
|
<!-- Time only, and earlier than the 23:59 above: rolls onto the next calendar day. -->
|
||||||
|
<zustieg id="2" idbuspro="11" zeit="02:15"/>
|
||||||
|
<!-- No zeit at all: the entry survives with both time fields null. -->
|
||||||
|
<zustieg id="3" idbuspro="12"/>
|
||||||
|
<!-- Referenced but absent from zustiege.xml. -->
|
||||||
|
<zustieg id="4" idbuspro="99" zeit="26.12.2026 21:00:00"/>
|
||||||
|
</zustiege>
|
||||||
|
</termin>
|
||||||
|
<!-- Summer date, to pin the +02:00 offset. -->
|
||||||
|
<termin id="2" idbuspro="901" termin="15.07.2027" bis="22.07.2027" code="SBW270715">
|
||||||
|
<text>Sommer</text>
|
||||||
|
<hotel idbuspro="1" code="H1"/>
|
||||||
|
<zustiege>
|
||||||
|
<zustieg id="1" idbuspro="10" zeit="15.07.2027 08:00:00"/>
|
||||||
|
<!-- Time only and after the full datetime above: stays on the departure date. -->
|
||||||
|
<zustieg id="2" idbuspro="11" zeit="09:30"/>
|
||||||
|
</zustiege>
|
||||||
|
</termin>
|
||||||
|
<!-- Hotel absent from hotel.xml: the row is dropped and counted. -->
|
||||||
|
<termin id="3" idbuspro="902" termin="01.02.2027" bis="08.02.2027" code="SBW270201">
|
||||||
|
<text>Ohne Hotel</text>
|
||||||
|
<hotel idbuspro="404" code="GHOST"/>
|
||||||
|
</termin>
|
||||||
|
<!-- Unparseable date: both its hotel rows are dropped and counted. -->
|
||||||
|
<termin id="4" idbuspro="903" termin="kaputt" bis="08.02.2027" code="SBW270301">
|
||||||
|
<text>Kaputtes Datum</text>
|
||||||
|
<hotel idbuspro="1" code="H1"/>
|
||||||
|
<hotel idbuspro="2" code="H2"/>
|
||||||
|
</termin>
|
||||||
|
</reise>
|
||||||
|
</reisen>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<reisen>
|
||||||
|
<reise code="SBW">
|
||||||
|
<termin id="1" idbuspro="900" termin="26.12.2026" bis="30.12.2026" code="SBW261226">
|
||||||
|
<text>Zweiter Treffer</text>
|
||||||
|
<hotel idbuspro="1" code="H1"/>
|
||||||
|
</termin>
|
||||||
|
</reise>
|
||||||
|
</reisen>
|
||||||
@@ -6,6 +6,9 @@ namespace App\Tests\Service;
|
|||||||
|
|
||||||
use App\Entity\Groups\Accommodation;
|
use App\Entity\Groups\Accommodation;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||||
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||||
use App\Service\GroupsPriceCalculator;
|
use App\Service\GroupsPriceCalculator;
|
||||||
@@ -190,6 +193,124 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
|||||||
self::assertArrayNotHasKey('discountedTotal', $breakdown);
|
self::assertArrayNotHasKey('discountedTotal', $breakdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAVersion1SnapshotReadsAsABookingWithoutPeriods(): void
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1);
|
||||||
|
|
||||||
|
$breakdown = $this->createCalculator()->compute($booking);
|
||||||
|
|
||||||
|
self::assertSame([], $breakdown['periods']);
|
||||||
|
self::assertSame(0, $breakdown['periodsTotal']);
|
||||||
|
self::assertSame(12345, $breakdown['discountedTotal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutPeriodsTheCatalogPricesTheWholeStay(): void
|
||||||
|
{
|
||||||
|
// Regression guard: a booking nobody split is priced exactly as before periods existed.
|
||||||
|
$booking = $this->stay();
|
||||||
|
|
||||||
|
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
|
||||||
|
|
||||||
|
self::assertSame(7, $breakdown['nights']);
|
||||||
|
self::assertSame(7 * 100_00, $breakdown['basePrice']);
|
||||||
|
self::assertSame(7 * 100_00, $breakdown['total']);
|
||||||
|
self::assertSame([], $breakdown['periods']);
|
||||||
|
self::assertSame(0, $breakdown['periodsTotal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheCatalogPricesOnlyTheMainPeriodAndThePeriodLinesAreAddedOnTop(): void
|
||||||
|
{
|
||||||
|
$booking = $this->stay();
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-07-07', '2026-07-08', [['label' => 'Abbau', 'amount' => 100_00]]));
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [
|
||||||
|
['label' => 'Übernachtung', 'amount' => 250_00],
|
||||||
|
['label' => 'Verpflegung', 'amount' => 50_00],
|
||||||
|
])->setLabel('Vorausteam'));
|
||||||
|
|
||||||
|
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
|
||||||
|
|
||||||
|
self::assertSame(5, $breakdown['nights']);
|
||||||
|
self::assertSame(5 * 100_00, $breakdown['basePrice']);
|
||||||
|
self::assertSame(400_00, $breakdown['periodsTotal']);
|
||||||
|
self::assertSame(5 * 100_00 + 400_00, $breakdown['total']);
|
||||||
|
self::assertSame([
|
||||||
|
[
|
||||||
|
'position' => 'leading',
|
||||||
|
'label' => 'Vorausteam',
|
||||||
|
'dateFrom' => '2026-07-01',
|
||||||
|
'dateTo' => '2026-07-02',
|
||||||
|
'nights' => 1,
|
||||||
|
'lines' => [
|
||||||
|
['label' => 'Übernachtung', 'amount' => 250_00],
|
||||||
|
['label' => 'Verpflegung', 'amount' => 50_00],
|
||||||
|
],
|
||||||
|
'total' => 300_00,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'position' => 'trailing',
|
||||||
|
'label' => null,
|
||||||
|
'dateFrom' => '2026-07-07',
|
||||||
|
'dateTo' => '2026-07-08',
|
||||||
|
'nights' => 1,
|
||||||
|
'lines' => [['label' => 'Abbau', 'amount' => 100_00]],
|
||||||
|
'total' => 100_00,
|
||||||
|
],
|
||||||
|
], $breakdown['periods']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyTheFixedDiscountReachesThePeriodLines(): void
|
||||||
|
{
|
||||||
|
// The section discounts belong to the catalog-priced main period; the fixed discount
|
||||||
|
// is taken off the grand total, so it may exceed what the main period alone costs.
|
||||||
|
$booking = $this->stay();
|
||||||
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [['label' => 'Vorausteam', 'amount' => 1000_00]]));
|
||||||
|
$booking->setAccommodationDiscount(10);
|
||||||
|
$booking->setTotalDiscountAmount(900_00);
|
||||||
|
$booking->setTotalDiscountLabel('Sonderpreis');
|
||||||
|
$calculator = $this->createCalculator($this->priceRepository());
|
||||||
|
|
||||||
|
$breakdown = $calculator->withDiscounts($booking, $calculator->computeCurrent($booking));
|
||||||
|
|
||||||
|
self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 60_00]], $breakdown['discounts']);
|
||||||
|
self::assertSame(600_00 + 1000_00 - 60_00, $breakdown['discountSubtotal']);
|
||||||
|
self::assertSame(600_00 + 1000_00 - 60_00 - 900_00, $breakdown['discountedTotal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stay(): AccommodationBooking
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('EUR'));
|
||||||
|
$booking->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
||||||
|
$booking->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||||
|
$booking->setPaxCount(20);
|
||||||
|
|
||||||
|
return $booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{label: string, amount: int}> $lines */
|
||||||
|
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, array $lines): AccommodationBookingPeriod
|
||||||
|
{
|
||||||
|
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
||||||
|
->setLines($lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function priceRepository(): AccommodationPriceRepository
|
||||||
|
{
|
||||||
|
$price = (new AccommodationPrice())
|
||||||
|
->setDateFrom(new \DateTimeImmutable('2026-06-01'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('2026-08-31'))
|
||||||
|
->setIncludedPax(20)
|
||||||
|
->setPricePerNight(100_00)
|
||||||
|
->setPriceAdditionalPerson(10_00)
|
||||||
|
->setMinNights(1);
|
||||||
|
|
||||||
|
$priceRepository = $this->createStub(AccommodationPriceRepository::class);
|
||||||
|
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn([$price]);
|
||||||
|
|
||||||
|
return $priceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Tests\Service;
|
|||||||
|
|
||||||
use App\Entity\Groups\Accommodation;
|
use App\Entity\Groups\Accommodation;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||||
use App\Service\AccommodationBookingPdfGenerator;
|
use App\Service\AccommodationBookingPdfGenerator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -27,6 +28,37 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
|||||||
self::assertSame(1, $this->pageCount($pdf));
|
self::assertSame(1, $this->pageCount($pdf));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testADocumentWithSplitOffPeriodsStillFitsOnASingleA4Page(): void
|
||||||
|
{
|
||||||
|
$booking = $this->booking();
|
||||||
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1)->setLabel('Vorausteam')->setLines([
|
||||||
|
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
|
||||||
|
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
|
||||||
|
]);
|
||||||
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1)->setLines([
|
||||||
|
['label' => 'Abbau', 'amount' => 10000],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$breakdown = [
|
||||||
|
...$this->breakdown(),
|
||||||
|
'nights' => 3,
|
||||||
|
'periods' => [
|
||||||
|
['position' => 'leading', 'label' => 'Vorausteam', 'dateFrom' => '2026-07-06', 'dateTo' => '2026-07-07', 'nights' => 1, 'lines' => [
|
||||||
|
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
|
||||||
|
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
|
||||||
|
], 'total' => 31000],
|
||||||
|
['position' => 'trailing', 'dateFrom' => '2026-07-10', 'dateTo' => '2026-07-11', 'nights' => 1, 'lines' => [
|
||||||
|
['label' => 'Abbau', 'amount' => 10000],
|
||||||
|
], 'total' => 10000],
|
||||||
|
],
|
||||||
|
'periodsTotal' => 41000,
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdf = $this->createGenerator($breakdown)->render($booking);
|
||||||
|
|
||||||
|
self::assertSame(1, $this->pageCount($pdf));
|
||||||
|
}
|
||||||
|
|
||||||
public function testTheDownloadIsAnAttachmentNamedAfterArrivalDateAndHotel(): void
|
public function testTheDownloadIsAnAttachmentNamedAfterArrivalDateAndHotel(): void
|
||||||
{
|
{
|
||||||
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
|
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
|
||||||
@@ -36,17 +68,16 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
|||||||
self::assertStringContainsString('2026-07-06 BERGBLICK Buchung.pdf', (string) $response->headers->get('Content-Disposition'));
|
self::assertStringContainsString('2026-07-06 BERGBLICK Buchung.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testADocumentForAnUnacceptedOfferKeepsTheSameFilename(): void
|
public function testADocumentForAnUnacceptedOfferIsNamedAsAnOffer(): void
|
||||||
{
|
{
|
||||||
// 'Buchung' in the filename is a literal, not the record label: the office files
|
// The filename follows the record label, like the document's heading.
|
||||||
// these documents by date and house, and a second spelling would break that order.
|
|
||||||
$booking = $this->booking();
|
$booking = $this->booking();
|
||||||
$booking->setAcceptedAt(null);
|
$booking->setAcceptedAt(null);
|
||||||
$booking->setConfirmedAt(null);
|
$booking->setConfirmedAt(null);
|
||||||
|
|
||||||
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($booking);
|
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($booking);
|
||||||
|
|
||||||
self::assertStringContainsString('2026-07-06 BERGBLICK Buchung.pdf', (string) $response->headers->get('Content-Disposition'));
|
self::assertStringContainsString('2026-07-06 BERGBLICK Angebot.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testABookingWithoutPricesCannotBeRendered(): void
|
public function testABookingWithoutPricesCannotBeRendered(): void
|
||||||
@@ -122,6 +153,8 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
|||||||
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
|
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
|
||||||
'servicesPrice' => 28800,
|
'servicesPrice' => 28800,
|
||||||
'runningCosts' => 16000,
|
'runningCosts' => 16000,
|
||||||
|
'periods' => [],
|
||||||
|
'periodsTotal' => 0,
|
||||||
'total' => 844800,
|
'total' => 844800,
|
||||||
'currency' => 'EUR',
|
'currency' => 'EUR',
|
||||||
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
|
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ use App\Entity\Groups\Accommodation;
|
|||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Entity\Groups\AccommodationPrice;
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
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\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||||
|
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||||
use App\Model\AccommodationBookingQueryParams;
|
use App\Model\AccommodationBookingQueryParams;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Repository\Groups\AccommodationRepository;
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
@@ -951,7 +954,86 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
self::assertSame($breakdown, $booking->getPriceBreakdown(), 'the derived keys must not be persisted');
|
self::assertSame($breakdown, $booking->getPriceBreakdown(), 'the derived keys must not be persisted');
|
||||||
self::assertSame(10895, $booking->getTotalPrice());
|
self::assertSame(10895, $booking->getTotalPrice());
|
||||||
self::assertSame('CHF', $booking->getPricingCurrency());
|
self::assertSame('CHF', $booking->getPricingCurrency());
|
||||||
self::assertSame(1, $booking->getPricingVersion());
|
self::assertSame(2, $booking->getPricingVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplittingOffAPeriodRepricesAndWritesTheBooking(): void
|
||||||
|
{
|
||||||
|
$booking = $this->periodBooking();
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::once())->method('flush');
|
||||||
|
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||||
|
$breakdownCalculator->expects(self::once())->method('computeCurrent')->with($booking)->willReturn(null);
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
|
||||||
|
$violations = $service->splitOffPeriod(
|
||||||
|
$booking,
|
||||||
|
AccommodationBookingPeriodPosition::Leading,
|
||||||
|
new AccommodationBookingPeriodDto(1, [3 => new AccommodationBookingPeriodLineDto('Übernachtung', 30000)], 'Vorausteam'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(0, $violations);
|
||||||
|
self::assertSame('Vorausteam', $booking->getLeadingPeriod()?->getLabel());
|
||||||
|
self::assertSame([['label' => 'Übernachtung', 'amount' => 30000]], $booking->getLeadingPeriod()?->getLines());
|
||||||
|
self::assertSame(6, $booking->getMainNights());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASplitThatLeavesTheGroupNoNightIsNeitherPricedNorWritten(): void
|
||||||
|
{
|
||||||
|
$booking = $this->periodBooking();
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::never())->method('flush');
|
||||||
|
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||||
|
$breakdownCalculator->expects(self::never())->method('computeCurrent');
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
|
||||||
|
$violations = $service->splitOffPeriod($booking, AccommodationBookingPeriodPosition::Trailing, new AccommodationBookingPeriodDto(7));
|
||||||
|
|
||||||
|
self::assertCount(1, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatingAPeriodChangesItsLengthAndLines(): void
|
||||||
|
{
|
||||||
|
$booking = $this->periodBooking();
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::once())->method('flush');
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||||
|
$violations = $service->updatePeriod($booking, $period, new AccommodationBookingPeriodDto(2, [new AccommodationBookingPeriodLineDto('Abbau', 5000)]));
|
||||||
|
|
||||||
|
self::assertCount(0, $violations);
|
||||||
|
self::assertSame(2, $period->getNights());
|
||||||
|
self::assertSame([['label' => 'Abbau', 'amount' => 5000]], $period->getLines());
|
||||||
|
self::assertSame(5, $booking->getMainNights());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemovingAPeriodGivesItsNightsBackToTheGroup(): void
|
||||||
|
{
|
||||||
|
$booking = $this->periodBooking();
|
||||||
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::once())->method('flush');
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||||
|
$violations = $service->removePeriod($booking, $period);
|
||||||
|
|
||||||
|
self::assertCount(0, $violations);
|
||||||
|
self::assertTrue($booking->getPeriods()->isEmpty());
|
||||||
|
self::assertSame(7, $booking->getMainNights());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function periodBooking(): AccommodationBooking
|
||||||
|
{
|
||||||
|
return (new AccommodationBooking())
|
||||||
|
->setAccommodation((new Accommodation())->setCalendarCode('HOTEL'))
|
||||||
|
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('2026-07-08'))
|
||||||
|
->setPaxCount(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void
|
public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void
|
||||||
|
|||||||
@@ -48,6 +48,97 @@ class GroupsPriceCalculatorTest extends TestCase
|
|||||||
self::assertSame(50, $result['undersubscriptionThreshold']);
|
self::assertSame(50, $result['undersubscriptionThreshold']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheShortTermSurchargeIsJudgedOnTheWholeStay(): void
|
||||||
|
{
|
||||||
|
// Three nights priced for the group, but one night split off a four-night stay: the
|
||||||
|
// stay is not short-term, so the three nights must not pick up the 10 % surcharge.
|
||||||
|
$price = $this->price('2026-06-01', '2026-08-31', 20);
|
||||||
|
|
||||||
|
$split = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-01', 4, [$price]);
|
||||||
|
$unsplit = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-02', 3, [$price]);
|
||||||
|
|
||||||
|
self::assertSame(0, $split['shortTermSurcharge']);
|
||||||
|
self::assertSame(3, $split['nights']);
|
||||||
|
self::assertSame((int) round(($unsplit['basePrice'] + $unsplit['additionalPersonsPrice']) * 10 / 100), $unsplit['shortTermSurcharge']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheShortTermSurchargeOfAShortStayIsChargedOnThePricedNightsOnly(): void
|
||||||
|
{
|
||||||
|
// A three-night stay with one night split off: 10 % on the two nights the group pays
|
||||||
|
// for, not 20 % for a two-night stay and not on the split-off night's lines.
|
||||||
|
$price = $this->price('2026-06-01', '2026-08-31', 20);
|
||||||
|
|
||||||
|
$result = $this->calculateMain('2026-07-02', '2026-07-04', '2026-07-01', 3, [$price]);
|
||||||
|
|
||||||
|
self::assertSame(2 * 100_00, $result['basePrice']);
|
||||||
|
self::assertSame((int) round(($result['basePrice'] + $result['additionalPersonsPrice']) * 10 / 100), $result['shortTermSurcharge']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheIncludedPaxFloorComesFromTheStaysFirstNight(): void
|
||||||
|
{
|
||||||
|
// The stay starts in a price period with a floor of 30; the main group arrives a day
|
||||||
|
// later, in one with a floor of 50. Splitting the first night off must not raise the
|
||||||
|
// floor the 20 people are billed at.
|
||||||
|
$prices = [
|
||||||
|
$this->price('2026-06-01', '2026-07-01', 30),
|
||||||
|
$this->price('2026-07-02', '2026-08-31', 50),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->calculateMain('2026-07-02', '2026-07-08', '2026-07-01', 7, $prices, paxCount: 20, boardServicePrice: 1500);
|
||||||
|
|
||||||
|
self::assertSame(30, $result['includedPax']);
|
||||||
|
self::assertSame(30, $result['effectivePax']);
|
||||||
|
self::assertSame(1500 * 30 * 6, $result['boardPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AccommodationPrice> $prices
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function calculateMain(string $from, string $to, string $stayFrom, int $stayNights, array $prices, int $paxCount = 20, ?int $boardServicePrice = null): array
|
||||||
|
{
|
||||||
|
$dateFrom = new \DateTimeImmutable($from);
|
||||||
|
$dateTo = new \DateTimeImmutable($to);
|
||||||
|
|
||||||
|
return $this->calculator()->calculateFromSnapshots(
|
||||||
|
$paxCount,
|
||||||
|
0,
|
||||||
|
$dateFrom->diff($dateTo)->days,
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
new \DateTimeImmutable($stayFrom),
|
||||||
|
$stayNights,
|
||||||
|
$prices,
|
||||||
|
$boardServicePrice,
|
||||||
|
[],
|
||||||
|
'EUR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function price(string $from, string $to, int $includedPax): AccommodationPrice
|
||||||
|
{
|
||||||
|
return (new AccommodationPrice())
|
||||||
|
->setDateFrom(new \DateTimeImmutable($from))
|
||||||
|
->setDateTo(new \DateTimeImmutable($to))
|
||||||
|
->setIncludedPax($includedPax)
|
||||||
|
->setPricePerNight(100_00)
|
||||||
|
->setPriceAdditionalPerson(10_00)
|
||||||
|
->setMinNights(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculator(): GroupsPriceCalculator
|
||||||
|
{
|
||||||
|
return new GroupsPriceCalculator(new PriceTimelineBuilder(), [
|
||||||
|
'runningCostsEur' => 0,
|
||||||
|
'runningCostsChf' => 0,
|
||||||
|
'undersubscription30Eur' => 5.0,
|
||||||
|
'undersubscription30Chf' => 6.0,
|
||||||
|
'undersubscription40Eur' => 2.5,
|
||||||
|
'undersubscription40Chf' => 3.0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -64,21 +155,14 @@ class GroupsPriceCalculatorTest extends TestCase
|
|||||||
->setPriceAdditionalPerson(10_00)
|
->setPriceAdditionalPerson(10_00)
|
||||||
->setMinNights(1);
|
->setMinNights(1);
|
||||||
|
|
||||||
$calculator = new GroupsPriceCalculator(new PriceTimelineBuilder(), [
|
return $this->calculator()->calculateFromSnapshots(
|
||||||
'runningCostsEur' => 0,
|
|
||||||
'runningCostsChf' => 0,
|
|
||||||
'undersubscription30Eur' => 5.0,
|
|
||||||
'undersubscription30Chf' => 6.0,
|
|
||||||
'undersubscription40Eur' => 2.5,
|
|
||||||
'undersubscription40Chf' => 3.0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $calculator->calculateFromSnapshots(
|
|
||||||
$paxCount,
|
$paxCount,
|
||||||
0,
|
0,
|
||||||
self::NIGHTS,
|
self::NIGHTS,
|
||||||
$dateFrom,
|
$dateFrom,
|
||||||
$dateTo,
|
$dateTo,
|
||||||
|
$dateFrom,
|
||||||
|
self::NIGHTS,
|
||||||
[$price],
|
[$price],
|
||||||
$boardServicePrice,
|
$boardServicePrice,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Pickup;
|
||||||
|
use App\BusProNet\XmlLoader\PickupLoader;
|
||||||
|
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
|
||||||
|
use App\BusProNet\XmlParser\ProductExportParser;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Service\ProductExportBuilder;
|
||||||
|
use League\Flysystem\DirectoryListing;
|
||||||
|
use League\Flysystem\FileAttributes;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
|
class ProductExportBuilderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
|
||||||
|
{
|
||||||
|
$export = $this->build();
|
||||||
|
|
||||||
|
$keys = array_map(
|
||||||
|
static fn (array $row): string => $row['date']['busProId'].'-'.$row['hotel']['busProId'],
|
||||||
|
$export->rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['900-222931', '900-222932'], $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCarriesIdentityFromTheTravelXmlAlone(): void
|
||||||
|
{
|
||||||
|
$row = $this->rowFor('900-222931');
|
||||||
|
|
||||||
|
self::assertSame(['code' => 'DKIWE', 'busProId' => 1127], $row['product']);
|
||||||
|
self::assertSame([
|
||||||
|
'code' => 'DKIWE041226',
|
||||||
|
'busProId' => 900,
|
||||||
|
'from' => '2026-12-04',
|
||||||
|
'to' => '2026-12-06',
|
||||||
|
'type' => 'F',
|
||||||
|
], $row['date']);
|
||||||
|
self::assertSame(['code' => 'DKIWEKT', 'busProId' => 222931], $row['hotel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDropsAndCountsRowsOfAnUnparseableDate(): void
|
||||||
|
{
|
||||||
|
self::assertSame(1, $this->build()->skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmitsEveryRoomButThePseudoRoomVerbatim(): void
|
||||||
|
{
|
||||||
|
$rooms = $this->rowFor('900-222931')['rooms'];
|
||||||
|
|
||||||
|
self::assertSame(['MBZET', '3erET'], array_column($rooms, 'code'), 'PDGS is not a room; the code is trimmed');
|
||||||
|
self::assertSame([
|
||||||
|
'busProId' => 74,
|
||||||
|
'code' => 'MBZET',
|
||||||
|
'label' => 'Bett im Mehrbettzimmer',
|
||||||
|
'category' => 'GR',
|
||||||
|
'minPax' => 1,
|
||||||
|
'maxPax' => 1,
|
||||||
|
'nights' => 2,
|
||||||
|
'price' => 229.5,
|
||||||
|
'available' => 24,
|
||||||
|
'status' => 'Frei',
|
||||||
|
'boardCode' => 'Lt. Auss.',
|
||||||
|
'boardBusProId' => 2,
|
||||||
|
], $rooms[0]);
|
||||||
|
|
||||||
|
// Buchungsstop rooms reach the consumer: "ausgebucht" is its call.
|
||||||
|
self::assertSame('Buchungsstop', $rooms[1]['status']);
|
||||||
|
self::assertSame(3, $rooms[1]['maxPax'], 'MaxPax is read despite its casing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingRoomPriceOrAvailabilityIsNullNotZero(): void
|
||||||
|
{
|
||||||
|
$room = $this->rowFor('900-222932')['rooms'][0];
|
||||||
|
|
||||||
|
self::assertNull($room['price']);
|
||||||
|
self::assertNull($room['available']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThePseudoRoomBecomesThePseudoPriceOfItsHotelOnly(): void
|
||||||
|
{
|
||||||
|
self::assertSame(299.0, $this->rowFor('900-222931')['pseudoPrice']);
|
||||||
|
self::assertNull($this->rowFor('900-222932')['pseudoPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsTheThreeServiceListsApartAndUnfiltered(): void
|
||||||
|
{
|
||||||
|
$first = $this->rowFor('900-222931');
|
||||||
|
$second = $this->rowFor('900-222932');
|
||||||
|
|
||||||
|
self::assertSame([193804, 193811, 193802, 193820], array_column($first['transportServices'], 'busProId'), 'RUECK and Buchungsstop are kept');
|
||||||
|
self::assertSame([193805, 193830], array_column($first['dateServices'], 'busProId'), 'EIN is kept; hotel services do not leak in');
|
||||||
|
self::assertSame([193840], array_column($first['hotelServices'], 'busProId'));
|
||||||
|
self::assertSame([], $second['hotelServices'], 'a hotel without its own list gets an empty one, not the date-level one');
|
||||||
|
self::assertSame($first['dateServices'], $second['dateServices']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicesAreNumericAndNullWhereAbsent(): void
|
||||||
|
{
|
||||||
|
$row = $this->rowFor('900-222931');
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'busProId' => 193804,
|
||||||
|
'subType' => 'PKW',
|
||||||
|
'label' => 'Eigene Anreise mit Rabatt',
|
||||||
|
'price' => -50.0,
|
||||||
|
'status' => 'Frei',
|
||||||
|
'direction' => 'HIN',
|
||||||
|
], $row['transportServices'][0]);
|
||||||
|
self::assertSame(1234.5, $row['transportServices'][3]['price'], 'thousands separator');
|
||||||
|
|
||||||
|
self::assertNull($row['dateServices'][0]['direction'], 'general services have no direction');
|
||||||
|
self::assertNull($row['dateServices'][1]['price'], 'a missing price is null, not 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmitsEverySelectionGroupInSourceOrder(): void
|
||||||
|
{
|
||||||
|
$groups = $this->rowFor('900-222931')['selectionGroups'];
|
||||||
|
|
||||||
|
self::assertSame([1, 7, 99], array_column($groups, 'busProId'));
|
||||||
|
self::assertSame('Leistungen Skipass', $groups[1]['label']);
|
||||||
|
self::assertSame([
|
||||||
|
['busProId' => 92, 'label' => '3 Tage Skipass'],
|
||||||
|
['busProId' => 93, 'label' => 'Skipass Region'],
|
||||||
|
], $groups[1]['selections']);
|
||||||
|
self::assertSame([], $groups[2]['selections']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTakesTheGuideFromTheOutboundBus(): void
|
||||||
|
{
|
||||||
|
self::assertSame(['name' => 'Hin Leiterin', 'phone' => '0171 123'], $this->rowFor('900-222931')['guide']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheGuideIsNullWithoutAnOutboundOne(): void
|
||||||
|
{
|
||||||
|
$xml = str_replace('<name>Hin Leiterin</name>', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'));
|
||||||
|
$xml = preg_replace('#<leistung id="3" idbuspro="193802".*?</leistung>#s', '', $xml);
|
||||||
|
|
||||||
|
self::assertNull($this->build((string) $xml)->rows[0]['guide']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmitsPickupsVerbatimWithResolvedDatetimes(): void
|
||||||
|
{
|
||||||
|
$pickups = $this->rowFor('900-222931')['pickups'];
|
||||||
|
|
||||||
|
self::assertSame([4, 27, 55, 0], array_column($pickups, 'busProId'), 'source order, id 0 kept');
|
||||||
|
self::assertSame([
|
||||||
|
'busProId' => 4,
|
||||||
|
'code' => 'Z4',
|
||||||
|
'city' => 'Stadt 4',
|
||||||
|
'street' => 'Strasse 4',
|
||||||
|
'price' => 5.9,
|
||||||
|
'datetime' => '2026-12-03T23:30:00',
|
||||||
|
], $pickups[0]);
|
||||||
|
self::assertNull($pickups[1]['price'], 'an absent price is null');
|
||||||
|
self::assertSame('2026-12-04T01:45:00', $pickups[1]['datetime']);
|
||||||
|
self::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0');
|
||||||
|
self::assertNull($pickups[2]['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
|
||||||
|
{
|
||||||
|
// DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.
|
||||||
|
$pickups = $this->pickupsFor('28.03.2027', <<<'XML'
|
||||||
|
<zustieg id="1" idbuspro="4" zeit="00:30" />
|
||||||
|
<zustieg id="2" idbuspro="27" zeit="02:45" />
|
||||||
|
<zustieg id="3" idbuspro="3" zeit="27.03.2027 22:00:00" />
|
||||||
|
<zustieg id="4" idbuspro="89" zeit="01:30" />
|
||||||
|
<zustieg id="5" idbuspro="86" zeit="27.03.2027 23:30:00" />
|
||||||
|
<zustieg id="6" idbuspro="74" zeit="03:45" />
|
||||||
|
<zustieg id="7" idbuspro="262" zeit="04:45" />
|
||||||
|
XML);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'2027-03-28T00:30:00',
|
||||||
|
'2027-03-28T02:45:00',
|
||||||
|
'2027-03-27T22:00:00',
|
||||||
|
'2027-03-28T01:30:00',
|
||||||
|
'2027-03-27T23:30:00',
|
||||||
|
'2027-03-28T03:45:00',
|
||||||
|
'2027-03-28T04:45:00',
|
||||||
|
], array_column($pickups, 'datetime'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
|
||||||
|
{
|
||||||
|
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
|
||||||
|
$pickups = $this->pickupsFor('30.10.2027', <<<'XML'
|
||||||
|
<zustieg id="1" idbuspro="4" zeit="30.10.2027 23:30:00" />
|
||||||
|
<zustieg id="2" idbuspro="27" zeit="02:15" />
|
||||||
|
<zustieg id="3" idbuspro="55" zeit="02:59" />
|
||||||
|
<zustieg id="4" idbuspro="0" zeit="31.10.2027 02:30:00" />
|
||||||
|
XML);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'2027-10-30T23:30:00',
|
||||||
|
'2027-10-31T02:15:00',
|
||||||
|
'2027-10-31T02:59:00',
|
||||||
|
'2027-10-31T02:30:00',
|
||||||
|
], array_column($pickups, 'datetime'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotLevelBoardingPointPrices(): void
|
||||||
|
{
|
||||||
|
// Point 27 is priced on the return leg only. The booking path levels that onto the
|
||||||
|
// outbound entry; the website publishes the outbound price as exported.
|
||||||
|
self::assertNull($this->rowFor('900-222931')['pickups'][1]['price']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountsPickupsWithoutMasterDataButNotTheIdlessOne(): void
|
||||||
|
{
|
||||||
|
self::assertSame(1, $this->build()->pickupsWithoutMasterData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCarriesTheSourceExportTimestamp(): void
|
||||||
|
{
|
||||||
|
$export = $this->build(null, "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n");
|
||||||
|
|
||||||
|
self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function rowFor(string $key): array
|
||||||
|
{
|
||||||
|
foreach ($this->build()->rows as $row) {
|
||||||
|
if ($row['date']['busProId'].'-'.$row['hotel']['busProId'] === $key) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('No row for '.$key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixture's first date moved to $dateFrom, with $zustiege as its boarding list.
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function pickupsFor(string $dateFrom, string $zustiege): array
|
||||||
|
{
|
||||||
|
$xml = (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
|
||||||
|
$xml = str_replace('termin="04.12.2026"', 'termin="'.$dateFrom.'"', $xml);
|
||||||
|
$xml = (string) preg_replace('#<zustiege>.*?</zustiege>#s', '<zustiege>'.$zustiege.'</zustiege>', $xml);
|
||||||
|
|
||||||
|
return $this->build($xml)->rows[0]['pickups'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function build(?string $xml = null, ?string $marker = null): Export
|
||||||
|
{
|
||||||
|
$xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
|
||||||
|
|
||||||
|
$filesystem = $this->createStub(FilesystemOperator::class);
|
||||||
|
$filesystem->method('listContents')->willReturn(new DirectoryListing([new FileAttributes('Ziel_1127.xml')]));
|
||||||
|
$filesystem->method('read')->willReturnCallback(
|
||||||
|
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $xml
|
||||||
|
);
|
||||||
|
$filesystem->method('fileExists')->willReturnCallback(
|
||||||
|
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
|
||||||
|
);
|
||||||
|
|
||||||
|
$pickupLoader = $this->createStub(PickupLoader::class);
|
||||||
|
$pickupLoader->method('loadAll')->willReturn([
|
||||||
|
4 => $this->pickup(4),
|
||||||
|
27 => $this->pickup(27),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (new ProductExportBuilder(
|
||||||
|
$pickupLoader,
|
||||||
|
// Levelling on, to prove the export does not go through it.
|
||||||
|
new TravelParser(new AgeConstraintParserRegistry([]), true),
|
||||||
|
new ProductExportParser(),
|
||||||
|
$filesystem,
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
new NullLogger(),
|
||||||
|
))->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickup(int $id): Pickup
|
||||||
|
{
|
||||||
|
$pickup = new Pickup();
|
||||||
|
$pickup->id = $id;
|
||||||
|
$pickup->code = 'Z'.$id;
|
||||||
|
$pickup->city = 'Stadt '.$id;
|
||||||
|
$pickup->street = 'Strasse '.$id;
|
||||||
|
|
||||||
|
return $pickup;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Hotel;
|
||||||
|
use App\BusProNet\Model\Pickup;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\PickupLoader;
|
||||||
|
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Service\TravelExportBuilder;
|
||||||
|
use League\Flysystem\DirectoryListing;
|
||||||
|
use League\Flysystem\FileAttributes;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
|
class TravelExportBuilderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
|
||||||
|
{
|
||||||
|
$rows = $this->build()->rows;
|
||||||
|
|
||||||
|
$keys = array_map(
|
||||||
|
static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['900-1', '900-2', '901-1'], $keys);
|
||||||
|
self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCarriesTravelAndHotelMasterData(): void
|
||||||
|
{
|
||||||
|
$row = $this->rowFor('900-1');
|
||||||
|
|
||||||
|
self::assertSame(900, $row['busProId']);
|
||||||
|
self::assertIsInt($row['busProId']);
|
||||||
|
self::assertSame('SBW261226', $row['code']);
|
||||||
|
self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed');
|
||||||
|
self::assertSame('2026-12-26', $row['dateFrom']);
|
||||||
|
self::assertSame('2026-12-30', $row['dateTo']);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'busProId' => 1,
|
||||||
|
'code' => 'H1',
|
||||||
|
'name' => 'Hotel Eins',
|
||||||
|
'costUnit' => '84 85',
|
||||||
|
'country' => 'CH',
|
||||||
|
'countryCode' => 'ch',
|
||||||
|
], $row['hotel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void
|
||||||
|
{
|
||||||
|
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
||||||
|
|
||||||
|
self::assertSame('23:59', $pickups[10]['time']);
|
||||||
|
self::assertSame('2026-12-26T23:59:00', $pickups[10]['datetime']);
|
||||||
|
|
||||||
|
// Time only, so it defaults to the departure date -- which would put it before the
|
||||||
|
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
|
||||||
|
self::assertSame('02:15', $pickups[11]['time']);
|
||||||
|
self::assertSame('2026-12-27T02:15:00', $pickups[11]['datetime']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmitsNoOffsetInSummerEither(): void
|
||||||
|
{
|
||||||
|
$pickups = $this->rowFor('901-1')['pickups'];
|
||||||
|
|
||||||
|
self::assertSame('2027-07-15T08:00:00', $pickups[0]['datetime']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
|
||||||
|
{
|
||||||
|
// Berlin skips 02:00-02:59 on 28.03.2027; BusPro still plans a stop at 02:45.
|
||||||
|
$pickups = $this->build(<<<'XML'
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<reisen><reise><termin idbuspro="1" termin="28.03.2027" bis="03.04.2027"><hotel idbuspro="1"/><zustiege>
|
||||||
|
<zustieg idbuspro="10" zeit="27.03.2027 23:30:00"/>
|
||||||
|
<zustieg idbuspro="11" zeit="02:45"/>
|
||||||
|
<zustieg idbuspro="12" zeit="03:45"/>
|
||||||
|
</zustiege></termin></reise></reisen>
|
||||||
|
XML)->rows[0]['pickups'];
|
||||||
|
|
||||||
|
self::assertSame(['23:30', '02:45', '03:45'], array_column($pickups, 'time'));
|
||||||
|
self::assertSame(['2027-03-27T23:30:00', '2027-03-28T02:45:00', '2027-03-28T03:45:00'], array_column($pickups, 'datetime'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
|
||||||
|
{
|
||||||
|
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
|
||||||
|
$pickups = $this->build(<<<'XML'
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<reisen><reise><termin idbuspro="1" termin="30.10.2027" bis="06.11.2027"><hotel idbuspro="1"/><zustiege>
|
||||||
|
<zustieg idbuspro="10" zeit="30.10.2027 23:30:00"/>
|
||||||
|
<zustieg idbuspro="11" zeit="02:15"/>
|
||||||
|
<zustieg idbuspro="12" zeit="02:59"/>
|
||||||
|
</zustiege></termin></reise></reisen>
|
||||||
|
XML)->rows[0]['pickups'];
|
||||||
|
|
||||||
|
self::assertSame(['2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00'], array_column($pickups, 'datetime'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
|
||||||
|
{
|
||||||
|
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
|
||||||
|
|
||||||
|
self::assertSame('2027-07-15T09:30:00', $pickups[11]['datetime'], 'no roll when the order already holds');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
|
||||||
|
{
|
||||||
|
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
||||||
|
|
||||||
|
self::assertArrayHasKey(12, $pickups);
|
||||||
|
self::assertNull($pickups[12]['time']);
|
||||||
|
self::assertNull($pickups[12]['datetime']);
|
||||||
|
self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void
|
||||||
|
{
|
||||||
|
$export = $this->build();
|
||||||
|
$pickups = array_column($export->rows[0]['pickups'], null, 'busProId');
|
||||||
|
|
||||||
|
// Dropping it would hide a real departure point from the disposition UI.
|
||||||
|
self::assertArrayHasKey(99, $pickups);
|
||||||
|
self::assertNull($pickups[99]['code']);
|
||||||
|
self::assertNull($pickups[99]['city']);
|
||||||
|
self::assertNull($pickups[99]['street']);
|
||||||
|
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
|
||||||
|
self::assertSame('2026-12-26T21:00:00', $pickups[99]['datetime']);
|
||||||
|
|
||||||
|
// Counted once, not once per row that references it.
|
||||||
|
self::assertSame(1, $export->pickupsWithoutMasterData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupsKeepSourceOrder(): void
|
||||||
|
{
|
||||||
|
$order = array_column($this->rowFor('900-1')['pickups'], 'busProId');
|
||||||
|
|
||||||
|
self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void
|
||||||
|
{
|
||||||
|
$rows = $this->build('<?xml version="1.0"?><reisen><reise><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027"><hotel idbuspro="1"/></termin></reise></reisen>')->rows;
|
||||||
|
|
||||||
|
self::assertSame([], $rows[0]['pickups']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void
|
||||||
|
{
|
||||||
|
$export = $this->build();
|
||||||
|
|
||||||
|
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId']));
|
||||||
|
// One hotel on travel 902, two on the travel with the unparseable date.
|
||||||
|
self::assertSame(3, $export->skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void
|
||||||
|
{
|
||||||
|
$export = $this->build();
|
||||||
|
|
||||||
|
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollapsesADuplicatePairLastWins(): void
|
||||||
|
{
|
||||||
|
$export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']);
|
||||||
|
|
||||||
|
$matching = array_values(array_filter(
|
||||||
|
$export->rows,
|
||||||
|
static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'],
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $matching);
|
||||||
|
self::assertSame('Zweiter Treffer', $matching[0]['product']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function testCarriesTheSourceExportTimestamp(): void
|
||||||
|
{
|
||||||
|
$export = $this->build(null, ['Ziel_1.xml'], "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n");
|
||||||
|
|
||||||
|
self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSourceExportTimestampIsNullWithoutAMarker(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->build()->sourceGeneratedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rowFor(string $key): array
|
||||||
|
{
|
||||||
|
foreach ($this->build()->rows as $row) {
|
||||||
|
if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('No row for '.$key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $files
|
||||||
|
*/
|
||||||
|
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export
|
||||||
|
{
|
||||||
|
$contents = [
|
||||||
|
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
|
||||||
|
'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$filesystem = $this->createStub(FilesystemOperator::class);
|
||||||
|
$filesystem->method('listContents')->willReturn(new DirectoryListing(array_map(
|
||||||
|
static fn (string $path): FileAttributes => new FileAttributes($path),
|
||||||
|
$files,
|
||||||
|
)));
|
||||||
|
$filesystem->method('read')->willReturnCallback(
|
||||||
|
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? ''
|
||||||
|
);
|
||||||
|
$filesystem->method('fileExists')->willReturnCallback(
|
||||||
|
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
|
||||||
|
);
|
||||||
|
|
||||||
|
$hotelLoader = $this->createStub(HotelLoader::class);
|
||||||
|
$hotelLoader->method('loadAll')->willReturn([
|
||||||
|
1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'),
|
||||||
|
2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pickupLoader = $this->createStub(PickupLoader::class);
|
||||||
|
$pickupLoader->method('loadAll')->willReturn([
|
||||||
|
10 => $this->pickup(10),
|
||||||
|
11 => $this->pickup(11),
|
||||||
|
12 => $this->pickup(12),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (new TravelExportBuilder(
|
||||||
|
$hotelLoader,
|
||||||
|
$pickupLoader,
|
||||||
|
new TravelParser(new AgeConstraintParserRegistry([]), false),
|
||||||
|
$filesystem,
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
new NullLogger(),
|
||||||
|
))->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel
|
||||||
|
{
|
||||||
|
$hotel = new Hotel();
|
||||||
|
$hotel->id = $id;
|
||||||
|
$hotel->code = $code;
|
||||||
|
$hotel->name = $name;
|
||||||
|
$hotel->costUnit = $costUnit;
|
||||||
|
$hotel->country = $country;
|
||||||
|
$hotel->countryCode = $countryCode;
|
||||||
|
|
||||||
|
return $hotel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickup(int $id): Pickup
|
||||||
|
{
|
||||||
|
$pickup = new Pickup();
|
||||||
|
$pickup->id = $id;
|
||||||
|
$pickup->code = 'Z'.$id;
|
||||||
|
$pickup->city = 'Stadt '.$id;
|
||||||
|
$pickup->street = 'Strasse '.$id;
|
||||||
|
|
||||||
|
return $pickup;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user