Compare commits

25 Commits
Author SHA1 Message Date
fromme 77960fe81f chore: update project dependencies 2026-11-09 18:15:36 +01:00
fromme 9ce42de4c1 refactor: drop edit-mode room assignment code 2026-11-09 18:01:25 +01:00
fromme d50995a21c fix: pass BusPro room assignments through on edit 2026-11-09 18:00:47 +01:00
fromme 897a1ac7f9 fix: show booked room count in edit summary 2026-11-09 18:00:47 +01:00
fromme 2705401393 feat: add bus-only travel mailing lists 2026-11-09 15:53:53 +01:00
fromme f5894a6bef chore: update project dependencies 2026-11-04 16:50:54 +01:00
fromme e7df5ac03a feat: use a Mailjet sub-account for travel lists 2026-11-04 16:21:15 +01:00
fromme c16f368293 feat: create Mailjet lists for travel mailings 2026-11-04 15:32:50 +01:00
fromme d63fa10a56 fix: show warning flashes in the admin layout 2026-11-04 15:32:49 +01:00
fromme e8fa71175d chore: remove obsolete booking draft repair command 2026-11-04 10:14:10 +01:00
fromme 09610889f3 fix: map live service status to export wording 2026-11-04 10:12:34 +01:00
fromme d8fe9a3875 fix: stop counting held seats in edit usage 2026-11-04 10:10:37 +01:00
fromme e9766dbdce fix: strip invalid XML characters from responses 2026-11-03 15:07:47 +01:00
fromme b047465339 chore: update project dependencies 2026-10-29 15:52:06 +01:00
fromme a60bf68345 docs: document contingent hotel code contract 2026-10-29 15:34:27 +01:00
fromme 97c6330a9a feat: sync contingents for TYPO3 calendar hotels 2026-10-29 15:05:04 +01:00
fromme ec36b61275 feat: add raw contingent availability endpoint 2026-10-29 11:19:31 +01:00
fromme 7c8dbeef48 fix: validate newly entered phone numbers 2026-10-28 13:48:51 +01:00
fromme e293bd3b1c chore: update project dependencies 2026-10-26 16:42:20 +01:00
fromme a7b845751b chore: update project dependencies 2026-10-23 16:22:50 +02:00
fromme 234f3a0bd2 fix: submit BusPro nationality token for applicant 2026-10-23 16:20:37 +02:00
fromme f794d882ca chore: disable unused ssh-agent in ddev web container 2026-10-23 16:11:53 +02:00
fromme 05e348e9b7 chore: upgrade htmx from 1.9 to 2.0 2026-10-20 14:59:54 +02:00
fromme 654214cf37 chore: decide on npm install scripts 2026-10-20 14:58:13 +02:00
fromme 8a6f73bd9b chore: bump node to 24 2026-10-20 14:53:53 +02:00
89 changed files with 4086 additions and 1442 deletions

No files matched your search

+2 -1
View File
@@ -17,8 +17,9 @@ use_dns_when_possible: true
timezone: Europe/Berlin
composer_version: "2"
web_environment: [ ]
nodejs_version: "22"
nodejs_version: "24"
corepack_enable: false
omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml:
+3
View File
@@ -44,6 +44,9 @@ MAILJET_API_KEY=
MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_DEFAULT_LIST_ID=
# Sub-account for the travel mailing lists
MAILJET_TRAVEL_MAILING_API_KEY=
MAILJET_TRAVEL_MAILING_API_SECRET=
# Development default for HTTP Basic Auth password; override in production secrets.
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
+1
View File
@@ -0,0 +1 @@
24
Generated
+898 -632
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -29,6 +29,10 @@ zenstruck_schedule:
frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests"
- task: app:cleanup:travel-mailing-lists
frequency: "50 1 * * *"
description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
- task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly"
+14
View File
@@ -441,6 +441,20 @@ services:
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
# Travel mailing lists live in a Mailjet sub-account, which has its own key pair and keeps
# its contacts and lists apart from the newsletter on the main account.
app.mailjet.travel_mailing_client:
class: App\Mailjet\ApiClient
arguments:
$apiKey: '%env(default::MAILJET_TRAVEL_MAILING_API_KEY)%'
$apiSecret: '%env(default::MAILJET_TRAVEL_MAILING_API_SECRET)%'
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
App\Service\TravelMailingListManager:
arguments:
$mailjet: '@app.mailjet.travel_mailing_client'
App\EventListener\MaintenanceModeListener:
arguments:
$options:
+59 -6
View File
@@ -507,21 +507,74 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
Each entry, ordered by date:
```json
{"date": "2027-01-30", "status": "BLOCKED"}
```
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
Two differences from `calendar`:
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
#### `GET /api/contingents/status` — scope `api`
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
```json
{
"hotelCode": "DGS",
"name": "Sportclub Spinabad",
"sources": ["accommodation", "typo3"],
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
"pricedFrom": "2027-06-01",
"pricedTo": "2027-09-30",
"syncedAt": "2026-10-29T10:15:00+01:00",
"lastError": null,
"failureCount": 0,
"available": true
}
```
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
---
### Operating the snapshot (deployment)
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
The snapshot is filled only by the scheduler, so on an empty database (a new environment, or after the snapshot tables were truncated) every hotel returns `502` until the first successful sync — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
Rollout order:
Bringing up an environment:
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`.
2. Deploy; `database:migrate` creates the empty tables.
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the environment.** If it is rejected the sync cannot populate anything and the endpoints stay at `502`.
2. Deploy; `database:migrate` creates the tables.
3. Run `bin/console app:bpn:sync-contingents -v` on the host promptly, ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
4. Verify with `GET /api/contingents/status`: every row `available: true`, `failureCount` 0.
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
**TYPO3's codes must be BusPro calendar codes.** The sync sends every code to bpn-connect as is. A TYPO3 hotel whose code is a CMS code rather than the BusPro hotel code (group houses often have both, e.g. calendar code `DKS`, CMS code `DKSGRP`) is answered with a `404` by bpn-connect: that code fails on every run, logs two errors each time, and shows up in `contingents/status` with `lastError` set and `available: false`. The task itself keeps succeeding, since only a total failure fails it. The fix belongs in TYPO3, by pointing the product at a hotel whose code is the BusPro code; MyE&P does not translate CMS codes. `contingents/status` is the place to spot such codes, and `accommodation.cmsCode` there shows both codes side by side for group houses.
### Freshness and propagation
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261029120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
// written, until a later migration drops it; that keeps this step reversible.
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
}
public function down(Schema $schema): void
{
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('DROP TABLE calendar_hotel');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261104142531 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds travel_mailing_list to track the Mailjet contact lists created per travel until their deletion.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE travel_mailing_list');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261109074654 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
// Every existing list holds all participants and is named by its travel code alone.
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
}
public function down(Schema $schema): void
{
// Fails while a travel has two lists; delete the bus-only one first.
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
}
}
+4 -4
View File
@@ -24,7 +24,7 @@
"babel-plugin-polyfill-corejs3": "^1.0.0",
"core-js": "^3.51.0",
"cypress": "^15.5.0",
"htmx.org": "^1.9.12",
"htmx.org": "^2.0.11",
"lightningcss": "^1.33.0",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
@@ -4597,9 +4597,9 @@
}
},
"node_modules/htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.11.tgz",
"integrity": "sha512-Thx/WtpeOQqSrqBCw/A1cwGJGg4UrVa3+sW0GmrM3p4gJgO89ecH4qtbnyzDDWFvBTqjnIMCgELTNt636dtamA==",
"dev": true,
"license": "0BSD"
},
+5 -1
View File
@@ -11,7 +11,7 @@
"babel-plugin-polyfill-corejs3": "^1.0.0",
"core-js": "^3.51.0",
"cypress": "^15.5.0",
"htmx.org": "^1.9.12",
"htmx.org": "^2.0.11",
"lightningcss": "^1.33.0",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
@@ -35,5 +35,9 @@
"sortablejs": "^1.15.7",
"stimulus-use": "^0.52.3",
"vanilla-calendar-pro": "^3.1.0"
},
"allowScripts": {
"cypress": true,
"core-js": false
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\BpnConnect\Model;
use App\Enum\BusLeg;
readonly class TravelMailingResponse
{
/**
@@ -12,6 +14,7 @@ readonly class TravelMailingResponse
public function __construct(
public string $travelCode,
public array $data,
public ?BusLeg $busLeg = null,
) {
}
}
+25 -5
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\Model\TravelMailingResponse;
use App\Enum\BusLeg;
class TravelsClient extends AbstractApiClient
{
@@ -14,12 +16,30 @@ class TravelsClient extends AbstractApiClient
/**
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
* apart from a travel without confirmed participants.
*
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
*/
public function getMailingRecipients(string $travelCode): TravelMailingResponse
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
{
$payload = $this->request(self::MAILING_PATH, [
'travelCode' => $travelCode,
]);
$query = ['travelCode' => $travelCode];
if (null !== $busLeg) {
$query['busLeg'] = $busLeg->value;
}
$payload = $this->request(self::MAILING_PATH, $query);
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
// must not end up on a list meant for bus passengers only.
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
if ($appliedBusLeg !== $busLeg?->value) {
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
'travel_code' => $travelCode,
'requested_bus_leg' => $busLeg?->value,
'applied_bus_leg' => $appliedBusLeg,
]);
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
}
$recipients = array_map(
static fn (array $item) => new TravelMailingRecipient(
@@ -30,6 +50,6 @@ class TravelsClient extends AbstractApiClient
$payload['data'] ?? [],
);
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients);
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
}
}
@@ -153,7 +153,6 @@ class BookingDataProcessor
$roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection;
}
}
@@ -341,6 +340,7 @@ class BookingDataProcessor
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
}
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
$this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants,
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
* Processes participant service selections for booking updates.
*
* Handles the assignment and mapping of services (additional, transportation,
* pickup, room, insurance) from participant DTOs to the booking model during
* the update request workflow.
* pickup, insurance) from participant DTOs to the booking model during the
* update request workflow. Rooms are not taken from the DTOs: they cannot be
* changed in edit mode, so the booking's own room assignments are sent back.
*/
class ParticipantServiceProcessor
{
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
* Resets all existing participant-to-service mappings to start with a clean slate.
*
* This ensures that service assignments are rebuilt from scratch based on current form selections.
* Includes resetting room mappings to allow room reassignments during edit.
* Room mappings are left alone, see removeCanceledParticipantsFromRooms().
*
* @param Booking $bookingData The booking data object containing services to reset
*/
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
...$bookingData->transportationServices,
...$bookingData->pickups,
...$bookingData->dropOffs,
...$bookingData->rooms,
...$bookingData->insurances,
];
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
* Processes all services for a single participant.
*
* This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, pickup locations, and room assignments.
* including additional services, transportation services, pickup locations, and insurance.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
$this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData);
}
@@ -271,27 +270,23 @@ class ParticipantServiceProcessor
}
/**
* Processes room assignment for a participant.
* Removes canceled participants from the booking's room assignments.
*
* Maps the participant to their assigned room. Allows room reassignment during
* booking edits while maintaining the constraint that participants can only be
* assigned to room types that have already been booked.
* Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
* them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
* session would undo any reassignment made in BusPro while the edit was open, and send a
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
* other service.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Booking $bookingData The booking data object to update
*/
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void
public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
{
if (null === $participant->assignedRoomId) {
return;
}
// Find the room in the existing booking by ID
foreach ($bookingData->rooms as $room) {
if ($room->id === $participant->assignedRoomId) {
$room->mapping[] = $participant->index;
break;
}
$room->mapping = array_values(array_filter(
$room->mapping,
fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
));
}
}
+31
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -15,17 +16,47 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class Communication
{
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $phone = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $mobile = null;
// The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_identity'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_identity'])]
public ?string $email = null;
public bool $newsletter = false;
public function rememberLoadedNumbers(): void
{
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
/**
* Converts the communication data to API payload format.
*
+12 -1
View File
@@ -6,6 +6,7 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\Utility\XmlCharacterFilter;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\XmlDumpWriter;
use Psr\Log\LoggerInterface;
@@ -208,8 +209,18 @@ class XmlChannel
*/
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
{
// The dump keeps the raw response; only the parser sees the filtered one.
$filtered = XmlCharacterFilter::strip($xml, $removed);
if ($removed > 0) {
$this->logger->warning('Removed characters not allowed in XML from API response', [
'request_id' => $requestId,
'type' => $type->value,
'removed' => $removed,
]);
}
try {
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
/**
* Removes characters XML 1.0 does not allow from a BusPro response before it is parsed.
*
* BusPro escapes whatever a field holds into a character reference without checking it,
* so a NUL-padded value comes out as `&#x0;`. libxml rejects that as fatal and the whole
* response fails to parse, not just the field. The same applies to the raw control bytes.
*/
final class XmlCharacterFilter
{
/**
* @param-out int $count the number of characters removed
*/
public static function strip(string $xml, ?int &$count = null): string
{
$count = 0;
$xml = preg_replace_callback(
'/&#(x[0-9a-f]+|[0-9]+);/i',
static function (array $match) use (&$count): string {
$reference = $match[1];
$codePoint = 'x' === strtolower($reference[0]) ? hexdec(substr($reference, 1)) : (int) $reference;
if (self::isAllowed($codePoint)) {
return $match[0];
}
++$count;
return '';
},
$xml,
);
// Control bytes never occur inside a UTF-8 multibyte sequence, so a byte-wise match is safe.
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml, -1, $rawCount);
$count += $rawCount;
return $xml;
}
/**
* The Char production of XML 1.0.
*/
private static function isAllowed(int|float $codePoint): bool
{
return 0x9 === $codePoint
|| 0xA === $codePoint
|| 0xD === $codePoint
|| ($codePoint >= 0x20 && $codePoint <= 0xD7FF)
|| ($codePoint >= 0xE000 && $codePoint <= 0xFFFD)
|| ($codePoint >= 0x10000 && $codePoint <= 0x10FFFF);
}
}
+4 -4
View File
@@ -307,10 +307,10 @@ class TravelLoader extends AbstractLoader
$availability = $serviceAvailabilities[$service->id];
$service->available = $availability->available;
// The live status is the only signal that a contingent has run out: BusPro moves a
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
// response actually carries one, so a response that omits the attribute does not wipe
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
// The live status is fresher than the nightly export's. The parser has already mapped it
// onto the export vocabulary. Only overwrite when the response actually carries one, so a
// response that omits the attribute does not wipe the value parsed from the XML export.
// Mirrors TravelSnapshotManager::applyExtendedToService().
if (null !== $availability->status && '' !== trim($availability->status)) {
$service->status = $availability->status;
}
@@ -2,6 +2,7 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use App\BusProNet\Traits\TypeConversionTrait;
use Symfony\Component\DomCrawler\Crawler;
@@ -9,6 +10,19 @@ abstract class AbstractParser
{
use TypeConversionTrait;
/**
* Live availability wording for a Leistung, mapped onto the XML export's.
*
* The availability endpoints answer "buchbar" or "ausgebucht", while every availability rule
* compares against the export's "Frei" / "Anfrage" / "Buchungsstop". "ausgebucht" also covers
* a Leistung that would still take an inquiry (anfragemoeglich="J"); that is not offered
* either, so it maps to the booking stop.
*/
private const LIVE_SERVICE_STATUS = [
'buchbar' => Constants::STATUS_AVAILABLE,
'ausgebucht' => Constants::STATUS_BLOCKED,
];
protected function getStringOrNullValue(Crawler $node): ?string
{
return 0 < $node->count() ? $node->text() : null;
@@ -87,4 +101,16 @@ abstract class AbstractParser
return $value;
}
/**
* Translates a live Leistung status into the export vocabulary; anything else passes through.
*/
protected function toServiceStatus(?string $liveStatus): ?string
{
if (null === $liveStatus) {
return null;
}
return self::LIVE_SERVICE_STATUS[strtolower(trim($liveStatus))] ?? $liveStatus;
}
}
@@ -19,7 +19,7 @@ class AvailabilitiesParser extends AbstractParser
->each(function (Crawler $node) use (&$availabilities) {
$availability = new Availability();
$availability->serviceId = (int) $node->attr('id');
$availability->status = $node->attr('status');
$availability->status = $this->toServiceStatus($node->attr('status'));
$availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis'));
@@ -30,7 +30,7 @@ class ExtendedAvailabilitiesParser extends AbstractParser
$availability = new ExtendedAvailability();
$availability->serviceId = $serviceId;
$availability->status = $node->attr('status');
$availability->status = $this->toServiceStatus($node->attr('status'));
$availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis'));
$availability->dateFrom = $this->stringToDate($node->attr('termin'));
@@ -50,6 +50,7 @@ class PersonalDataParser extends AbstractParser
$communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil'));
$communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email'));
$communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter'));
$communication->rememberLoadedNumbers();
$personalData->communication = $communication;
}
-491
View File
@@ -1,491 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Repository\BookingEditDraftRepository;
use App\Security\Crypt;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftMerger;
use App\Service\TravelDataProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Repairs a booking edit draft that BusPro keeps rejecting.
*
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
* accumulated 37 such failures over two months.
*
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
* every field the customer typed, and reverts only the service selections that BusPro will not
* accept - naming each one, so the office can tell the customer what to pick again.
*
* Dry run by default. --apply writes a JSON backup of the original form data first.
*/
#[AsCommand(
name: 'app:booking:repair-draft',
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
)]
class BookingRepairDraftCommand extends Command
{
/**
* Draft `services` keys holding a single service id.
*/
/**
* Date of birth the office writes into the empty slots of a template booking.
*/
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
/**
* Draft `services` keys holding a list of service ids.
*
* @var list<string>
*/
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
/**
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
* pickup and drop-off that only make sense on a coach.
*
* @var list<string>
*/
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly BookingClient $bookingClient,
private readonly TravelClient $travelClient,
private readonly Crypt $crypt,
private readonly TravelDataProvider $travelDataProvider,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly BookingEditDraftMerger $draftMerger,
private readonly BookingChangeTracker $changeTracker,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bookingId = $input->getOption('booking');
if (null === $bookingId) {
$io->error('--booking is required.');
return Command::INVALID;
}
$draft = $this->findSingleDraft($io, (int) $bookingId);
if (null === $draft) {
return Command::FAILURE;
}
$user = $draft->getUser();
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
if (null === $booking) {
return Command::FAILURE;
}
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
if (null === $travel) {
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
return Command::FAILURE;
}
$formData = $draft->getFormData();
$draftParticipants = $formData['participants'] ?? [];
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
return Command::FAILURE;
}
$blocked = $this->collectUnbookableServices($io, $travel);
// The booking as BusPro currently holds it is the only state known to be acceptable, so
// it is the base everything is rebuilt on.
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
$booking,
$travel,
false,
);
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
$reverted = [];
foreach ($draftParticipants as $index => $participantData) {
if (false === isset($repairedDto->participants[$index])) {
continue;
}
$filtered = $this->filterServiceSelections(
$participantData,
$liveSelections[$index]['services'] ?? [],
$blocked,
$index,
$reverted,
);
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
}
$repaired = $this->changeTracker->extractUserData($repairedDto);
$this->report($io, $formData, $repaired, $reverted, $blocked);
if (false === $input->getOption('apply')) {
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
return Command::SUCCESS;
}
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
$io->success(sprintf('Original form data backed up to %s', $backupPath));
$draft->setFormData($repaired);
$this->entityManager->flush();
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
return Command::SUCCESS;
}
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
{
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
if ([] === $drafts) {
$io->error(sprintf('No draft found for booking %d.', $bookingId));
return null;
}
if (count($drafts) > 1) {
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
return null;
}
$draft = $drafts[0];
$io->definitionList(
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
);
return $draft;
}
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
{
$result = $this->bookingClient->getBooking(
(string) $user->getEmail(),
$this->crypt->decrypt((string) $user->getPassword()),
$bookingId,
);
if ($result instanceof Notification) {
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
return null;
}
return $result;
}
/**
* Refuses to repair a draft whose positions no longer line up with the live booking.
*
* Drafts are merged positionally, by array index, with no identity check. Empty template
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
* date of birth are the only anchors available. If one of those has moved, every later
* position is suspect and rebuilding would quietly graft data onto the wrong people.
*
* @param array<int, array<string, mixed>> $draftParticipants
*/
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
{
$liveCount = count($booking->participants);
$draftCount = count($draftParticipants);
if ($liveCount !== $draftCount) {
$io->warning(sprintf(
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
$liveCount,
$draftCount,
));
}
$anchors = 0;
$mismatches = [];
foreach (array_values($booking->participants) as $index => $participant) {
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
continue;
}
++$anchors;
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
if (null !== $draftDob && $draftDob !== $liveDob) {
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
}
}
if ([] !== $mismatches) {
$io->error('Draft positions no longer match the live booking:');
$io->listing($mismatches);
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
return false;
}
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
return true;
}
/**
* Collects the services BusPro will not accept as an addition.
*
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
* Live availability is preferred over the travel data, which lags behind it.
*
* @return array<int, string> service id => status
*/
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
{
$statuses = [];
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
if (null !== $service->id && null !== $service->status) {
$statuses[$service->id] = $service->status;
}
}
$live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
if ($live instanceof Notification) {
$io->warning(sprintf(
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
(int) $live->code,
(string) $live->message,
));
} else {
foreach ($live->getServices() as $availability) {
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
$statuses[$availability->serviceId] = $availability->status;
}
}
}
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
if ([] === $blocked) {
$io->text('Every service on this travel is currently "Frei".');
return [];
}
$rows = [];
foreach ($blocked as $serviceId => $status) {
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
$label = $service instanceof Service ? (string) $service->label : '?';
$rows[] = [$serviceId, $label, $status];
}
$io->section('Services that cannot take new participants');
$io->table(['id', 'Leistung', 'status'], $rows);
return $blocked;
}
/**
* Drops the draft's selections that would add a participant to a service BusPro has closed.
*
* Removing a key leaves the live booking's own value in place, because the merger applies
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
* preference - are kept, so the customer loses as little as possible.
*
* @param array<string, mixed> $participantData
* @param array<string, mixed> $liveServices
* @param array<int, string> $blocked
* @param list<string> $reverted
*
* @return array<string, mixed>
*/
private function filterServiceSelections(
array $participantData,
array $liveServices,
array $blocked,
int $index,
array &$reverted,
): array {
if (false === isset($participantData['services']) || [] === $blocked) {
return $participantData;
}
$services = $participantData['services'];
$position = $index + 1;
foreach (self::SINGLE_SERVICE_KEYS as $key) {
$selected = $services[$key] ?? null;
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
unset($services[$key]);
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
}
}
foreach (self::LIST_SERVICE_KEYS as $key) {
$selected = $services[$key] ?? null;
if (false === is_array($selected)) {
continue;
}
$liveList = $liveServices[$key] ?? [];
$kept = [];
foreach ($selected as $serviceId) {
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
continue;
}
$kept[] = $serviceId;
}
$services[$key] = $kept;
}
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
$selected = $services[$key] ?? null;
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
continue;
}
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
// Transport reverts as a unit, pickup and drop-off included.
foreach (self::TRANSPORT_KEYS as $transportKey) {
unset($services[$transportKey]);
}
break;
}
$participantData['services'] = $services;
return $participantData;
}
/**
* @param array<string, mixed> $original
* @param array<string, mixed> $repaired
* @param list<string> $reverted
* @param array<int, string> $blocked
*/
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
{
$io->section('Typed data preserved');
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
$kept = 0;
$lost = [];
foreach ($original['participants'] ?? [] as $index => $participantData) {
foreach ($fields as $field) {
$before = $participantData['personalData'][$field] ?? null;
if (null === $before || '' === $before) {
continue;
}
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
if ($before === $after) {
++$kept;
continue;
}
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
}
}
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
if ([] !== $lost) {
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
$io->listing(array_slice($lost, 0, 25));
}
$io->section('Service selections reverted');
if ([] === $reverted) {
$io->text([] === $blocked
? 'None - no service on this travel is closed.'
: 'None - the draft selects no closed service.');
return;
}
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
$io->listing($reverted);
}
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
{
$directory = $this->projectDir.'/'.trim($backupDir, '/');
if (false === is_dir($directory)) {
mkdir($directory, 0o775, true);
}
$path = sprintf(
'%s/draft-%d-booking-%d-%s.json',
$directory,
(int) $draft->getId(),
$draft->getBookingId(),
(new \DateTimeImmutable())->format('Ymd-His'),
);
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
return $path;
}
}
+38 -31
View File
@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Groups\Accommodation;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentHotelDirectory;
use App\Service\ContingentSnapshotManager;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;
@@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
* to re-sync a single accommodation while debugging.
* to re-sync a single hotel code while debugging.
*
* Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus
* TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both
* sources, so the snapshot tables only ever describe hotels that are still synced.
*/
class BpnSyncContingentsCommand extends Command
{
use LockableTrait;
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
private readonly ContingentHotelDirectory $hotelDirectory,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSnapshotManager $snapshotManager,
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
{
$this
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code')
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
;
}
@@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command
$dateFrom = $today->toDateTimeImmutable();
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
$this->hotelDirectory->refresh();
// array_keys() turns a numeric code into an int.
$allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all()));
$hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes;
if ([] === $accommodations) {
$io->warning('No accommodation with a calendarCode matched.');
if ([] === $hotelCodes) {
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
return Command::SUCCESS;
}
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
$changed = 0;
$failedCodes = [];
foreach ($accommodations as $accommodation) {
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
foreach ($hotelCodes as $code) {
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
++$skipped;
continue;
}
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
++$processed;
if (!$result->successful) {
$failedCodes[] = (string) $accommodation->getCalendarCode();
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
$failedCodes[] = $code;
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
continue;
}
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
$io->writeln(sprintf(
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
(string) $accommodation->getCalendarCode(),
$code,
$result->changed ? 'changed' : 'unchanged',
$result->added,
$result->updated,
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
// Only a full run knows the whole set. A code that left both sources is not served any
// more (the availability endpoint answers 400 for it), so its snapshot is dead weight;
// should it come back, the next run rebuilds it.
if (!is_string($hotelCode)) {
$orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes);
$orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes);
if ($orphanedStates > 0) {
$this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [
'hotels' => $orphanedStates,
'days' => $orphanedDays,
]);
}
}
// Only a *total* failure is worth failing the task over: that means upstream is down or
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
}
/**
* @return Accommodation[]
*/
private function resolveAccommodations(?string $hotelCode): array
{
if (null === $hotelCode) {
return $this->accommodationRepository->findAllWithCalendarCode();
}
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
return null !== $accommodation ? [$accommodation] : [];
}
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
{
if (0 === $staleAfterMinutes) {
return false;
}
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
if (null === $syncedAt) {
return false;
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\TravelMailingList;
use App\Exception\NewsletterProviderException;
use App\Repository\TravelMailingListRepository;
use App\Service\TravelMailingListManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:cleanup:travel-mailing-lists',
description: 'Deletes travel mailing lists and their orphaned contacts from Mailjet 30 days after the travel started',
)]
class CleanupTravelMailingListsCommand extends Command
{
public function __construct(
private readonly TravelMailingListRepository $travelMailingListRepository,
private readonly TravelMailingListManager $travelMailingListManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$mailingLists = $this->travelMailingListRepository->findDueForDeletion(new \DateTimeImmutable());
if ([] === $mailingLists) {
$io->success('No travel mailing lists due for deletion.');
return Command::SUCCESS;
}
// A travel's lists are deleted together (see TravelMailingListManager::delete()), including
// one that is not due yet because the travel's start date moved after it was created.
$travelCodes = array_unique(array_map(static fn (TravelMailingList $mailingList) => $mailingList->getTravelCode(), $mailingLists));
$failed = 0;
$deletedLists = 0;
foreach ($travelCodes as $travelCode) {
$travelLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
$listIds = implode(', ', array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $travelLists));
try {
$deletedContacts = $this->travelMailingListManager->delete($travelLists);
$deletedLists += count($travelLists);
$io->writeln(sprintf('Deleted %s (lists %s) and %d orphaned contact(s).', $travelCode, $listIds, $deletedContacts));
} catch (NewsletterProviderException $exception) {
// The rows of lists not yet deleted stay, so the next run picks them up again.
++$failed;
$this->logger->error('Delete travel mailing lists failed', [
'travel_code' => $travelCode,
'mailjet_list_ids' => $listIds,
'error' => $exception->getMessage(),
]);
$io->error(sprintf('%s (lists %s): %s', $travelCode, $listIds, $exception->getMessage()));
}
}
if ($failed > 0) {
return Command::FAILURE;
}
$io->success(sprintf('Deleted %d travel mailing list(s).', $deletedLists));
return Command::SUCCESS;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\TravelMailing;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\TravelsClient;
use App\BusProNet\Utility\DateCodeUtility;
use App\Entity\TravelMailingList;
use App\Exception\NewsletterProviderException;
use App\Form\Model\TravelMailingLookupDto;
use App\Repository\TravelMailingListRepository;
use App\Service\TravelDataProvider;
use App\Service\TravelMailingListManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
class CreateListController extends AbstractController
{
public const string CSRF_TOKEN_ID = 'create_travel_mailing_list';
public function __construct(
private readonly TravelsClient $travelsClient,
private readonly TravelDataProvider $travelDataProvider,
private readonly TravelMailingListRepository $travelMailingListRepository,
private readonly TravelMailingListManager $travelMailingListManager,
private readonly LoggerInterface $adminLogger,
) {
}
#[Route('/admin/travel-mailing/list', name: 'app_admin_travelmailing_createlist', methods: ['POST'])]
public function index(Request $request): Response
{
if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$lookup = new TravelMailingLookupDto();
$lookup->travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
$lookup->busOnly = $request->request->getBoolean('busOnly');
$travelCode = $lookup->travelCode;
$busLeg = $lookup->getBusLeg();
$returnUrl = $this->generateUrl('app_admin_travelmailing', array_filter([
'travelCode' => $travelCode,
'busOnly' => $lookup->busOnly ? 1 : null,
]));
$name = TravelMailingList::nameFor($travelCode, $busLeg);
if (null !== $this->travelMailingListRepository->findOneByName($name)) {
$this->addFlash('error', sprintf('Die Mailjet-Liste %s gibt es bereits.', $name));
return $this->redirect($returnUrl);
}
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
if (null === $travelStartsAt) {
$this->addFlash('error', sprintf('Für %s ist kein Reisebeginn bekannt, die Liste könnte nicht automatisch gelöscht werden.', $travelCode));
return $this->redirect($returnUrl);
}
// Fetched again rather than posted from the page, so the list holds what BusPro reports now.
try {
$recipients = $this->travelsClient->getMailingRecipients($travelCode, $busLeg)->data;
} catch (BpnConnectException) {
$this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.');
return $this->redirect($returnUrl);
}
if ([] === $recipients) {
$this->addFlash('error', sprintf('Keine Empfänger für %s gefunden.', $travelCode));
return $this->redirect($returnUrl);
}
try {
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $busLeg, $travelStartsAt);
} catch (NewsletterProviderException $exception) {
$this->adminLogger->error('Create travel mailing list failed', [
'travel_code' => $travelCode,
'error' => $exception->getMessage(),
]);
$this->addFlash('error', 'Die Mailjet-Liste konnte nicht angelegt werden: '.$exception->getMessage());
return $this->redirect($returnUrl);
}
$this->adminLogger->info('Create travel mailing list', [
'travel_code' => $travelCode,
'mailjet_list_id' => $mailingList->getMailjetListId(),
'bus_leg' => $busLeg?->value,
'recipients' => count($recipients),
]);
$this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $mailingList->getName(), count($recipients)));
return $this->redirect($returnUrl);
}
}
@@ -7,8 +7,10 @@ namespace App\Controller\Admin\TravelMailing;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\TravelsClient;
use App\BusProNet\Utility\DateCodeUtility;
use App\Entity\TravelMailingList;
use App\Form\Admin\TravelMailingLookupType;
use App\Form\Model\TravelMailingLookupDto;
use App\Repository\TravelMailingListRepository;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -22,6 +24,7 @@ class IndexController extends AbstractController
public function __construct(
private readonly TravelsClient $travelsClient,
private readonly TravelDataProvider $travelDataProvider,
private readonly TravelMailingListRepository $travelMailingListRepository,
) {
}
@@ -36,6 +39,10 @@ class IndexController extends AbstractController
$result = null;
$travelLabel = null;
$travelStartsAt = null;
$mailingLists = [];
$mailingList = null;
$listName = null;
$failed = false;
if ($form->isSubmitted() && $form->isValid()) {
// The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126"
@@ -43,8 +50,17 @@ class IndexController extends AbstractController
$travelCode = (new DateCodeUtility())->sanitize((string) $lookup->travelCode);
try {
$result = $this->travelsClient->getMailingRecipients($travelCode);
$result = $this->travelsClient->getMailingRecipients($travelCode, $lookup->getBusLeg());
$travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode);
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
$mailingLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
// The list matching the current filter, if one exists, replaces the create button.
$listName = TravelMailingList::nameFor($travelCode, $lookup->getBusLeg());
foreach ($mailingLists as $existing) {
if ($listName === $existing->getName()) {
$mailingList = $existing;
}
}
} catch (BpnConnectException) {
// The client has already logged the cause; the page only needs to say it failed.
$failed = true;
@@ -55,6 +71,10 @@ class IndexController extends AbstractController
'form' => $form,
'result' => $result,
'travel_label' => $travelLabel,
'travel_starts_at' => $travelStartsAt,
'mailing_lists' => $mailingLists,
'mailing_list' => $mailingList,
'list_name' => $listName,
'failed' => $failed,
]);
}
+85 -1
View File
@@ -11,7 +11,9 @@ use App\Model\ContingentCalendarQuery;
use App\Model\ContingentPricesQuery;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\AccommodationPriceCoverage;
use App\Service\ContingentHotelDirectory;
use App\Service\ContingentSnapshotReader;
use App\Service\PriceTimelineBuilder;
use Carbon\CarbonImmutable;
@@ -33,6 +35,8 @@ class ContingentController extends AbstractController
private readonly ContingentSnapshotReader $snapshotReader,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly AccommodationPriceCoverage $priceCoverage,
private readonly ContingentHotelDirectory $hotelDirectory,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly LoggerInterface $logger,
) {
}
@@ -107,7 +111,7 @@ class ContingentController extends AbstractController
// An empty or stale snapshot must not be served as though every day were blocked: that
// is a plausible-looking 200 nobody can distinguish from real data. Fail the way this
// endpoint always failed instead, so existing consumers need no change.
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
if (null === $statuses) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
@@ -133,6 +137,86 @@ class ContingentController extends AbstractController
return $this->json($data);
}
/**
* The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are
* left out rather than blocked, so a portal shows them as free like it did with the feed
* that read bpn-connect directly.
*
* Serves every synced hotel code, including TYPO3's calendar hotels that have no
* accommodation (winter-only hotels).
*/
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
public function availability(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ContingentCalendarQuery $query,
): JsonResponse {
if (null === $this->hotelDirectory->find($query->hotelCode)) {
$this->logger->error('Hotel not found for hotelcode '.$query->hotelCode);
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
}
// Without a range: from today to wherever the snapshot ends.
$dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0);
$dateTo = $query->dateToDate();
// Same reasoning as calendar(): a missing or stale snapshot must not pass for real data.
// Here it matters even more, since an empty feed would show every day as free.
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
if (null === $statuses) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
}
ksort($statuses);
$data = [];
foreach ($statuses as $date => $status) {
$data[] = ['date' => (string) $date, 'status' => $status->value];
}
return $this->json($data);
}
/**
* One row per synced hotel code, for the overview of which hotel supports which calendar.
*
* `available` is whether `availability` currently serves the hotel; calendar and price table
* additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show.
*/
#[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])]
public function status(): JsonResponse
{
$states = $this->syncStateRepository->findAllIndexedByHotelCode();
$data = [];
foreach ($this->hotelDirectory->all() as $hotel) {
$state = $states[$hotel->code] ?? null;
$accommodation = $hotel->accommodation;
$bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null;
$data[] = [
'hotelCode' => $hotel->code,
'name' => $hotel->calendarHotel?->getName(),
'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()),
'accommodation' => null !== $accommodation ? [
'name' => $accommodation->getName(),
'cmsCode' => $accommodation->getEffectiveCmsCode(),
] : null,
'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null,
'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null,
'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM),
'lastError' => $state?->getLastError(),
'failureCount' => $state?->getFailureCount() ?? 0,
'available' => $this->snapshotReader->isCurrent($state),
];
}
return $this->json($data);
}
/**
* @param AccommodationPrice[] $prices
* @param array<string, true> $covered
@@ -240,7 +240,7 @@ class Step1Controller extends AbstractController
\DateTimeImmutable $dateTo,
): array {
$contingentStatus = null !== $accommodation
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? []
: [];
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Repository\Groups\CalendarHotelRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Local copy of the last calendar hotel list TYPO3 answered successfully.
*
* Kept so the contingent sync still covers TYPO3's hotels while TYPO3 is unreachable. Written
* only by {@see \App\Service\ContingentHotelDirectory::refresh()}, never by hand: the list is
* TYPO3's, this is not a third place to maintain hotel codes in.
*/
#[ORM\Entity(repositoryClass: CalendarHotelRepository::class)]
#[ORM\Table(name: 'calendar_hotel')]
class CalendarHotel
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 16, unique: true)]
private ?string $code = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): self
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}
+24 -2
View File
@@ -12,11 +12,14 @@ use Doctrine\ORM\Mapping as ORM;
/**
* Local snapshot of a single day of contingent availability as reported by bpn-connect.
*
* Keyed by hotel code, not by accommodation: the snapshot also covers TYPO3's calendar hotels,
* which have no `Accommodation`.
*
* Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable.
*/
#[ORM\Entity(repositoryClass: ContingentDayRepository::class)]
#[ORM\Table(name: 'contingent_day')]
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])]
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_hotel_code_date', columns: ['hotel_code', 'date'])]
class ContingentDay
{
#[ORM\Id]
@@ -24,8 +27,15 @@ class ContingentDay
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 16)]
private ?string $hotelCode = null;
/**
* Superseded by $hotelCode and no longer written. Only rows from before the switch still
* carry it; it stays mapped until the column is dropped, so the switch can be rolled back.
*/
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Accommodation $accommodation = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
@@ -39,6 +49,18 @@ class ContingentDay
return $this->id;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function setHotelCode(string $hotelCode): self
{
$this->hotelCode = $hotelCode;
return $this;
}
public function getAccommodation(): ?Accommodation
{
return $this->accommodation;
+15 -4
View File
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Bookkeeping for the scheduled contingent sync of a single accommodation.
* Bookkeeping for the scheduled contingent sync of a single hotel code.
*
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
* horizon it had already reached; `horizonTo` is how far that reach extends.
@@ -23,8 +23,14 @@ class ContingentSyncState
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 16, unique: true)]
private ?string $hotelCode = null;
/**
* Superseded by $hotelCode, see {@see ContingentDay::$accommodation}.
*/
#[ORM\OneToOne]
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: true, unique: true, onDelete: 'CASCADE')]
private ?Accommodation $accommodation = null;
/**
@@ -48,9 +54,9 @@ class ContingentSyncState
#[ORM\Column]
private int $failureCount = 0;
public function __construct(?Accommodation $accommodation = null)
public function __construct(?string $hotelCode = null)
{
$this->accommodation = $accommodation;
$this->hotelCode = $hotelCode;
}
public function getId(): ?int
@@ -58,6 +64,11 @@ class ContingentSyncState
return $this->id;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function getAccommodation(): ?Accommodation
{
return $this->accommodation;
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\BusLeg;
use App\Repository\TravelMailingListRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* A Mailjet contact list created for one travel's participants. The row exists so the list can
* be deleted again once the travel is past; the list itself is never updated after creation.
*
* A travel can have two lists, one for all participants and one for bus passengers only, told
* apart by their name.
*/
#[ORM\Entity(repositoryClass: TravelMailingListRepository::class)]
#[ORM\Index(columns: ['travel_code'])]
class TravelMailingList
{
private const string BUS_ONLY_SUFFIX = ' nur Bus';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 32)]
private string $travelCode;
/** The Mailjet list name. */
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $name;
#[ORM\Column(type: 'integer')]
private int $mailjetListId;
/** The bus leg its participants were filtered by; null when it holds all participants. */
#[ORM\Column(length: 20, nullable: true, enumType: BusLeg::class)]
private ?BusLeg $busLeg;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $deleteAfter;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(string $travelCode, int $mailjetListId, ?BusLeg $busLeg, \DateTimeImmutable $deleteAfter)
{
$this->travelCode = $travelCode;
$this->name = self::nameFor($travelCode, $busLeg);
$this->mailjetListId = $mailjetListId;
$this->busLeg = $busLeg;
$this->deleteAfter = $deleteAfter;
$this->createdAt = new \DateTimeImmutable();
}
public static function nameFor(string $travelCode, ?BusLeg $busLeg): string
{
return null !== $busLeg ? $travelCode.self::BUS_ONLY_SUFFIX : $travelCode;
}
public function getId(): ?int
{
return $this->id;
}
public function getTravelCode(): string
{
return $this->travelCode;
}
public function getName(): string
{
return $this->name;
}
public function getMailjetListId(): int
{
return $this->mailjetListId;
}
public function getBusLeg(): ?BusLeg
{
return $this->busLeg;
}
public function getDeleteAfter(): \DateTimeImmutable
{
return $this->deleteAfter;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Which bus leg a travel participant must have booked to be selected, as bpn-connect's travel
* mailing endpoint takes it (`busLeg`).
*/
enum BusLeg: string
{
case Outbound = 'outbound';
case Inbound = 'inbound';
case Any = 'any';
case Both = 'both';
public function label(): string
{
return match ($this) {
self::Outbound => 'Busreisende (Hinfahrt)',
self::Inbound => 'Busreisende (Rückfahrt)',
self::Any => 'Busreisende (Hin- oder Rückfahrt)',
self::Both => 'Busreisende (Hin- und Rückfahrt)',
};
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Where a hotel code in the contingent snapshot comes from. A code can have both.
*/
enum ContingentHotelSource: string
{
/** The calendarCode of a group house (`Accommodation`). */
case Accommodation = 'accommodation';
/** The calendar hotel of a visible TYPO3 product, from TYPO3's `/api/calendar-hotels`. */
case Typo3 = 'typo3';
}
+6 -1
View File
@@ -6,13 +6,14 @@ namespace App\Form\Admin;
use App\Form\Model\TravelMailingLookupDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
* and can be bookmarked or shared.
* (plus `&busOnly=1`) and can be bookmarked or shared.
*
* @extends AbstractType<TravelMailingLookupDto>
*/
@@ -26,6 +27,10 @@ class TravelMailingLookupType extends AbstractType
'placeholder' => 'z. B. LPJMP281126',
],
]);
$builder->add('busOnly', CheckboxType::class, [
'label' => 'nur '.TravelMailingLookupDto::BUS_LEG->label(),
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
+3 -2
View File
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -53,6 +53,8 @@ class BookingApplicantType extends AbstractType
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'required' => true,
'property' => 'nationality',
'placeholder' => 'Bitte auswählen...',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('dateOfBirth', BirthdayType::class, [
@@ -72,7 +74,6 @@ class BookingApplicantType extends AbstractType
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => true,
'sanitize_html' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
-1
View File
@@ -222,7 +222,6 @@ class BookingParticipantType extends AbstractType
$form->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)',
'required' => false,
'sanitize_html' => true,
'property_path' => 'participant.mobile',
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
+15
View File
@@ -93,8 +93,17 @@ class ParticipantDto
)]
public ?string $email = null;
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['booking_edit', 'booking_create'],
)]
public ?string $mobile = null;
// The number as loaded from BusPro, which goes back unchanged and is exempt from PhoneNumber.
// Public so it survives the session round trip through __unserialize().
public ?string $loadedMobile = null;
#[Assert\Valid(groups: ['booking_edit', 'booking_create'])]
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
public ?Address $address = null;
@@ -240,6 +249,7 @@ class ParticipantDto
$instance->nationality = $personalData->nationality ?: 'D';
$instance->email = $personalData->communication->email;
$instance->mobile = $personalData->communication->mobile;
$instance->loadedMobile = $personalData->communication->mobile;
$instance->dateOfBirth = $personalData->dateOfBirth;
$instance->height = $personalData->height;
$instance->weight = $personalData->weight;
@@ -254,6 +264,11 @@ class ParticipantDto
return $instance;
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
public function isApplicant(): bool
{
return 0 === $this->index;
+13
View File
@@ -4,10 +4,23 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\Enum\BusLeg;
use Symfony\Component\Validator\Constraints as Assert;
class TravelMailingLookupDto
{
/**
* The leg "bus passengers only" stands for: whoever takes the bus out, whatever their return.
*/
public const BusLeg BUS_LEG = BusLeg::Outbound;
#[Assert\NotBlank(message: 'Bitte einen Reisecode angeben.')]
public ?string $travelCode = null;
public bool $busOnly = false;
public function getBusLeg(): ?BusLeg
{
return $this->busOnly ? self::BUS_LEG : null;
}
}
+27
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Model\Address;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -69,10 +70,24 @@ class VoucherPurchaseDto
#[Assert\Valid]
public Address $address;
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['voucher_step_2'],
)]
public ?string $phone = null;
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['voucher_step_2'],
)]
public ?string $mobile = null;
// The numbers prefilled from BusPro, which are exempt from PhoneNumber.
public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
public ?string $email = null;
@@ -90,6 +105,16 @@ class VoucherPurchaseDto
$this->address->country = 'D';
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
public function getBuyerFullName(): string
{
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
@@ -119,6 +144,8 @@ class VoucherPurchaseDto
$this->address->country ??= 'D';
$this->phone = $personalData->communication->phone;
$this->mobile = $personalData->communication->mobile;
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
$this->email = $loginEmail;
}
}
-2
View File
@@ -81,12 +81,10 @@ class PersonalDataType extends AbstractType
'label' => 'Telefon',
'required' => false,
'property_path' => 'communication.phone',
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'property_path' => 'communication.mobile',
'sanitize_html' => true,
])
;
}
@@ -48,6 +48,17 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
return 'assignedRoomId';
}
/**
* Skips edit mode, where room assignments cannot be changed.
*
* The field is static text there, so a submitted value can only come from a replayed or
* hand-crafted POST. The edit request sends the booking's own assignments anyway.
*/
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return BookingDto::MODE_EDIT !== $mode && parent::shouldProcess($submittedData, $mode, $participantIndex);
}
/**
* Processes the assignedRoomId field for a specific participant.
*
@@ -65,7 +76,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
* 6. Updates the participant's assignedRoomId property
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -122,15 +133,8 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
return false;
}
// Calculate total capacity for this room type
// In edit mode, use maxQuantity which includes available rooms beyond the booked count.
// In create mode, use quantity which is the user's room selection from step 1.
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
$effectiveQuantity = $roomSelection->maxQuantity;
} else {
$effectiveQuantity = $roomSelection->quantity ?? 0;
}
$totalCapacity = $effectiveQuantity * $roomSelection->capacity;
// Calculate total capacity for this room type from the step 1 selection
$totalCapacity = ($roomSelection->quantity ?? 0) * $roomSelection->capacity;
// Count current occupancy (excluding current participant)
$occupancy = 0;
@@ -182,12 +186,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
return; // Shouldn't happen
}
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
$effectiveQuantity = $roomSelection->maxQuantity;
} else {
$effectiveQuantity = $roomSelection->quantity ?? 0;
}
$totalCapacity = $effectiveQuantity * $roomSelection->capacity;
$totalCapacity = ($roomSelection->quantity ?? 0) * $roomSelection->capacity;
$currentOccupancy = count($participantsWithRoom);
$spacesNeeded = ($currentOccupancy + 1) - $totalCapacity;
@@ -8,7 +8,6 @@ use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
@@ -86,21 +85,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/
protected function registerFieldOptionProviders(): void
{
// Room assignment field provider (available for both create and edit workflows)
// Room assignment field provider (create only: edit mode renders the room as static text)
// Returns empty array when no room choices available
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = [];
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Create context: use selected rooms from step 1, filtered by participant age
// Use selected rooms from step 1, filtered by participant age
$choices = $this->filterRoomsByParticipantAge(
$bookingDto->getSelectedRooms(),
$bookingDto,
$participantIndex
);
} elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// Edit context: convert booked rooms to RoomSelectionDto for consistent handling
$choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms);
}
if (true === empty($choices)) {
@@ -782,31 +778,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
});
}
/**
* Converts booked Room objects to RoomSelectionDto for consistent form handling.
*
* In edit mode, we need to convert Room entities to RoomSelectionDto objects
* so the form can use the same choice_value and choice_label configuration
* as create mode, enabling consistent price display in the template.
*
* @param Room[] $bookedRooms Rooms from existing booking
*
* @return RoomSelectionDto[] Array of room selection DTOs
*/
private function convertBookedRoomsToSelectionDtos(array $bookedRooms): array
{
$selections = [];
foreach ($bookedRooms as $room) {
$selection = new RoomSelectionDto();
$selection->id = $room->id;
$selection->label = $room->label;
$selection->price = $room->price;
$selections[] = $selection;
}
return $selections;
}
/**
* Filters rental services by selected skipass duration.
*
-2
View File
@@ -55,12 +55,10 @@ class VoucherPurchaseStep2Type extends AbstractType
->add('phone', TextType::class, [
'label' => 'Telefon privat',
'required' => false,
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'required' => false,
'sanitize_html' => true,
])
;
}
+147 -1
View File
@@ -12,6 +12,7 @@ class ApiClient
{
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
private const int NAME_MAX_LENGTH = 255;
private const int PAGE_LIMIT = 1000;
/**
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
@@ -141,6 +142,139 @@ class ApiClient
]);
}
public function createContactsList(string $name): int
{
$this->assertConfigured();
$response = $this->request('POST', 'contactslist', [
'json' => [
'Name' => $name,
],
]);
$listId = $response['Data'][0]['ID'] ?? null;
if (null === $listId) {
throw new NewsletterProviderException(sprintf('Mailjet did not return an id for the new contact list %s', $name));
}
return (int) $listId;
}
/**
* Adds the contacts as subscribed, including any who unsubscribed from this list before.
* Mailjet processes the request as an asynchronous job.
*
* @param list<array{email: string, firstName: ?string}> $contacts
*
* @return int|null the job id
*/
public function addContactsToList(int $listId, array $contacts): ?int
{
$this->assertConfigured();
$payload = [];
foreach ($contacts as $contact) {
$entry = ['Email' => $this->normalizeEmail($contact['email'])];
$firstName = $this->normalizeName($contact['firstName']);
if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
$entry['Properties'] = [$this->contactMetadataFields['firstName'] => $firstName];
}
$payload[] = $entry;
}
$response = $this->request('POST', sprintf('contactslist/%d/managemanycontacts', $listId), [
'json' => [
'Action' => 'addforce',
'Contacts' => $payload,
],
]);
$jobId = $response['Data'][0]['JobID'] ?? null;
return null !== $jobId ? (int) $jobId : null;
}
/**
* @return list<int>
*/
public function getListContactIds(int $listId): array
{
$this->assertConfigured();
$contactIds = [];
$offset = 0;
do {
$response = $this->request('GET', 'listrecipient', [
'query' => [
'ContactsList' => $listId,
'Limit' => self::PAGE_LIMIT,
'Offset' => $offset,
],
]);
$entries = true === is_array($response['Data'] ?? null) ? $response['Data'] : [];
foreach ($entries as $entry) {
if (true === is_array($entry) && true === isset($entry['ContactID'])) {
$contactIds[] = (int) $entry['ContactID'];
}
}
$offset += self::PAGE_LIMIT;
} while (self::PAGE_LIMIT === count($entries));
return array_values(array_unique($contactIds));
}
/**
* Lists the contact is on, subscribed or not. Mailjet includes lists deleted within the last 60 days.
*
* @return list<int>
*/
public function getContactListIds(int $contactId): array
{
$this->assertConfigured();
$response = $this->request('GET', sprintf('contact/%d/getcontactslists', $contactId), [
'allow_404' => true,
]);
$listIds = [];
foreach ($response['Data'] ?? [] as $entry) {
if (true === is_array($entry) && true === isset($entry['ListID'])) {
$listIds[] = (int) $entry['ListID'];
}
}
return $listIds;
}
public function deleteContactsList(int $listId): void
{
$this->assertConfigured();
$this->request('DELETE', sprintf('contactslist/%d', $listId), [
'allow_404' => true,
'allow_empty' => true,
]);
}
/**
* GDPR deletion: Mailjet anonymises the contact immediately and purges it after 30 days.
* This cannot be undone.
*/
public function deleteContact(int $contactId): void
{
$this->assertConfigured();
$this->request('DELETE', sprintf('contacts/%d', $contactId), [
'allow_404' => true,
'allow_empty' => true,
'api_version' => 'v4',
]);
}
private function findOrCreateContactId(string $email): int
{
$contactId = $this->resolveContactId($email);
@@ -246,15 +380,17 @@ class ApiClient
{
$allow404 = true === ($options['allow_404'] ?? false);
$allowEmpty = true === ($options['allow_empty'] ?? false);
$baseUrl = 'v4' === ($options['api_version'] ?? null) ? $this->getV4BaseUrl() : $this->getBaseUrl();
unset($options['allow_404']);
unset($options['allow_empty']);
unset($options['api_version']);
$resourcePath = trim($resource, '/');
try {
$response = $this->httpClient->request(
$method,
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
sprintf('%s/%s', $baseUrl, $resourcePath),
array_merge($options, [
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
])
@@ -310,6 +446,16 @@ class ApiClient
return rtrim($baseUrl, '/');
}
/**
* The configured base URL points at v3 (`…/v3/REST`); the v4 API sits beside it on the same host.
*/
private function getV4BaseUrl(): string
{
$baseUrl = preg_replace('~/v3/REST$~i', '', $this->getBaseUrl());
return sprintf('%s/v4', $baseUrl);
}
private function assertConfigured(): void
{
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* One entry of TYPO3's `/api/calendar-hotels`: the calendar hotel of a visible product.
*
* TYPO3 also lists the products per hotel; only the code and the name are kept here.
*/
final readonly class CmsCalendarHotel
{
public function __construct(
public string $code,
public string $name,
) {
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery
$hasFrom = null !== $from && '' !== $from;
$hasTo = null !== $to && '' !== $to;
// Neither supplied: the caller wants the full priced span, which the persisted prices define.
// Neither supplied: the caller wants the full span, which each endpoint defines for itself.
if (!$hasFrom && !$hasTo) {
return;
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Enum\ContingentHotelSource;
/**
* A hotel code the contingent snapshot is kept for, with whatever it is known as locally.
*/
final readonly class ContingentHotel
{
public function __construct(
public string $code,
public ?Accommodation $accommodation,
public ?CalendarHotel $calendarHotel,
) {
}
/**
* @return list<ContingentHotelSource>
*/
public function sources(): array
{
$sources = [];
if (null !== $this->accommodation) {
$sources[] = ContingentHotelSource::Accommodation;
}
if (null !== $this->calendarHotel) {
$sources[] = ContingentHotelSource::Typo3;
}
return $sources;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\CalendarHotel;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CalendarHotel>
*/
class CalendarHotelRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CalendarHotel::class);
}
/**
* @return array<string, CalendarHotel>
*/
public function findAllIndexedByCode(): array
{
$indexed = [];
foreach ($this->findAll() as $hotel) {
$indexed[(string) $hotel->getCode()] = $hotel;
}
return $indexed;
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -20,53 +19,30 @@ class ContingentDayRepository extends ServiceEntityRepository
}
/**
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
* identified by the given hotel code, keyed by Y-m-d.
* Returns the snapshot days of a hotel code within [dateFrom, dateTo], keyed by Y-m-d.
* A null dateTo means everything from dateFrom to the end of the snapshot.
*
* @return array<string, ContingentDay>
*/
public function findByHotelCodeAndDateRange(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
?\DateTimeImmutable $dateTo,
): array {
/** @var ContingentDay[] $days */
$days = $this->createQueryBuilder('cd')
->join('cd.accommodation', 'a')
->where('a.calendarCode = :hotelCode')
$qb = $this->createQueryBuilder('cd')
->where('cd.hotelCode = :hotelCode')
->andWhere('cd.date >= :dateFrom')
->andWhere('cd.date <= :dateTo')
->setParameter('hotelCode', $hotelCode)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->orderBy('cd.date', 'ASC')
->getQuery()
->getResult();
->orderBy('cd.date', 'ASC');
return $this->indexByDate($days);
}
if (null !== $dateTo) {
$qb->andWhere('cd.date <= :dateTo')
->setParameter('dateTo', $dateTo);
}
/**
* Same as above but for a known accommodation; used by the sync to diff against.
*
* @return array<string, ContingentDay>
*/
public function findByAccommodationAndDateRange(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): array {
/** @var ContingentDay[] $days */
$days = $this->createQueryBuilder('cd')
->where('cd.accommodation = :accommodation')
->andWhere('cd.date >= :dateFrom')
->andWhere('cd.date <= :dateTo')
->setParameter('accommodation', $accommodation)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->orderBy('cd.date', 'ASC')
->getQuery()
->getResult();
$days = $qb->getQuery()->getResult();
return $this->indexByDate($days);
}
@@ -84,6 +60,21 @@ class ContingentDayRepository extends ServiceEntityRepository
->execute();
}
/**
* Drops the days of every hotel code that is no longer synced.
*
* @param non-empty-list<string> $hotelCodes the codes that are still synced
*/
public function deleteExceptHotelCodes(array $hotelCodes): int
{
return (int) $this->createQueryBuilder('cd')
->delete()
->where('cd.hotelCode NOT IN (:hotelCodes)')
->setParameter('hotelCodes', $hotelCodes)
->getQuery()
->execute();
}
/**
* @param ContingentDay[] $days
*
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentSyncState;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -19,30 +18,37 @@ class ContingentSyncStateRepository extends ServiceEntityRepository
parent::__construct($registry, ContingentSyncState::class);
}
public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState
public function findOneByHotelCode(string $hotelCode): ?ContingentSyncState
{
return $this->findOneBy(['accommodation' => $accommodation]);
return $this->findOneBy(['hotelCode' => $hotelCode]);
}
/**
* Returns every sync state keyed by accommodation id, for the revisions endpoint.
*
* @return array<int, ContingentSyncState>
* @return array<string, ContingentSyncState>
*/
public function findAllIndexedByAccommodationId(): array
public function findAllIndexedByHotelCode(): array
{
$indexed = [];
foreach ($this->findAll() as $state) {
$accommodation = $state->getAccommodation();
if (null === $accommodation || null === $accommodation->getId()) {
continue;
}
$indexed[$accommodation->getId()] = $state;
$indexed[(string) $state->getHotelCode()] = $state;
}
return $indexed;
}
/**
* Drops the sync state of every hotel code that is no longer synced.
*
* @param non-empty-list<string> $hotelCodes the codes that are still synced
*/
public function deleteExceptHotelCodes(array $hotelCodes): int
{
return (int) $this->createQueryBuilder('s')
->delete()
->where('s.hotelCode NOT IN (:hotelCodes)')
->setParameter('hotelCodes', $hotelCodes)
->getQuery()
->execute();
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TravelMailingList;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TravelMailingList>
*/
class TravelMailingListRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TravelMailingList::class);
}
public function findOneByName(string $name): ?TravelMailingList
{
return $this->findOneBy(['name' => $name]);
}
/**
* @return list<TravelMailingList>
*/
public function findByTravelCode(string $travelCode): array
{
return $this->findBy(['travelCode' => $travelCode], ['id' => 'ASC']);
}
/**
* @return list<TravelMailingList>
*/
public function findDueForDeletion(\DateTimeImmutable $now): array
{
return $this->createQueryBuilder('l')
->where('l.deleteAfter <= :now')
->setParameter('now', $now)
->orderBy('l.deleteAfter', 'ASC')
->getQuery()
->getResult();
}
}
+7 -14
View File
@@ -63,7 +63,7 @@ class BookingEditDraftMerger
// Room assignment
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
$this->applyRoomAssignment($participant, $data['roomAssignment']);
}
// License plate
@@ -177,26 +177,19 @@ class BookingEditDraftMerger
}
/**
* Applies the drafted room assignment to the participant.
* Applies the drafted room remarks to the participant.
*
* The room id uses the same merge strategy as single-select services: it is only
* applied when it resolves to a room of the current travel. A draft may therefore move
* a participant to another room, but never unassign one. Edit mode renders the room as
* static text and never submits assignedRoomId, so a null in the draft is only ever an
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
* would drop the participant's room, both in the UI and in the outbound zuordnung.
* The drafted assignedRoomId is ignored. Edit mode renders the room as static text and
* never submits it, so the draft only holds what BusPro reported when it was saved, and
* replaying it would undo a reassignment the office made since. The room always comes
* from the booking as loaded.
*
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
*
* @param array<string, mixed> $data
*/
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
{
$assignedRoomId = $data['assignedRoomId'] ?? null;
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
$participant->assignedRoomId = (int) $assignedRoomId;
}
if (true === array_key_exists('remarksRoom', $data)) {
$participant->remarksRoom = $data['remarksRoom'];
}
+35
View File
@@ -2,6 +2,7 @@
namespace App\Service;
use App\Model\CmsCalendarHotel;
use App\Model\CmsHotelData;
use App\Model\CmsRegionData;
use Symfony\Contracts\Cache\CacheInterface;
@@ -169,6 +170,40 @@ class CmsDataProvider
return is_numeric($value) ? (float) $value : null;
}
/**
* The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a
* usable list. Uncached: the only caller keeps its own copy of the last good answer.
*
* @return list<CmsCalendarHotel>|null
*/
public function fetchCalendarHotels(): ?array
{
try {
$data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray();
} catch (ExceptionInterface) {
return null;
}
if (!array_is_list($data)) {
return null;
}
$hotels = [];
foreach ($data as $entry) {
$code = $entry['code'] ?? null;
if (!is_string($code) || '' === $code) {
continue;
}
$name = $entry['name'] ?? null;
$hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code);
}
return $hotels;
}
/** @return array<string, mixed> */
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
{
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\CalendarHotel;
use App\Model\ContingentHotel;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\CalendarHotelRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
* the calendar hotels of TYPO3's visible products.
*
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
* TYPO3 does not make its hotels drop out of the sync.
*/
class ContingentHotelDirectory
{
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
// for is not worth syncing, and it would not fit the column either.
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
public function __construct(
private readonly CmsDataProvider $cmsDataProvider,
private readonly AccommodationRepository $accommodationRepository,
private readonly CalendarHotelRepository $calendarHotelRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
*
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
* a fault on its side, not every winter hotel being withdrawn at once.
*/
public function refresh(): bool
{
$fetched = [];
foreach ($this->cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) {
if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) {
$this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]);
continue;
}
$fetched[$hotel->code] = $hotel;
}
if ([] === $fetched) {
$this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list');
return false;
}
$stored = $this->calendarHotelRepository->findAllIndexedByCode();
foreach ($fetched as $code => $hotel) {
$calendarHotel = $stored[$code] ?? null;
if (null === $calendarHotel) {
$this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name));
} elseif ($calendarHotel->getName() !== $hotel->name) {
$calendarHotel->setName($hotel->name);
}
}
foreach (array_diff_key($stored, $fetched) as $calendarHotel) {
$this->entityManager->remove($calendarHotel);
}
$this->entityManager->flush();
return true;
}
/**
* @return array<string, ContingentHotel> keyed and sorted by hotel code
*/
public function all(): array
{
$accommodations = [];
foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) {
$accommodations[(string) $accommodation->getCalendarCode()] = $accommodation;
}
$calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode();
$hotels = [];
foreach (array_keys($accommodations + $calendarHotels) as $code) {
$code = (string) $code;
$hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null);
}
ksort($hotels, \SORT_STRING);
return $hotels;
}
public function find(string $hotelCode): ?ContingentHotel
{
if ('' === $hotelCode) {
return null;
}
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
$calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]);
if (null === $accommodation && null === $calendarHotel) {
return null;
}
return new ContingentHotel($hotelCode, $accommodation, $calendarHotel);
}
}
+7 -10
View File
@@ -6,7 +6,6 @@ namespace App\Service;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Model\ContingentSyncResult;
@@ -34,24 +33,22 @@ class ContingentSnapshotManager
}
/**
* Refreshes [dateFrom, dateTo] for a single accommodation.
* Refreshes [dateFrom, dateTo] for a single hotel code.
*
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
* the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a
* full-horizon run already stored.
*/
public function sync(
Accommodation $accommodation,
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): ContingentSyncResult {
$hotelCode = $accommodation->getCalendarCode();
if (null === $hotelCode || '' === $hotelCode) {
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
if ('' === $hotelCode) {
return ContingentSyncResult::failed('No hotel code given.');
}
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode);
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
// are the window growing, not the contingent situation changing.
@@ -67,7 +64,7 @@ class ContingentSnapshotManager
return $this->recordFailure($state, $hotelCode, $e->getMessage());
}
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
$existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
// An empty upstream response would otherwise wipe the window and render the whole
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
@@ -103,7 +100,7 @@ class ContingentSnapshotManager
if (null === $day) {
$day = (new ContingentDay())
->setAccommodation($accommodation)
->setHotelCode($hotelCode)
->setDate($date);
$this->entityManager->persist($day);
+27 -18
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use Carbon\CarbonImmutable;
@@ -41,22 +41,20 @@ class ContingentSnapshotReader
* Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel.
*
* Days inside the range that the snapshot does not cover are simply absent from the map;
* what that means is the caller's decision.
* what that means is the caller's decision. A null dateTo reads to the end of the snapshot.
*
* @return array<string, ContingentStatus>|null
*/
public function statusesFor(
Accommodation $accommodation,
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
?\DateTimeImmutable $dateTo,
): ?array {
$hotelCode = $accommodation->getCalendarCode();
if (null === $hotelCode || '' === $hotelCode) {
if ('' === $hotelCode) {
return null;
}
if (!$this->isUsable($accommodation, $hotelCode)) {
if (!$this->isUsable($hotelCode)) {
return null;
}
@@ -69,25 +67,36 @@ class ContingentSnapshotReader
return $statuses;
}
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
/**
* Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without
* the logging: for overviews that ask about every hotel at once.
*/
public function isCurrent(?ContingentSyncState $state): bool
{
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
$syncedAt = $state?->getSyncedAt();
return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE);
}
private function isUsable(string $hotelCode): bool
{
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode);
if ($this->isCurrent($state)) {
return true;
}
$syncedAt = $state?->getSyncedAt();
if (null === $syncedAt) {
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
return false;
}
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
} else {
$this->logger->error('Contingent snapshot is stale', [
'hotelCode' => $hotelCode,
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
]);
return false;
}
return true;
return false;
}
}
+1
View File
@@ -67,6 +67,7 @@ class ParticipantDataPrefiller
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->loadedMobile = $prepopulated->loadedMobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
+3 -10
View File
@@ -147,12 +147,8 @@ class RoomPricingCalculator
}
}
// Count current participant assignments from the DTO (reflects live state
// including new assignments not yet in the booking entity)
$assignmentCounts = $bookingDto->getRoomAssignmentCounts();
// Build pricing array
foreach ($roomGroups as $roomId => $data) {
foreach ($roomGroups as $data) {
$room = $data['room'];
$participantCount = $data['participantCount'];
$totalPrice = $data['totalPrice'];
@@ -160,14 +156,11 @@ class RoomPricingCalculator
// Calculate average unit price (price per person)
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
// Use live assignment count for quantity display so the sidebar reflects
// new room assignments that haven't been persisted to the booking entity yet
$quantity = $assignmentCounts[$roomId] ?? $room->totalCount;
$roomPricing[] = [
'roomId' => $room->id,
'label' => $room->label,
'quantity' => $quantity,
// Rooms, not occupants: room assignments cannot change in edit mode
'quantity' => $room->totalCount,
'participantCount' => $participantCount,
'unitPrice' => $unitPrice,
'totalPrice' => $totalPrice,
@@ -219,6 +219,10 @@ class ServiceAvailabilityCalculator
/**
* Calculate how many times each service has been selected by other participants.
*
* Only new selections count. In edit mode the form is prefilled with the live booking, whose
* seats BusPro has already deducted from frei; counting them again would report a contingent
* as exhausted while seats are still free.
*
* @param BookingDto $bookingDto The booking data with participant selections
* @param int $currentParticipantIndex The index of the participant currently filling the form
*
@@ -238,6 +242,10 @@ class ServiceAvailabilityCalculator
$participantUsage = $this->countParticipantServiceUsage($participant);
foreach ($participantUsage as $serviceId => $count) {
if (null !== $bookingDto->booking && $bookingDto->booking->hasServiceForParticipant($index, $serviceId)) {
--$count;
}
$serviceUsage[$serviceId] = ($serviceUsage[$serviceId] ?? 0) + $count;
}
}
+12
View File
@@ -432,6 +432,18 @@ class TravelDataProvider
return $this->travelLookupService->mapDateCodeToLabel($dateCode);
}
/**
* Map date code to the travel's start date.
*
* @param string $dateCode The date code to map
*
* @return \DateTimeImmutable|null The start date or null if not found
*/
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
{
return $this->travelLookupService->mapDateCodeToStartDate($dateCode);
}
/**
* Map hotel code to hotel ID.
*
+20
View File
@@ -127,6 +127,26 @@ class TravelIndex
return $this->generateFilesMap()[$dateId]['label'] ?? null;
}
/**
* Resolve a date code to the travel's start date.
*
* @param string $dateCode The date code to resolve
*
* @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none
*/
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
{
$dateId = $this->mapDateCodeToId($dateCode);
if (null === $dateId) {
return null;
}
$dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null;
return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null;
}
/**
* Convert a hotel code string to its corresponding hotel ID.
*
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\Entity\TravelMailingList;
use App\Enum\BusLeg;
use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Creates Mailjet contact lists for a travel (all participants, bus passengers only) and deletes
* them again 30 days after the travel started, together with every contact that is on no other
* Mailjet list.
*/
class TravelMailingListManager
{
private const string DELETE_AFTER = '+30 days';
public function __construct(
private readonly ApiClient $mailjet,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @param TravelMailingRecipient[] $recipients
* @param ?BusLeg $busLeg the bus leg the recipients were filtered by, if any
*
* @throws NewsletterProviderException
*/
public function create(string $travelCode, array $recipients, ?BusLeg $busLeg, \DateTimeImmutable $travelStartsAt): TravelMailingList
{
$listId = $this->mailjet->createContactsList(TravelMailingList::nameFor($travelCode, $busLeg));
// Tracked before the contacts go in, so a list Mailjet already holds is never left without a row
// that the cleanup would find.
$mailingList = new TravelMailingList($travelCode, $listId, $busLeg, $travelStartsAt->modify(self::DELETE_AFTER));
$this->entityManager->persist($mailingList);
$this->entityManager->flush();
try {
$jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients));
} catch (NewsletterProviderException $exception) {
$this->discardEmptyList($mailingList);
throw $exception;
}
$this->logger->info('Created Mailjet travel mailing list', [
'travel_code' => $travelCode,
'name' => $mailingList->getName(),
'mailjet_list_id' => $listId,
'mailjet_job_id' => $jobId,
'bus_leg' => $busLeg?->value,
'recipients' => count($recipients),
]);
return $mailingList;
}
/**
* Deletes all lists of one travel together. Taken one at a time, a contact on both would
* never count as orphaned: Mailjet still reports a list for 60 days after its deletion.
*
* The orphaned contacts go first and the lists last, so a run that fails halfway can be
* repeated: the lists still name whoever is left.
*
* @param TravelMailingList[] $mailingLists
*
* @return int the number of contacts deleted
*
* @throws NewsletterProviderException
*/
public function delete(array $mailingLists): int
{
$listIds = array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $mailingLists);
$contactIds = [];
foreach ($listIds as $listId) {
array_push($contactIds, ...$this->mailjet->getListContactIds($listId));
}
$deletedContacts = 0;
foreach (array_unique($contactIds) as $contactId) {
// Anyone who is, or recently was, on another list is kept: their newsletter consent or
// another travel's mailing still needs the contact.
if ([] !== array_diff($this->mailjet->getContactListIds($contactId), $listIds)) {
continue;
}
$this->mailjet->deleteContact($contactId);
++$deletedContacts;
}
$this->logger->info('Deleted orphaned travel mailing contacts', [
'mailjet_list_ids' => $listIds,
'deleted_contacts' => $deletedContacts,
]);
foreach ($mailingLists as $mailingList) {
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
$this->entityManager->remove($mailingList);
$this->entityManager->flush();
$this->logger->info('Deleted Mailjet travel mailing list', [
'travel_code' => $mailingList->getTravelCode(),
'name' => $mailingList->getName(),
'mailjet_list_id' => $mailingList->getMailjetListId(),
]);
}
return $deletedContacts;
}
/**
* @param TravelMailingRecipient[] $recipients
*
* @return list<array{email: string, firstName: ?string}>
*/
private function buildContacts(array $recipients): array
{
// Several participants can share one address; the first name that comes with it wins.
$contacts = [];
foreach ($recipients as $recipient) {
$email = mb_strtolower(trim($recipient->email));
if ('' === $email || true === isset($contacts[$email])) {
continue;
}
$contacts[$email] = [
'email' => $email,
'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null,
];
}
return array_values($contacts);
}
private function discardEmptyList(TravelMailingList $mailingList): void
{
try {
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
} catch (NewsletterProviderException $exception) {
// The row stays, so the scheduled cleanup removes the list later.
$this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [
'travel_code' => $mailingList->getTravelCode(),
'name' => $mailingList->getName(),
'mailjet_list_id' => $mailingList->getMailjetListId(),
'error' => $exception->getMessage(),
]);
return;
}
$this->entityManager->remove($mailingList);
$this->entityManager->flush();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\RegexValidator;
use function Symfony\Component\String\u;
/**
* Limits a phone number to digits, spaces, slashes and hyphens, with an optional leading plus.
*
* Only applied to numbers the user entered or changed: BusPro already holds numbers that break
* this rule, and one loaded from BusPro has to go back unchanged, or an edit is rejected for the
* mismatch. The DTOs remember the loaded number and gate the constraint with isNewlyEntered().
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class PhoneNumber extends Regex
{
public const PATTERN = '~^\+?[0-9][0-9 /\-]*$~';
/** @param string[]|null $groups */
public function __construct(?array $groups = null, mixed $payload = null)
{
parent::__construct(
pattern: self::PATTERN,
message: 'Bitte nur Ziffern, Leerzeichen, /, - und ein führendes + verwenden',
groups: $groups,
payload: $payload,
);
}
public static function isNewlyEntered(?string $value, ?string $loaded): bool
{
return u($value ?? '')->trim()->toString() !== u($loaded ?? '')->trim()->toString();
}
public function validatedBy(): string
{
return RegexValidator::class;
}
}
@@ -7,3 +7,6 @@
{% if app.session.flashBag.peek('info')|length > 0 %}
{% include '_partials/_toast.html.twig' with { level: 'info', messages: app.flashes('info') } %}
{% endif %}
{% if app.session.flashBag.peek('warning')|length > 0 %}
{% include '_partials/_toast.html.twig' with { level: 'warning', messages: app.flashes('warning') } %}
{% endif %}
+37 -2
View File
@@ -9,6 +9,9 @@
<div class="w-64">
{{ form_row(form.travelCode) }}
</div>
<div>
{{ form_row(form.busOnly) }}
</div>
<button type="submit"
class="button button--primary button--small">
abrufen
@@ -26,7 +29,7 @@
{% if result.data is empty %}
{% include '_partials/_alert.html.twig' with {
level: 'warning',
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)],
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer%s.'|format(result.travelCode|e, result.busLeg ? ' unter den ' ~ result.busLeg.label : '')],
} %}
{% else %}
<div class="pb-4">
@@ -34,9 +37,41 @@
{{ travel_label|default('Reise nicht im lokalen Reisebestand') }}
</h2>
<p class="text-sm text-gray-700">
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>{% if result.busLeg %}, nur {{ result.busLeg.label }}{% endif %}
</p>
</div>
<div class="pb-6 text-sm">
{% for list in mailing_lists %}
<p>
Mailjet-Liste <code>{{ list.name }}</code> (ID {{ list.mailjetListId }},
{{ list.busLeg ? 'nur ' ~ list.busLeg.label : 'alle Teilnehmer' }})
am {{ list.createdAt|date('d.m.Y, H:i') }} angelegt,
wird ab {{ list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
</p>
{% endfor %}
{% if mailing_list is not null %}
{# the list for this filter exists and is listed above #}
{% elseif travel_starts_at is null %}
<p class="text-gray-700">
Kein Reisebeginn bekannt, daher kann keine Mailjet-Liste angelegt werden.
</p>
{% else %}
<form method="post" action="{{ path('app_admin_travelmailing_createlist') }}"{% if mailing_lists is not empty %} class="pt-2"{% endif %}>
<input type="hidden" name="travelCode" value="{{ result.travelCode }}">
{% if result.busLeg %}
<input type="hidden" name="busOnly" value="1">
{% endif %}
<input type="hidden" name="_token" value="{{ csrf_token(constant('App\\Controller\\Admin\\TravelMailing\\CreateListController::CSRF_TOKEN_ID')) }}">
<button type="submit" class="button button--primary button--small">
Mailjet-Liste {{ list_name }} anlegen
</button>
<span class="pl-2 text-gray-700">
Alle Empfänger werden ohne Opt-in eingetragen; die Liste wird 30 Tage nach Reisebeginn
({{ travel_starts_at|date('d.m.Y') }}) gelöscht.
</span>
</form>
{% endif %}
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
+38
View File
@@ -7,6 +7,7 @@ namespace App\Tests\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\TravelsClient;
use App\Enum\BusLeg;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
@@ -35,12 +36,49 @@ class TravelsClientTest extends TestCase
self::assertSame('https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126', $requests[0][1]);
self::assertContains('X-API-KEY: secret', $requests[0][2]['headers']);
self::assertSame('LPJMP281126', $response->travelCode);
self::assertNull($response->busLeg);
self::assertEquals([
new TravelMailingRecipient('[email protected]', 'Erika', 'Muster'),
new TravelMailingRecipient('[email protected]', 'Max', 'Muster'),
], $response->data);
}
public function testGetMailingRecipientsPassesTheBusLegAndAcceptsItsEcho(): void
{
$requests = [];
$http = new MockHttpClient(function (string $method, string $url) use (&$requests): MockResponse {
$requests[] = $url;
return $this->jsonResponse([
'meta' => ['travel_code' => 'LPJMP281126', 'bus_leg' => 'outbound', 'row_count' => 1, 'execution_time_ms' => 12],
'data' => [
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
],
]);
});
$response = $this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
self::assertSame(['https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126&busLeg=outbound'], $requests);
self::assertSame(BusLeg::Outbound, $response->busLeg);
self::assertCount(1, $response->data);
}
public function testABusLegTheServerDidNotApplyIsRejected(): void
{
// What a bpn-connect without the filter answers: everyone, and no bus_leg in meta.
$http = new MockHttpClient($this->jsonResponse([
'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 1, 'execution_time_ms' => 12],
'data' => [
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
],
]));
$this->expectException(BpnConnectException::class);
$this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
}
public function testAnUnknownTravelCodeYieldsAnEmptyList(): void
{
$http = new MockHttpClient($this->jsonResponse([
@@ -342,6 +342,66 @@ class BookingDataProcessorTest extends TestCase
$this->assertEquals('07.01.2024', $room['@abreise']);
}
public function testUpdatePayloadSendsBookedRoomsUnchanged(): void
{
$formData = $this->createFormDataWithBookedRooms();
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3,4'], 3 => ['3', '5,6,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadDropsCanceledParticipantsFromRooms(): void
{
$formData = $this->createFormDataWithBookedRooms();
$formData->participants[5]->status = 'S';
$formData->booking->participantsStatus[5] = 'S';
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3,4'], 3 => ['3', '5,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadKeepsRoomChangesMadeInBusProDuringTheEdit(): void
{
$formData = $this->createFormDataWithBookedRooms();
// The office moved participant 4 into the doubles after the edit session was loaded
$formData->booking->rooms[95]->mapping = [0, 1, 2];
$formData->booking->rooms[3]->totalCount = 4;
$formData->booking->rooms[3]->mapping = [3, 4, 5, 6, 7, 8, 9];
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3'], 3 => ['4', '4,5,6,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadKeepsARoomTypeAddedInBusProDuringTheEdit(): void
{
$formData = $this->createFormDataWithBookedRooms();
// The office moved participants 1 and 2 out of the 4-bed room into a new double
$formData->booking->rooms[95]->mapping = [2, 3];
$formData->booking->rooms[95]->totalCount = 1;
$formData->booking->rooms[7] = $this->createBookedRoom(7, 'DZ', 1, [0, 1]);
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '3,4'], 3 => ['3', '5,6,7,8,9,10'], 7 => ['1', '1,2']],
$this->roomsInPayload($result)
);
}
public function testCommunicationObjectCreation(): void
{
$formData = $this->createFormDataWithoutExistingCommunication();
@@ -526,6 +586,60 @@ class BookingDataProcessorTest extends TestCase
return $formData;
}
/**
* Ten participants as BusPro reports them: four in one 4-bed room, six in three doubles.
*/
private function createFormDataWithBookedRooms(): BookingDto
{
$bookingDto = new BookingDto($this->createMockTravel(), 1);
$bookingDto->booking = $this->createMockBooking();
$bookingDto->booking->participants = [];
$bookingDto->booking->participantsStatus = [];
$bookingDto->participants = [];
for ($index = 0; $index < 10; ++$index) {
$bookingDto->booking->participants[$index] = $this->createMockPersonalData("Participant{$index}");
$bookingDto->booking->participantsStatus[$index] = 'F';
$bookingDto->participants[$index] = $this->createMockParticipantDto($index, 'F');
$bookingDto->participants[$index]->assignedRoomId = $index < 4 ? 95 : 3;
}
$bookingDto->booking->rooms = [
95 => $this->createBookedRoom(95, '4er', 1, [0, 1, 2, 3]),
3 => $this->createBookedRoom(3, 'DZ', 3, [4, 5, 6, 7, 8, 9]),
];
return $bookingDto;
}
/** @param list<int> $mapping */
private function createBookedRoom(int $id, string $category, int $totalCount, array $mapping): Room
{
$room = new Room();
$room->id = $id;
$room->category = $category;
$room->boardId = 2;
$room->totalCount = $totalCount;
$room->mapping = $mapping;
return $room;
}
/**
* @param array<string, mixed> $payload
*
* @return array<int, array{string, string}> Room id => [anzahl, zuordnung]
*/
private function roomsInPayload(array $payload): array
{
$rooms = [];
foreach ($payload['ferienzielunterbringungen']['ferienzielunterbringung'] as $room) {
$rooms[$room['@idzimmer']] = [(string) $room['@anzahl'], $room['@zuordnung']];
}
return $rooms;
}
private function createFormDataWithoutExistingCommunication(): BookingDto
{
$formData = $this->createCompleteFormData();
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\Utility;
use App\BusProNet\Utility\XmlCharacterFilter;
use App\BusProNet\XmlCrawlerFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class XmlCharacterFilterTest extends TestCase
{
public function testNulPaddedFieldNoLongerBreaksTheResponse(): void
{
// Shape of a real KUNDENKONTO/Adressdaten response with a NUL-padded Geburtsort.
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz>'
.'<idperson>11297</idperson><adressdaten><name>Muster</name>'
.'<geburtsort>'.str_repeat('&#x0;', 40).'</geburtsort><beruf></beruf></adressdaten></ergebnis>';
$this->assertCount(0, XmlCrawlerFactory::create($xml));
$filtered = XmlCharacterFilter::strip($xml, $count);
$this->assertSame(40, $count);
$crawler = XmlCrawlerFactory::create($filtered);
$this->assertSame('Muster', $crawler->filterXPath('//adressdaten/name')->text());
$this->assertSame('', $crawler->filterXPath('//adressdaten/geburtsort')->text());
}
/** @return iterable<string, array{string}> */
public static function invalidProvider(): iterable
{
yield 'hex NUL' => ['&#x0;'];
yield 'hex NUL, padded and upper case' => ['&#X0000;'];
yield 'decimal NUL' => ['&#0;'];
yield 'vertical tab' => ['&#x0B;'];
yield 'decimal 31' => ['&#31;'];
yield 'surrogate' => ['&#xD800;'];
yield 'noncharacter FFFE' => ['&#xFFFE;'];
yield 'beyond Unicode' => ['&#x110000;'];
yield 'raw NUL byte' => ["\x00"];
yield 'raw escape byte' => ["\x1B"];
}
#[DataProvider('invalidProvider')]
public function testRemovesInvalidCharacters(string $invalid): void
{
$this->assertSame('<a>xy</a>', XmlCharacterFilter::strip('<a>x'.$invalid.'y</a>', $count));
$this->assertSame(1, $count);
}
/** @return iterable<string, array{string}> */
public static function validProvider(): iterable
{
yield 'tab' => ['&#x9;'];
yield 'line feed' => ['&#10;'];
yield 'carriage return' => ['&#xD;'];
yield 'space' => ['&#32;'];
yield 'umlaut' => ['&#xFC;'];
yield 'emoji' => ['&#x1F600;'];
yield 'named entity' => ['&amp;'];
yield 'raw tab and newline' => ["\t\r\n"];
yield 'raw UTF-8' => ['Grüße 😀'];
}
#[DataProvider('validProvider')]
public function testKeepsAllowedCharacters(string $valid): void
{
$xml = '<a>x'.$valid.'y</a>';
$this->assertSame($xml, XmlCharacterFilter::strip($xml, $count));
$this->assertSame(0, $count);
}
}
@@ -19,9 +19,9 @@ use Symfony\Contracts\Cache\CacheInterface;
/**
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
*
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
* availability rule read the status from the nightly XML export instead.
* Dropping the status meant every availability rule read it from the nightly XML export instead.
* The statuses here are already in export vocabulary; the parsers translate BusPro's live
* "buchbar" / "ausgebucht" (see AvailabilitiesParserTest).
*/
class TravelLoaderAvailabilityTest extends TestCase
{
@@ -45,10 +45,10 @@ class TravelLoaderAvailabilityTest extends TestCase
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
$this->createResponse([$this->createAvailability(60, Constants::STATUS_BLOCKED, 3)])
);
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
$this->assertSame(3, $service->available);
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\Constants;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AvailabilitiesParser;
use PHPUnit\Framework\TestCase;
/**
* The service lines are taken from recorded VERFUEGBARKEIT responses (travels 12855 and 12833).
*/
class AvailabilitiesParserTest extends TestCase
{
private AvailabilitiesParser $parser;
protected function setUp(): void
{
$this->parser = new AvailabilitiesParser();
}
public function testMapsLiveServiceStatusOntoExportVocabulary(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="VERFUEGBARKEIT"/>
<reise id="12855" status="frei" buchungstatusmoeglich="FOA"/>
<leistungen>
<leistung id="201200" bezeichnung="Begleitperson (gemäß Gruppenregelung)" art="SON" unterart="SON" status="buchbar" frei="3" preis="0,00" preisfaellig="Einmalig"/>
<leistung id="201590" bezeichnung="Begleitperson (gemäß Gruppenregelung)" art="SON" unterart="SON" status="ausgebucht" anfragemoeglich="J" frei="0" preis="0,00" preisfaellig="Einmalig"/>
</leistungen>
</ergebnis>';
$services = $this->parser->parseServices(XmlCrawlerFactory::create($xml))->getServices();
$this->assertSame(Constants::STATUS_AVAILABLE, $services[201200]->status);
$this->assertSame(3, $services[201200]->available);
$this->assertSame(Constants::STATUS_BLOCKED, $services[201590]->status);
$this->assertSame(0, $services[201590]->available);
}
public function testPassesExportVocabularyAndUnknownStatusThrough(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<leistungen>
<leistung id="1" status="Anfrage" frei="2" preis="0,00"/>
<leistung id="2" status="unbekannt" frei="2" preis="0,00"/>
<leistung id="3" frei="2" preis="0,00"/>
</leistungen>
</ergebnis>';
$services = $this->parser->parseServices(XmlCrawlerFactory::create($xml))->getServices();
$this->assertSame(Constants::STATUS_ON_REQUEST, $services[1]->status);
$this->assertSame('unbekannt', $services[2]->status);
$this->assertNull($services[3]->status);
}
public function testLeavesTravelLevelStatusUntouched(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<reise id="12855" status="frei" buchungstatusmoeglich="FOA"/>
<leistungen/>
</ergebnis>';
$response = $this->parser->parseServices(XmlCrawlerFactory::create($xml));
$this->assertSame('frei', $response->travelStatus);
$this->assertSame(['F', 'O', 'A'], $response->allowedBookingStatus);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\Constants;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\ExtendedAvailabilitiesParser;
use PHPUnit\Framework\TestCase;
@@ -48,6 +49,22 @@ class ExtendedAvailabilitiesParserTest extends TestCase
$this->assertTrue($availability->mandatory);
}
public function testMapsLiveServiceStatusOntoExportVocabulary(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<leistungen>
<leistung id="103" status="buchbar" frei="3" preis="0,00" />
<leistung id="104" status="ausgebucht" anfragemoeglich="J" frei="0" preis="0,00" />
</leistungen>
</ergebnis>';
$services = $this->parser->parseServices(XmlCrawlerFactory::create($xml))->getServices();
$this->assertSame(Constants::STATUS_AVAILABLE, $services[103]->status);
$this->assertSame(Constants::STATUS_BLOCKED, $services[104]->status);
}
public function testIgnoresEmptyAgeAndMandatoryAttributes(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
+98 -19
View File
@@ -6,10 +6,12 @@ namespace App\Tests\Command;
use App\Command\BpnSyncContingentsCommand;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Model\ContingentHotel;
use App\Model\ContingentSyncResult;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentHotelDirectory;
use App\Service\ContingentSnapshotManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -53,36 +55,113 @@ class BpnSyncContingentsCommandTest extends TestCase
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testARunWithNoAccommodationsSucceeds(): void
public function testARunWithNoHotelsSucceeds(): void
{
$tester = $this->runSync([], []);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
/**
* @param string[] $hotelCodes
* @param ContingentSyncResult[] $results
*/
private function runSync(array $hotelCodes, array $results): CommandTester
public function testSyncsAccommodationAndTypo3CodesAndDropsTheRest(): void
{
$accommodations = array_map(
static fn (string $code) => (new Accommodation())->setCalendarCode($code),
$hotelCodes,
$manager = $this->createMock(ContingentSnapshotManager::class);
$synced = [];
$manager->expects(self::exactly(2))->method('sync')
->willReturnCallback(static function (string $code) use (&$synced): ContingentSyncResult {
$synced[] = $code;
return ContingentSyncResult::synced(false, 0, 0, 0, 0);
});
$dayRepository = $this->createMock(ContingentDayRepository::class);
$dayRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$syncStateRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
$this->runSync(
['DGS' => 'accommodation', 'DKI' => 'typo3'],
manager: $manager,
dayRepository: $dayRepository,
syncStateRepository: $syncStateRepository,
);
$accommodationRepository = $this->createStub(AccommodationRepository::class);
$accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations);
self::assertSame(['DGS', 'DKI'], $synced);
}
$manager = $this->createStub(ContingentSnapshotManager::class);
if ([] !== $results) {
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
public function testASingleHotelRunAcceptsATypo3OnlyCodeAndDropsNothing(): void
{
$manager = $this->createMock(ContingentSnapshotManager::class);
$manager->expects(self::once())->method('sync')
->with('DKI', self::anything(), self::anything())
->willReturn(ContingentSyncResult::synced(false, 0, 0, 0, 0));
$dayRepository = $this->createMock(ContingentDayRepository::class);
$dayRepository->expects(self::never())->method('deleteExceptHotelCodes');
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$syncStateRepository->expects(self::never())->method('deleteExceptHotelCodes');
$tester = $this->runSync(
['DGS' => 'accommodation', 'DKI' => 'typo3'],
manager: $manager,
dayRepository: $dayRepository,
syncStateRepository: $syncStateRepository,
options: ['--hotel' => 'DKI'],
);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testASingleHotelRunIgnoresAnUnknownCode(): void
{
$manager = $this->createMock(ContingentSnapshotManager::class);
$manager->expects(self::never())->method('sync');
$tester = $this->runSync(['DGS' => 'accommodation'], manager: $manager, options: ['--hotel' => 'NOPE']);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('No accommodation calendarCode or TYPO3 calendar hotel matched', $tester->getDisplay());
}
/**
* @param array<int|string, string> $hotels codes, or code => 'accommodation'|'typo3'
* @param ContingentSyncResult[] $results
* @param array<string, string> $options
*/
private function runSync(
array $hotels,
array $results = [],
?ContingentSnapshotManager $manager = null,
?ContingentDayRepository $dayRepository = null,
?ContingentSyncStateRepository $syncStateRepository = null,
array $options = [],
): CommandTester {
if (array_is_list($hotels)) {
$hotels = array_fill_keys($hotels, 'accommodation');
}
$directoryEntries = [];
foreach ($hotels as $code => $source) {
$code = (string) $code;
$directoryEntries[$code] = 'typo3' === $source
? new ContingentHotel($code, null, (new CalendarHotel())->setCode($code)->setName($code))
: new ContingentHotel($code, (new Accommodation())->setCalendarCode($code), null);
}
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('all')->willReturn($directoryEntries);
if (null === $manager) {
$manager = $this->createStub(ContingentSnapshotManager::class);
if ([] !== $results) {
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
}
}
$command = new BpnSyncContingentsCommand(
$accommodationRepository,
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(ContingentDayRepository::class),
$hotelDirectory,
$syncStateRepository ?? $this->createStub(ContingentSyncStateRepository::class),
$dayRepository ?? $this->createStub(ContingentDayRepository::class),
$manager,
new NullLogger(),
);
@@ -91,7 +170,7 @@ class BpnSyncContingentsCommandTest extends TestCase
$application->addCommand($command);
$tester = new CommandTester($application->find('app:bpn:sync-contingents'));
$tester->execute(['--horizon-months' => '1']);
$tester->execute(['--horizon-months' => '1'] + $options);
// LockableTrait's lock lives for the life of the process, so without releasing it every
// run after the first would exit early and these assertions would pass for the wrong reason.
@@ -8,10 +8,16 @@ use App\BpnConnect\Model\ContingentStatus;
use App\Controller\Api\ContingentController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\CalendarHotel;
use App\Entity\Groups\ContingentSyncState;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentHotel;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\AccommodationPriceCoverage;
use App\Service\ContingentHotelDirectory;
use App\Service\ContingentSnapshotReader;
use App\Service\PriceTimelineBuilder;
use Carbon\CarbonImmutable;
@@ -181,6 +187,222 @@ class ContingentControllerTest extends TestCase
$this->callCalendar([], null, null, $priceRepository);
}
public function testAvailabilityKeepsTheStatusOfUnpricedDays(): void
{
$data = json_decode((string) $this->callAvailability([
'2026-09-01' => ContingentStatus::Ok,
'2026-09-02' => ContingentStatus::OnRequest,
'2026-09-03' => ContingentStatus::Blocked,
])->getContent(), true);
// The opposite of testCalendarServesTheSnapshotStatuses: no price here, and nothing is blocked for it.
self::assertSame(['OK', 'ON_REQUEST', 'BLOCKED'], array_column($data, 'status'));
}
public function testAvailabilityLeavesOutDaysMissingFromTheSnapshot(): void
{
$data = json_decode((string) $this->callAvailability([
'2026-09-03' => ContingentStatus::Blocked,
'2026-09-01' => ContingentStatus::Ok,
])->getContent(), true);
self::assertSame([
['date' => '2026-09-01', 'status' => 'OK'],
['date' => '2026-09-03', 'status' => 'BLOCKED'],
], $data);
}
public function testAvailabilityFailsLoudlyWhenThereIsNoUsableSnapshot(): void
{
$response = $this->callAvailability(null);
self::assertSame(Response::HTTP_BAD_GATEWAY, $response->getStatusCode());
self::assertSame('{"error":"Failed to fetch contingent data."}', $response->getContent());
}
public function testAvailabilityRejectsAnUnknownHotel(): void
{
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(null);
$response = $this->callAvailability([], hotelDirectory: $hotelDirectory);
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
self::assertSame('{"error":"Hotel not found for hotelCode."}', $response->getContent());
}
public function testAvailabilityServesATypo3HotelWithoutAnAccommodation(): void
{
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')));
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with('DKI', self::anything(), self::anything())
->willReturn(['2026-09-01' => ContingentStatus::Ok]);
$response = $this->callAvailability(null, new ContingentCalendarQuery('DKI', '2026-09-01', '2026-09-03'), $snapshotReader, $hotelDirectory);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('[{"date":"2026-09-01","status":"OK"}]', $response->getContent());
}
public function testStatusListsEverySyncedHotel(): void
{
CarbonImmutable::setTestNow('2026-10-29 10:30:00');
$accommodation = (new Accommodation())->setName('Gruppenhaus Spinabad')->setCalendarCode('DGS')->setCmsCode('SERDGS');
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('all')->willReturn([
'DGS' => new ContingentHotel('DGS', $accommodation, (new CalendarHotel())->setCode('DGS')->setName('Sportclub Spinabad')),
'DKI' => new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')),
'DWW' => new ContingentHotel('DWW', null, (new CalendarHotel())->setCode('DWW')->setName('Sportclub Weissfluh')),
]);
$syncStateRepository = $this->createStub(ContingentSyncStateRepository::class);
$syncStateRepository->method('findAllIndexedByHotelCode')->willReturn([
'DGS' => (new ContingentSyncState('DGS'))->recordSuccess(new \DateTimeImmutable('2026-10-29 10:15:00+01:00'), new \DateTimeImmutable('2028-10-29')),
'DKI' => (new ContingentSyncState('DKI'))->recordFailure('unknown hotel code'),
]);
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->expects(self::once())
->method('findPricedDateBoundsByHotelCode')
->with('DGS')
->willReturn(['from' => new \DateTimeImmutable('2027-06-01'), 'to' => new \DateTimeImmutable('2027-09-30')]);
$controller = new ContingentController(
$this->createStub(AccommodationRepository::class),
$priceRepository,
new ContingentSnapshotReader(
$this->createStub(ContingentDayRepository::class),
$syncStateRepository,
$this->createStub(LoggerInterface::class),
),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$hotelDirectory,
$syncStateRepository,
$this->createStub(LoggerInterface::class),
);
$controller->setContainer(new Container());
$data = json_decode((string) $controller->status()->getContent(), true);
self::assertSame([
[
'hotelCode' => 'DGS',
'name' => 'Sportclub Spinabad',
'sources' => ['accommodation', 'typo3'],
'accommodation' => ['name' => 'Gruppenhaus Spinabad', 'cmsCode' => 'SERDGS'],
'pricedFrom' => '2027-06-01',
'pricedTo' => '2027-09-30',
'syncedAt' => '2026-10-29T10:15:00+01:00',
'lastError' => null,
'failureCount' => 0,
'available' => true,
],
[
'hotelCode' => 'DKI',
'name' => 'Sportclub Schwendi',
'sources' => ['typo3'],
'accommodation' => null,
'pricedFrom' => null,
'pricedTo' => null,
'syncedAt' => null,
'lastError' => 'unknown hotel code',
'failureCount' => 1,
'available' => false,
],
[
'hotelCode' => 'DWW',
'name' => 'Sportclub Weissfluh',
'sources' => ['typo3'],
'accommodation' => null,
'pricedFrom' => null,
'pricedTo' => null,
'syncedAt' => null,
'lastError' => null,
'failureCount' => 0,
'available' => false,
],
], $data);
}
public function testAvailabilityWithoutARangeReadsFromTodayToTheEndOfTheSnapshot(): void
{
CarbonImmutable::setTestNow('2026-10-29 09:00:00');
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with(
self::anything(),
self::callback(fn (\DateTimeImmutable $from) => '2026-10-29 00:00:00' === $from->format('Y-m-d H:i:s')),
self::isNull(),
)
->willReturn([]);
$response = $this->callAvailability(null, new ContingentCalendarQuery('HOTEL1'), $snapshotReader);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('[]', $response->getContent());
}
public function testAvailabilityWithARangePassesItThrough(): void
{
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with(self::anything(), new \DateTimeImmutable('2026-09-01'), new \DateTimeImmutable('2026-09-03'))
->willReturn([]);
$this->callAvailability(null, snapshotReader: $snapshotReader);
}
/**
* @param array<string, ContingentStatus>|null $statuses
*/
private function callAvailability(
?array $statuses,
?ContingentCalendarQuery $query = null,
?ContingentSnapshotReader $snapshotReader = null,
?ContingentHotelDirectory $hotelDirectory = null,
): Response {
if (null === $hotelDirectory) {
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(new ContingentHotel('HOTEL1', (new Accommodation())->setCalendarCode('HOTEL1'), null));
}
// The hotel is looked up in the directory: an accommodation is not required here.
$accommodationRepository = $this->createMock(AccommodationRepository::class);
$accommodationRepository->expects(self::never())->method(self::anything());
if (null === $snapshotReader) {
$snapshotReader = $this->createStub(ContingentSnapshotReader::class);
$snapshotReader->method('statusesFor')->willReturn($statuses);
}
// Prices play no part in this endpoint, so any call to their repository is a failure.
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->expects(self::never())->method(self::anything());
$controller = new ContingentController(
$accommodationRepository,
$priceRepository,
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$hotelDirectory,
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
$controller->setContainer(new Container());
return $controller->availability($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
}
/**
* @param array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds
* @param AccommodationPrice[] $prices
@@ -232,6 +454,8 @@ class ContingentControllerTest extends TestCase
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$this->createStub(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
@@ -250,6 +474,8 @@ class ContingentControllerTest extends TestCase
$this->createStub(ContingentSnapshotReader::class),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$this->createStub(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
}
@@ -33,7 +33,12 @@ class ParticipantAssignedRoomFieldHandlerTest extends TestCase
public function testShouldProcessReturnsTrueWhenFieldPresent(): void
{
$this->assertTrue($this->handler->shouldProcess(['assignedRoomId' => '1'], BookingDto::MODE_CREATE, 0));
$this->assertTrue($this->handler->shouldProcess(['assignedRoomId' => ''], BookingDto::MODE_EDIT, 5));
$this->assertTrue($this->handler->shouldProcess(['assignedRoomId' => ''], BookingDto::MODE_CREATE, 5));
}
public function testShouldProcessReturnsFalseInEditMode(): void
{
$this->assertFalse($this->handler->shouldProcess(['assignedRoomId' => '1'], BookingDto::MODE_EDIT, 0));
}
public function testShouldProcessReturnsFalseWhenFieldNotPresent(): void
+84
View File
@@ -194,4 +194,88 @@ class ApiClientTest extends TestCase
'Action' => 'addforce',
], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
}
public function testAddContactsToListForcesSubscriptionAndSendsOnlyTheFirstName(): void
{
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = [$method, $url, $options];
return new MockResponse(json_encode(['Data' => [['JobID' => 555]]], JSON_THROW_ON_ERROR), ['http_code' => 201]);
});
$mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', null, [
'firstName' => 'vorname',
'lastName' => 'nachname',
]);
$jobId = $mailjet->addContactsToList(42, [
['email' => ' [email protected] ', 'firstName' => ' Mia '],
['email' => '[email protected]', 'firstName' => null],
]);
self::assertSame(555, $jobId);
self::assertSame('POST', $requests[0][0]);
self::assertSame('https://mailjet.test/contactslist/42/managemanycontacts', $requests[0][1]);
self::assertSame([
'Action' => 'addforce',
'Contacts' => [
['Email' => '[email protected]', 'Properties' => ['vorname' => 'Mia']],
['Email' => '[email protected]'],
],
], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
}
public function testCreateContactsListReturnsTheNewListId(): void
{
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = [$method, $url, $options];
return new MockResponse(json_encode(['Data' => [['ID' => 42, 'Name' => 'LPJMP281126']]], JSON_THROW_ON_ERROR), ['http_code' => 201]);
});
$mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test');
self::assertSame(42, $mailjet->createContactsList('LPJMP281126'));
self::assertSame('https://mailjet.test/contactslist', $requests[0][1]);
self::assertSame(['Name' => 'LPJMP281126'], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
}
public function testGetListContactIdsPagesThroughAllRecipients(): void
{
$offsets = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$offsets): MockResponse {
$offset = (int) $options['query']['Offset'];
$offsets[] = $offset;
$data = 0 === $offset
? array_map(static fn (int $id): array => ['ContactID' => $id], range(1, 1000))
: [['ContactID' => 1001]];
return new MockResponse(json_encode(['Data' => $data], JSON_THROW_ON_ERROR));
});
$mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test');
$contactIds = $mailjet->getListContactIds(42);
self::assertSame([0, 1000], $offsets);
self::assertCount(1001, $contactIds);
}
public function testDeleteContactUsesTheV4Api(): void
{
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = [$method, $url];
return new MockResponse('', ['http_code' => 200]);
});
$mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://api.mailjet.com/v3/REST');
$mailjet->deleteContact(99);
self::assertSame([['DELETE', 'https://api.mailjet.com/v4/contacts/99']], $requests);
}
}
@@ -13,13 +13,12 @@ use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase;
/**
* Tests that draft restoration never unassigns a room.
* Tests that draft restoration never changes a room assignment.
*
* Edit mode renders the room as static text and never submits assignedRoomId, so a null in
* the draft payload is always an artefact of a snapshot taken before BusPro reported the
* assignment - typically for seats added to a group booking shortly before the draft was
* saved. Replaying such a null would strip the room from the UI and from the outbound
* zuordnung, so the room id uses the same merge strategy as single-select services.
* Edit mode renders the room as static text and never submits assignedRoomId, so the draft
* only holds what BusPro reported when it was saved: a null for seats added to a group booking
* shortly before, or a room the office has since reassigned. The room always comes from the
* booking as loaded; only the free-text remarks are restored.
*/
class BookingEditDraftMergerRoomAssignmentTest extends TestCase
{
@@ -43,7 +42,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
$this->assertSame(74, $participant->assignedRoomId);
}
public function testDraftAppliesRoomThatResolvesAgainstTravel(): void
public function testDraftDoesNotMoveParticipantToAnotherRoom(): void
{
$participant = new ParticipantDto();
$participant->mutable = true;
@@ -56,7 +55,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
$this->apply($travel, $participant, ['assignedRoomId' => 75]);
$this->assertSame(75, $participant->assignedRoomId);
$this->assertSame(74, $participant->assignedRoomId);
}
public function testDraftSkipsRoomThatIsUnknownToTravel(): void
+36
View File
@@ -79,4 +79,40 @@ class CmsDataProviderTest extends TestCase
self::assertNull($provider->getHotelDetails('UNKNOWN'));
}
public function testFetchCalendarHotelsKeepsCodeAndName(): void
{
$client = new MockHttpClient(function (string $method, string $url) {
self::assertSame('https://cms.test/api/calendar-hotels', $url);
return new MockResponse(json_encode([
['code' => 'ASCGR', 'name' => 'Ahrntal - Gruppenhaus Christiler', 'products' => [['uid' => 710, 'name' => 'Gruppenhaus Christiler', 'season' => 'w']]],
['code' => 'DKI', 'name' => '', 'products' => []],
['code' => '', 'name' => 'No code'],
['name' => 'Missing code'],
], JSON_THROW_ON_ERROR));
}, 'https://cms.test/');
$hotels = (new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels();
self::assertNotNull($hotels);
self::assertSame([['ASCGR', 'Ahrntal - Gruppenhaus Christiler'], ['DKI', 'DKI']], array_map(
static fn ($hotel) => [$hotel->code, $hotel->name],
$hotels,
));
}
public function testFetchCalendarHotelsIsNullOnAnErrorResponse(): void
{
$client = new MockHttpClient(new MockResponse('Forbidden', ['http_code' => 403]), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
public function testFetchCalendarHotelsIsNullWhenTheBodyIsNotAList(): void
{
$client = new MockHttpClient(new MockResponse('{"success":false}'), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Enum\ContingentHotelSource;
use App\Model\CmsCalendarHotel;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\CalendarHotelRepository;
use App\Service\CmsDataProvider;
use App\Service\ContingentHotelDirectory;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentHotelDirectoryTest extends TestCase
{
private CmsDataProvider&Stub $cmsDataProvider;
private AccommodationRepository&Stub $accommodationRepository;
private CalendarHotelRepository&Stub $calendarHotelRepository;
private EntityManagerInterface&Stub $entityManager;
protected function setUp(): void
{
$this->cmsDataProvider = $this->createStub(CmsDataProvider::class);
$this->accommodationRepository = $this->createStub(AccommodationRepository::class);
$this->calendarHotelRepository = $this->createStub(CalendarHotelRepository::class);
$this->entityManager = $this->createStub(EntityManagerInterface::class);
}
public function testRefreshAddsRenamesAndRemovesStoredHotels(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$renamed = (new CalendarHotel())->setCode('DKI')->setName('Schwendi');
$dropped = (new CalendarHotel())->setCode('ASB')->setName('Sportclub Bader');
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn(['DKI' => $renamed, 'ASB' => $dropped]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('DWW', 'Sportclub Weissfluh'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DWW' === $hotel->getCode() && 'Sportclub Weissfluh' === $hotel->getName()));
$this->entityManager->expects(self::once())->method('remove')->with($dropped);
$this->entityManager->expects(self::once())->method('flush');
self::assertTrue($this->directory()->refresh());
self::assertSame('Sportclub Schwendi', $renamed->getName());
}
public function testRefreshKeepsTheStoredListWhenTypo3IsUnavailable(): void
{
$this->assertKeepsStoredList(null);
}
public function testRefreshKeepsTheStoredListWhenTypo3ReturnsNoHotels(): void
{
$this->assertKeepsStoredList([]);
}
public function testRefreshIgnoresCodesTheApiCouldNotBeAskedFor(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('ABCDEFGHIJKLMNOPQ', 'Too long'),
new CmsCalendarHotel('A B', 'Space'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DKI' === $hotel->getCode()));
$this->directory()->refresh();
}
public function testAllIsTheUnionOfAccommodationsAndTypo3Hotels(): void
{
$this->accommodationRepository->method('findAllWithCalendarCode')->willReturn([
(new Accommodation())->setCalendarCode('DGS'),
(new Accommodation())->setCalendarCode('SERASCSO'),
]);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'ASCGR' => (new CalendarHotel())->setCode('ASCGR')->setName('Christiler'),
'DGS' => (new CalendarHotel())->setCode('DGS')->setName('Spinabad'),
]);
$hotels = $this->directory()->all();
self::assertSame(['ASCGR', 'DGS', 'SERASCSO'], array_keys($hotels));
self::assertSame([ContingentHotelSource::Typo3], $hotels['ASCGR']->sources());
self::assertSame([ContingentHotelSource::Accommodation, ContingentHotelSource::Typo3], $hotels['DGS']->sources());
self::assertSame([ContingentHotelSource::Accommodation], $hotels['SERASCSO']->sources());
}
public function testFindKnowsATypo3OnlyHotel(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn((new CalendarHotel())->setCode('DKI')->setName('Schwendi'));
self::assertSame('DKI', $this->directory()->find('DKI')?->code);
}
public function testFindReturnsNullForAnUnknownCode(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn(null);
self::assertNull($this->directory()->find('NOPE'));
}
/**
* @param list<CmsCalendarHotel>|null $fetched
*/
private function assertKeepsStoredList(?array $fetched): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'DKI' => (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi'),
]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn($fetched);
$this->entityManager->expects(self::never())->method('remove');
$this->entityManager->expects(self::never())->method('flush');
$logger->expects(self::once())->method('warning');
self::assertFalse($this->directory($logger)->refresh());
}
private function directory(?LoggerInterface $logger = null): ContingentHotelDirectory
{
return new ContingentHotelDirectory(
$this->cmsDataProvider,
$this->accommodationRepository,
$this->calendarHotelRepository,
$this->entityManager,
$logger ?? $this->createStub(LoggerInterface::class),
);
}
}
+30 -29
View File
@@ -10,7 +10,6 @@ use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
@@ -40,16 +39,23 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
]));
$this->entityManager->expects(self::exactly(3))->method('persist');
$persisted = [];
$this->entityManager->expects(self::exactly(3))->method('persist')
->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted[] = $entity;
});
$result = $this->sync();
$days = array_values(array_filter($persisted, static fn (object $entity) => $entity instanceof ContingentDay));
self::assertSame(['HOTEL1', 'HOTEL1'], array_map(static fn (ContingentDay $day) => $day->getHotelCode(), $days));
self::assertTrue($result->successful);
self::assertTrue($result->changed);
self::assertSame(2, $result->added);
@@ -67,7 +73,7 @@ class ContingentSnapshotManagerTest extends TestCase
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn($existing);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
@@ -85,10 +91,10 @@ class ContingentSnapshotManagerTest extends TestCase
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
@@ -107,11 +113,11 @@ class ContingentSnapshotManagerTest extends TestCase
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
@@ -126,9 +132,9 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
@@ -143,7 +149,7 @@ class ContingentSnapshotManagerTest extends TestCase
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
@@ -157,11 +163,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -174,11 +180,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -190,11 +196,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testTheRollingWindowGrowingIsNotAChange(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
]);
@@ -225,17 +231,12 @@ class ContingentSnapshotManagerTest extends TestCase
);
return $manager->sync(
$this->accommodation(),
'HOTEL1',
new \DateTimeImmutable('2026-07-01'),
new \DateTimeImmutable('2026-07-03'),
);
}
private function accommodation(): Accommodation
{
return (new Accommodation())->setCalendarCode('HOTEL1');
}
private function day(string $date, ContingentStatus $status): ContingentDay
{
return (new ContingentDay())
+23 -10
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
@@ -31,7 +30,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn(null);
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -41,7 +40,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-7 hours'));
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -49,7 +48,7 @@ class ContingentSnapshotReaderTest extends TestCase
public function testReturnsStatusesWhenRecentlySynced(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-10 minutes'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-09-01' => $this->day(ContingentStatus::Ok),
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
@@ -63,25 +62,39 @@ class ContingentSnapshotReaderTest extends TestCase
public function testStillUsableJustInsideTheStalenessWindow(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-5 hours'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
self::assertSame([], $this->read());
}
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
public function testReturnsNullForAnEmptyHotelCode(): void
{
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
$this->syncStateRepository->expects(self::never())->method('findOneByHotelCode');
self::assertNull($this->read(new Accommodation()));
self::assertNull($this->read(''));
}
public function testIsCurrentMatchesWhatStatusesForServes(): void
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
$this->syncStateRepository,
$this->createStub(LoggerInterface::class),
);
self::assertFalse($reader->isCurrent(null));
self::assertFalse($reader->isCurrent(new ContingentSyncState('HOTEL1')));
self::assertFalse($reader->isCurrent($this->state('-7 hours')));
self::assertTrue($reader->isCurrent($this->state('-5 hours')));
}
/**
* @return array<string, ContingentStatus>|null
*/
private function read(?Accommodation $accommodation = null): ?array
private function read(string $hotelCode = 'HOTEL1'): ?array
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
@@ -90,7 +103,7 @@ class ContingentSnapshotReaderTest extends TestCase
);
return $reader->statusesFor(
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
$hotelCode,
new \DateTimeImmutable('2026-09-01'),
new \DateTimeImmutable('2026-09-03'),
);
@@ -300,6 +300,51 @@ class RoomPricingCalculatorTest extends TestCase
$this->assertEquals(2, $result[0]['participantCount']);
$this->assertEquals(210.0, $result[0]['unitPrice']);
$this->assertEquals(420.0, $result[0]['totalPrice']);
$this->assertEquals(1, $result[0]['quantity']);
}
public function testCalculateRoomPricingInEditModeShowsBookedRoomCountNotOccupants(): void
{
// Booking 104407: 16 participants in one 6-bed room, one 4-bed room and three doubles.
$bookingDto = new BookingDto(new Travel(), 16);
$bookingDto->participants = [];
foreach ([0, 1, 2, 3, 4, 5] as $index) {
$bookingDto->participants[$index] = $this->participantInRoom(863);
}
foreach ([6, 7, 8, 9] as $index) {
$bookingDto->participants[$index] = $this->participantInRoom(95);
}
foreach ([10, 11, 12, 13, 14, 15] as $index) {
$bookingDto->participants[$index] = $this->participantInRoom(3);
}
$booking = new Booking();
$booking->rooms = [
863 => $this->bookedRoom(863, 6, 1, [0, 1, 2, 3, 4, 5], 194.0),
95 => $this->bookedRoom(95, 4, 1, [6, 7, 8, 9], 204.0),
3 => $this->bookedRoom(3, 2, 3, [10, 11, 12, 13, 14, 15], 224.0),
];
$bookingDto->booking = $booking;
$result = $this->calculator->calculateRoomPricing($bookingDto);
$quantities = array_column($result, 'quantity', 'roomId');
$this->assertSame([863 => 1, 95 => 1, 3 => 3], $quantities);
$this->assertEquals(1344.0, $result[2]['totalPrice']);
}
/** @param int[] $mapping */
private function bookedRoom(int $id, int $maxPax, int $totalCount, array $mapping, float $price): Room
{
$room = new Room();
$room->id = $id;
$room->label = 'Room '.$id;
$room->maxPax = $maxPax;
$room->totalCount = $totalCount;
$room->mapping = $mapping;
$room->individualPrice = array_fill_keys($mapping, $price);
return $room;
}
private function participantInRoom(int $roomId): ParticipantDto
@@ -112,6 +112,30 @@ class ServiceAvailabilityCalculatorTest extends TestCase
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testSeatsHeldByOtherParticipantsDoNotCountTowardUsageInEditMode(): void
{
// BusPro reports frei="3" after deducting the seats this booking already holds
$parkingService = $this->createParkingService(id: 1, available: 3);
$bookingDto = $this->createEditBookingDtoWithServices([$parkingService]);
$this->holdParkingForOtherParticipants($bookingDto, $parkingService, [1, 2, 3, 4, 5, 6, 7, 8]);
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
$this->assertSame(3, $this->calculator->calculateRemainingAvailability($bookingDto, 0)[1]);
}
public function testNewSelectionByOtherParticipantStillCountsTowardUsageInEditMode(): void
{
$parkingService = $this->createParkingService(id: 1, available: 1);
$bookingDto = $this->createEditBookingDtoWithServices([$parkingService]);
$otherParticipant = new ParticipantDto();
$otherParticipant->parkingService = $parkingService;
$bookingDto->participants[1] = $otherParticipant;
// Participant 1 picks the last slot in this edit without holding it in the booking
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
@@ -236,4 +260,23 @@ class ServiceAvailabilityCalculatorTest extends TestCase
return $bookingDto;
}
/**
* Prefills the given participants with the parking service, as the edit form does for a
* service they hold in the live booking.
*
* @param list<int> $participantIndexes
*/
private function holdParkingForOtherParticipants(BookingDto $bookingDto, Service $parkingService, array $participantIndexes): void
{
$held = clone $parkingService;
$held->mapping = $participantIndexes;
$bookingDto->booking->additionalServices[$parkingService->id] = $held;
foreach ($participantIndexes as $index) {
$participant = new ParticipantDto();
$participant->parkingService = $parkingService;
$bookingDto->participants[$index] = $participant;
}
}
}
+26
View File
@@ -186,6 +186,32 @@ class TravelIndexTest extends TestCase
$this->assertNull($result);
}
// -------------------------------------------------------------------------
// mapDateCodeToStartDate
// -------------------------------------------------------------------------
public function testMapDateCodeToStartDateReturnsTheTravelStartDate(): void
{
$this->travelLoader->method('generateFilesMap')->willReturn([
42 => ['id' => 42, 'code' => 'WI25', 'dateFrom' => new \DateTimeImmutable('2026-12-28'), 'hotels' => []],
]);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$result = $this->service()->mapDateCodeToStartDate('WI25');
$this->assertEquals(new \DateTimeImmutable('2026-12-28'), $result);
}
public function testMapDateCodeToStartDateReturnsNullWhenNotFound(): void
{
$this->travelLoader->method('generateFilesMap')->willReturn([]);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$result = $this->service()->mapDateCodeToStartDate('UNKNOWN');
$this->assertNull($result);
}
// -------------------------------------------------------------------------
// mapHotelCodeToId
// -------------------------------------------------------------------------
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\Entity\TravelMailingList;
use App\Enum\BusLeg;
use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient;
use App\Service\TravelMailingListManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class TravelMailingListManagerTest extends TestCase
{
public function testCreateTracksTheListAndAddsDeduplicatedContacts(): void
{
$mailjet = $this->createMock(ApiClient::class);
$mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126 nur Bus')->willReturn(42);
$mailjet->expects(self::once())->method('addContactsToList')->with(42, [
['email' => '[email protected]', 'firstName' => 'Mia'],
['email' => '[email protected]', 'firstName' => null],
])->willReturn(555);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(TravelMailingList::class));
$entityManager->expects(self::never())->method('remove');
$mailingList = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
new TravelMailingRecipient('[email protected]', 'Lea', 'Muster'),
new TravelMailingRecipient('[email protected]', '', 'Test'),
], BusLeg::Outbound, new \DateTimeImmutable('2026-11-28'));
self::assertSame(42, $mailingList->getMailjetListId());
self::assertSame(BusLeg::Outbound, $mailingList->getBusLeg());
self::assertSame('LPJMP281126 nur Bus', $mailingList->getName());
self::assertEquals(new \DateTimeImmutable('2026-12-28'), $mailingList->getDeleteAfter());
}
public function testCreateDiscardsTheListWhenTheContactsCannotBeAdded(): void
{
$mailjet = $this->createMock(ApiClient::class);
$mailjet->method('createContactsList')->willReturn(42);
$mailjet->method('addContactsToList')->willThrowException(new NewsletterProviderException('boom'));
$mailjet->expects(self::once())->method('deleteContactsList')->with(42);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('remove')->with(self::isInstanceOf(TravelMailingList::class));
$this->expectException(NewsletterProviderException::class);
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
], null, new \DateTimeImmutable('2026-11-28'));
}
public function testDeleteRemovesOnlyContactsOnNoOtherList(): void
{
$mailjet = $this->createMock(ApiClient::class);
$mailjet->method('getListContactIds')->with(42)->willReturn([1, 2]);
$mailjet->method('getContactListIds')->willReturnMap([
[1, [42]],
[2, [42, 10321569]],
]);
$mailjet->expects(self::once())->method('deleteContact')->with(1);
$mailjet->expects(self::once())->method('deleteContactsList')->with(42);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('remove');
$deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
->delete([new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28'))]);
self::assertSame(1, $deleted);
}
public function testDeleteTreatsTheTravelsOtherListAsItsOwn(): void
{
// 1 is on both lists of the travel, 2 also on a newsletter list.
$mailjet = $this->createMock(ApiClient::class);
$mailjet->method('getListContactIds')->willReturnMap([
[42, [1, 2]],
[43, [1]],
]);
$mailjet->method('getContactListIds')->willReturnMap([
[1, [42, 43]],
[2, [42, 10321569]],
]);
$mailjet->expects(self::once())->method('deleteContact')->with(1);
$mailjet->expects(self::exactly(2))->method('deleteContactsList');
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::exactly(2))->method('remove');
$deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->delete([
new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28')),
new TravelMailingList('LPJMP281126', 43, BusLeg::Outbound, new \DateTimeImmutable('2026-12-28')),
]);
self::assertSame(1, $deleted);
}
public function testDeleteKeepsTheRowWhenMailjetFails(): void
{
$mailjet = $this->createMock(ApiClient::class);
$mailjet->method('getListContactIds')->willReturn([]);
$mailjet->expects(self::once())->method('deleteContactsList')->willThrowException(new NewsletterProviderException('boom'));
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$this->expectException(NewsletterProviderException::class);
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
->delete([new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28'))]);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator\Constraints;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\Form\Model\ParticipantDto;
use App\Form\Model\VoucherPurchaseDto;
use App\Validator\Constraints\PhoneNumber;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PhoneNumberTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
}
/** @return iterable<string, array{string, bool}> */
public static function numbers(): iterable
{
yield 'digits' => ['01711234567', true];
yield 'international' => ['+49 171 1234567', true];
yield 'hyphen' => ['0171-1234567', true];
yield 'slash' => ['0171/1234567', true];
yield 'brackets' => ['+49 (0) 171 1234567', false];
yield 'sanitized plus' => ['&#43;49 171 1234567', false];
yield 'plus inside' => ['0171+1234567', false];
yield 'letters' => ['0171 privat', false];
}
#[DataProvider('numbers')]
public function testOnlyDigitsSpacesHyphensAndALeadingPlusPass(string $number, bool $valid): void
{
self::assertSame($valid, 0 === $this->validator->validate($number, new PhoneNumber())->count());
}
public function testAnEnteredParticipantNumberIsValidated(): void
{
$participant = new ParticipantDto();
$participant->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_create']));
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
/**
* A number loaded from BusPro goes back unchanged, so it must not block the form, even when it
* is one the rule would reject.
*/
public function testAParticipantNumberLoadedFromBusProIsExemptUntilChanged(): void
{
$personalData = new PersonalData();
$personalData->communication->mobile = '&#43;49 171 1234567';
$participant = ParticipantDto::fromPersonalData($personalData);
self::assertCount(0, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
$participant->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
public function testTheExemptionSurvivesTheSessionRoundTrip(): void
{
$personalData = new PersonalData();
$personalData->communication->mobile = '+49 (171) 1234567';
$participant = unserialize(serialize(ParticipantDto::fromPersonalData($personalData)));
self::assertCount(0, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
public function testCommunicationExemptsOnlyTheNumbersItWasLoadedWith(): void
{
$communication = new Communication();
$communication->phone = '030 (123456)';
$communication->mobile = '+49 (171) 1234567';
$communication->rememberLoadedNumbers();
self::assertCount(0, $this->validator->validate($communication, null, ['personal_data']));
$communication->phone = '030 (654321)';
$violations = $this->validator->validate($communication, null, ['personal_data']);
self::assertCount(1, $violations);
self::assertSame('phone', $violations->get(0)->getPropertyPath());
}
public function testVoucherBuyerNumbersAreExemptOnlyAsPrefilled(): void
{
$personalData = new PersonalData();
$personalData->communication->phone = '030 (123456)';
$personalData->communication->mobile = '+49 (171) 1234567';
$dto = new VoucherPurchaseDto();
$dto->prefillBuyer($personalData, null);
self::assertCount(0, $this->validator->validateProperty($dto, 'phone', ['voucher_step_2']));
self::assertCount(0, $this->validator->validateProperty($dto, 'mobile', ['voucher_step_2']));
$dto->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($dto, 'mobile', ['voucher_step_2']));
}
}