Compare commits
17
Commits
7c8dbeef48
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77960fe81f | ||
|
|
9ce42de4c1 | ||
|
|
d50995a21c | ||
|
|
897a1ac7f9 | ||
|
|
2705401393 | ||
|
|
f5894a6bef | ||
|
|
e7df5ac03a | ||
|
|
c16f368293 | ||
|
|
d63fa10a56 | ||
|
|
e8fa71175d | ||
|
|
09610889f3 | ||
|
|
d8fe9a3875 | ||
|
|
e9766dbdce | ||
|
|
b047465339 | ||
|
|
a60bf68345 | ||
|
|
97c6330a9a | ||
|
|
ec36b61275 |
No files matched your search
@@ -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
|
||||
|
||||
Generated
+883
-618
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,10 @@ zenstruck_schedule:
|
||||
frequency: "45 1 * * *"
|
||||
description: "Removes expired pending newsletter double opt-in requests"
|
||||
|
||||
- task: app:cleanup:travel-mailing-lists
|
||||
frequency: "50 1 * * *"
|
||||
description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
|
||||
|
||||
- task: league:oauth2-server:clear-expired-tokens
|
||||
frequency: "0 * * * *"
|
||||
description: "Removes expired oauth2 tokens hourly"
|
||||
|
||||
@@ -441,6 +441,20 @@ services:
|
||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
|
||||
# Travel mailing lists live in a Mailjet sub-account, which has its own key pair and keeps
|
||||
# its contacts and lists apart from the newsletter on the main account.
|
||||
app.mailjet.travel_mailing_client:
|
||||
class: App\Mailjet\ApiClient
|
||||
arguments:
|
||||
$apiKey: '%env(default::MAILJET_TRAVEL_MAILING_API_KEY)%'
|
||||
$apiSecret: '%env(default::MAILJET_TRAVEL_MAILING_API_SECRET)%'
|
||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
|
||||
App\Service\TravelMailingListManager:
|
||||
arguments:
|
||||
$mailjet: '@app.mailjet.travel_mailing_client'
|
||||
|
||||
App\EventListener\MaintenanceModeListener:
|
||||
arguments:
|
||||
$options:
|
||||
|
||||
@@ -507,21 +507,74 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations
|
||||
|
||||
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
|
||||
|
||||
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
|
||||
|
||||
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
|
||||
|
||||
Each entry, ordered by date:
|
||||
|
||||
```json
|
||||
{"date": "2027-01-30", "status": "BLOCKED"}
|
||||
```
|
||||
|
||||
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
|
||||
|
||||
Two differences from `calendar`:
|
||||
|
||||
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
|
||||
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
|
||||
|
||||
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
|
||||
|
||||
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
|
||||
|
||||
#### `GET /api/contingents/status` — scope `api`
|
||||
|
||||
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
|
||||
|
||||
```json
|
||||
{
|
||||
"hotelCode": "DGS",
|
||||
"name": "Sportclub Spinabad",
|
||||
"sources": ["accommodation", "typo3"],
|
||||
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
|
||||
"pricedFrom": "2027-06-01",
|
||||
"pricedTo": "2027-09-30",
|
||||
"syncedAt": "2026-10-29T10:15:00+01:00",
|
||||
"lastError": null,
|
||||
"failureCount": 0,
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
|
||||
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
|
||||
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
|
||||
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
|
||||
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
|
||||
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
|
||||
|
||||
---
|
||||
|
||||
### Operating the snapshot (deployment)
|
||||
|
||||
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||||
The snapshot is filled only by the scheduler, so on an empty database (a new environment, or after the snapshot tables were truncated) every hotel returns `502` until the first successful sync — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||||
|
||||
Rollout order:
|
||||
Bringing up an environment:
|
||||
|
||||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`.
|
||||
2. Deploy; `database:migrate` creates the empty tables.
|
||||
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||||
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
|
||||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the environment.** If it is rejected the sync cannot populate anything and the endpoints stay at `502`.
|
||||
2. Deploy; `database:migrate` creates the tables.
|
||||
3. Run `bin/console app:bpn:sync-contingents -v` on the host promptly, ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||||
4. Verify with `GET /api/contingents/status`: every row `available: true`, `failureCount` 0.
|
||||
|
||||
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
|
||||
|
||||
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
|
||||
|
||||
**TYPO3's codes must be BusPro calendar codes.** The sync sends every code to bpn-connect as is. A TYPO3 hotel whose code is a CMS code rather than the BusPro hotel code (group houses often have both, e.g. calendar code `DKS`, CMS code `DKSGRP`) is answered with a `404` by bpn-connect: that code fails on every run, logs two errors each time, and shows up in `contingents/status` with `lastError` set and `available: false`. The task itself keeps succeeding, since only a total failure fails it. The fix belongs in TYPO3, by pointing the product at a hotel whose code is the BusPro code; MyE&P does not translate CMS codes. `contingents/status` is the place to spot such codes, and `accommodation.cmsCode` there shows both codes side by side for group houses.
|
||||
|
||||
### Freshness and propagation
|
||||
|
||||
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261029120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
|
||||
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
|
||||
// written, until a later migration drops it; that keeps this step reversible.
|
||||
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
|
||||
|
||||
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
|
||||
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
|
||||
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
|
||||
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
|
||||
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
|
||||
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
|
||||
$this->addSql('DROP TABLE calendar_hotel');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261104142531 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds travel_mailing_list to track the Mailjet contact lists created per travel until their deletion.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE travel_mailing_list');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261109074654 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
|
||||
// Every existing list holds all participants and is named by its travel code alone.
|
||||
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Fails while a travel has two lists; delete the bus-only one first.
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
|
||||
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
readonly class TravelMailingResponse
|
||||
{
|
||||
/**
|
||||
@@ -12,6 +14,7 @@ readonly class TravelMailingResponse
|
||||
public function __construct(
|
||||
public string $travelCode,
|
||||
public array $data,
|
||||
public ?BusLeg $busLeg = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\BpnConnect\Model\TravelMailingResponse;
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
class TravelsClient extends AbstractApiClient
|
||||
{
|
||||
@@ -14,12 +16,30 @@ class TravelsClient extends AbstractApiClient
|
||||
/**
|
||||
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
|
||||
* apart from a travel without confirmed participants.
|
||||
*
|
||||
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
|
||||
*/
|
||||
public function getMailingRecipients(string $travelCode): TravelMailingResponse
|
||||
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
|
||||
{
|
||||
$payload = $this->request(self::MAILING_PATH, [
|
||||
'travelCode' => $travelCode,
|
||||
]);
|
||||
$query = ['travelCode' => $travelCode];
|
||||
if (null !== $busLeg) {
|
||||
$query['busLeg'] = $busLeg->value;
|
||||
}
|
||||
|
||||
$payload = $this->request(self::MAILING_PATH, $query);
|
||||
|
||||
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
|
||||
// must not end up on a list meant for bus passengers only.
|
||||
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
|
||||
if ($appliedBusLeg !== $busLeg?->value) {
|
||||
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
|
||||
'travel_code' => $travelCode,
|
||||
'requested_bus_leg' => $busLeg?->value,
|
||||
'applied_bus_leg' => $appliedBusLeg,
|
||||
]);
|
||||
|
||||
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
|
||||
}
|
||||
|
||||
$recipients = array_map(
|
||||
static fn (array $item) => new TravelMailingRecipient(
|
||||
@@ -30,6 +50,6 @@ class TravelsClient extends AbstractApiClient
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients);
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ class BookingDataProcessor
|
||||
$roomSelection->price = $room->price;
|
||||
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
|
||||
$roomSelection->capacity = $room->minPax ?? 1;
|
||||
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
|
||||
$dto->roomSelections[] = $roomSelection;
|
||||
}
|
||||
}
|
||||
@@ -341,6 +340,7 @@ class BookingDataProcessor
|
||||
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
|
||||
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
|
||||
* Processes participant service selections for booking updates.
|
||||
*
|
||||
* Handles the assignment and mapping of services (additional, transportation,
|
||||
* pickup, room, insurance) from participant DTOs to the booking model during
|
||||
* the update request workflow.
|
||||
* pickup, insurance) from participant DTOs to the booking model during the
|
||||
* update request workflow. Rooms are not taken from the DTOs: they cannot be
|
||||
* changed in edit mode, so the booking's own room assignments are sent back.
|
||||
*/
|
||||
class ParticipantServiceProcessor
|
||||
{
|
||||
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
|
||||
* Resets all existing participant-to-service mappings to start with a clean slate.
|
||||
*
|
||||
* This ensures that service assignments are rebuilt from scratch based on current form selections.
|
||||
* Includes resetting room mappings to allow room reassignments during edit.
|
||||
* Room mappings are left alone, see removeCanceledParticipantsFromRooms().
|
||||
*
|
||||
* @param Booking $bookingData The booking data object containing services to reset
|
||||
*/
|
||||
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
|
||||
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
|
||||
* Processes all services for a single participant.
|
||||
*
|
||||
* This orchestrator method handles the complete service assignment workflow for one participant,
|
||||
* including additional services, transportation services, pickup locations, and room assignments.
|
||||
* including additional services, transportation services, pickup locations, and insurance.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
|
||||
$this->processTransportationServices($participant, $bookingData, $travelData);
|
||||
$this->processPickupLocations($participant, $bookingData);
|
||||
$this->processDropOffLocations($participant, $bookingData);
|
||||
$this->processRoomAssignment($participant, $bookingData);
|
||||
$this->processInsurance($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
@@ -271,27 +270,23 @@ class ParticipantServiceProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes room assignment for a participant.
|
||||
* Removes canceled participants from the booking's room assignments.
|
||||
*
|
||||
* Maps the participant to their assigned room. Allows room reassignment during
|
||||
* booking edits while maintaining the constraint that participants can only be
|
||||
* assigned to room types that have already been booked.
|
||||
* Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
|
||||
* them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
|
||||
* session would undo any reassignment made in BusPro while the edit was open, and send a
|
||||
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
|
||||
* other service.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
*/
|
||||
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void
|
||||
public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
|
||||
{
|
||||
if (null === $participant->assignedRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the room in the existing booking by ID
|
||||
foreach ($bookingData->rooms as $room) {
|
||||
if ($room->id === $participant->assignedRoomId) {
|
||||
$room->mapping[] = $participant->index;
|
||||
break;
|
||||
}
|
||||
$room->mapping = array_values(array_filter(
|
||||
$room->mapping,
|
||||
fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Utility\XmlCharacterFilter;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Service\XmlDumpWriter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -208,8 +209,18 @@ class XmlChannel
|
||||
*/
|
||||
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
|
||||
{
|
||||
// The dump keeps the raw response; only the parser sees the filtered one.
|
||||
$filtered = XmlCharacterFilter::strip($xml, $removed);
|
||||
if ($removed > 0) {
|
||||
$this->logger->warning('Removed characters not allowed in XML from API response', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type->value,
|
||||
'removed' => $removed,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
|
||||
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
|
||||
} catch (ResponseParserException $e) {
|
||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Utility;
|
||||
|
||||
/**
|
||||
* Removes characters XML 1.0 does not allow from a BusPro response before it is parsed.
|
||||
*
|
||||
* BusPro escapes whatever a field holds into a character reference without checking it,
|
||||
* so a NUL-padded value comes out as `�`. libxml rejects that as fatal and the whole
|
||||
* response fails to parse, not just the field. The same applies to the raw control bytes.
|
||||
*/
|
||||
final class XmlCharacterFilter
|
||||
{
|
||||
/**
|
||||
* @param-out int $count the number of characters removed
|
||||
*/
|
||||
public static function strip(string $xml, ?int &$count = null): string
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
$xml = preg_replace_callback(
|
||||
'/&#(x[0-9a-f]+|[0-9]+);/i',
|
||||
static function (array $match) use (&$count): string {
|
||||
$reference = $match[1];
|
||||
$codePoint = 'x' === strtolower($reference[0]) ? hexdec(substr($reference, 1)) : (int) $reference;
|
||||
|
||||
if (self::isAllowed($codePoint)) {
|
||||
return $match[0];
|
||||
}
|
||||
|
||||
++$count;
|
||||
|
||||
return '';
|
||||
},
|
||||
$xml,
|
||||
);
|
||||
|
||||
// Control bytes never occur inside a UTF-8 multibyte sequence, so a byte-wise match is safe.
|
||||
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml, -1, $rawCount);
|
||||
|
||||
$count += $rawCount;
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Char production of XML 1.0.
|
||||
*/
|
||||
private static function isAllowed(int|float $codePoint): bool
|
||||
{
|
||||
return 0x9 === $codePoint
|
||||
|| 0xA === $codePoint
|
||||
|| 0xD === $codePoint
|
||||
|| ($codePoint >= 0x20 && $codePoint <= 0xD7FF)
|
||||
|| ($codePoint >= 0xE000 && $codePoint <= 0xFFFD)
|
||||
|| ($codePoint >= 0x10000 && $codePoint <= 0x10FFFF);
|
||||
}
|
||||
}
|
||||
@@ -307,10 +307,10 @@ class TravelLoader extends AbstractLoader
|
||||
$availability = $serviceAvailabilities[$service->id];
|
||||
$service->available = $availability->available;
|
||||
|
||||
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||
// response actually carries one, so a response that omits the attribute does not wipe
|
||||
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||
// The live status is fresher than the nightly export's. The parser has already mapped it
|
||||
// onto the export vocabulary. Only overwrite when the response actually carries one, so a
|
||||
// response that omits the attribute does not wipe the value parsed from the XML export.
|
||||
// Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||
$service->status = $availability->status;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Traits\TypeConversionTrait;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
@@ -9,6 +10,19 @@ abstract class AbstractParser
|
||||
{
|
||||
use TypeConversionTrait;
|
||||
|
||||
/**
|
||||
* Live availability wording for a Leistung, mapped onto the XML export's.
|
||||
*
|
||||
* The availability endpoints answer "buchbar" or "ausgebucht", while every availability rule
|
||||
* compares against the export's "Frei" / "Anfrage" / "Buchungsstop". "ausgebucht" also covers
|
||||
* a Leistung that would still take an inquiry (anfragemoeglich="J"); that is not offered
|
||||
* either, so it maps to the booking stop.
|
||||
*/
|
||||
private const LIVE_SERVICE_STATUS = [
|
||||
'buchbar' => Constants::STATUS_AVAILABLE,
|
||||
'ausgebucht' => Constants::STATUS_BLOCKED,
|
||||
];
|
||||
|
||||
protected function getStringOrNullValue(Crawler $node): ?string
|
||||
{
|
||||
return 0 < $node->count() ? $node->text() : null;
|
||||
@@ -87,4 +101,16 @@ abstract class AbstractParser
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a live Leistung status into the export vocabulary; anything else passes through.
|
||||
*/
|
||||
protected function toServiceStatus(?string $liveStatus): ?string
|
||||
{
|
||||
if (null === $liveStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::LIVE_SERVICE_STATUS[strtolower(trim($liveStatus))] ?? $liveStatus;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class AvailabilitiesParser extends AbstractParser
|
||||
->each(function (Crawler $node) use (&$availabilities) {
|
||||
$availability = new Availability();
|
||||
$availability->serviceId = (int) $node->attr('id');
|
||||
$availability->status = $node->attr('status');
|
||||
$availability->status = $this->toServiceStatus($node->attr('status'));
|
||||
$availability->available = (int) $node->attr('frei');
|
||||
$availability->price = $this->stringToFloat($node->attr('preis'));
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ExtendedAvailabilitiesParser extends AbstractParser
|
||||
|
||||
$availability = new ExtendedAvailability();
|
||||
$availability->serviceId = $serviceId;
|
||||
$availability->status = $node->attr('status');
|
||||
$availability->status = $this->toServiceStatus($node->attr('status'));
|
||||
$availability->available = (int) $node->attr('frei');
|
||||
$availability->price = $this->stringToFloat($node->attr('preis'));
|
||||
$availability->dateFrom = $this->stringToDate($node->attr('termin'));
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\Client\BookingClient;
|
||||
use App\BusProNet\Client\TravelClient;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\BookingChangeTracker;
|
||||
use App\Service\BookingEditDraftMerger;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Repairs a booking edit draft that BusPro keeps rejecting.
|
||||
*
|
||||
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
||||
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
||||
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
||||
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
||||
* accumulated 37 such failures over two months.
|
||||
*
|
||||
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
||||
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
||||
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
||||
* every field the customer typed, and reverts only the service selections that BusPro will not
|
||||
* accept - naming each one, so the office can tell the customer what to pick again.
|
||||
*
|
||||
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:booking:repair-draft',
|
||||
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
||||
)]
|
||||
class BookingRepairDraftCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Draft `services` keys holding a single service id.
|
||||
*/
|
||||
/**
|
||||
* Date of birth the office writes into the empty slots of a template booking.
|
||||
*/
|
||||
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
||||
|
||||
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
||||
|
||||
/**
|
||||
* Draft `services` keys holding a list of service ids.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
|
||||
|
||||
/**
|
||||
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
|
||||
* pickup and drop-off that only make sense on a coach.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $draftRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly BookingClient $bookingClient,
|
||||
private readonly TravelClient $travelClient,
|
||||
private readonly Crypt $crypt,
|
||||
private readonly TravelDataProvider $travelDataProvider,
|
||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||
private readonly BookingEditDraftMerger $draftMerger,
|
||||
private readonly BookingChangeTracker $changeTracker,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
|
||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
|
||||
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bookingId = $input->getOption('booking');
|
||||
if (null === $bookingId) {
|
||||
$io->error('--booking is required.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$draft = $this->findSingleDraft($io, (int) $bookingId);
|
||||
if (null === $draft) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = $draft->getUser();
|
||||
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
|
||||
if (null === $booking) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
|
||||
if (null === $travel) {
|
||||
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$formData = $draft->getFormData();
|
||||
$draftParticipants = $formData['participants'] ?? [];
|
||||
|
||||
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$blocked = $this->collectUnbookableServices($io, $travel);
|
||||
|
||||
// The booking as BusPro currently holds it is the only state known to be acceptable, so
|
||||
// it is the base everything is rebuilt on.
|
||||
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
|
||||
$booking,
|
||||
$travel,
|
||||
false,
|
||||
);
|
||||
|
||||
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
|
||||
|
||||
$reverted = [];
|
||||
foreach ($draftParticipants as $index => $participantData) {
|
||||
if (false === isset($repairedDto->participants[$index])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered = $this->filterServiceSelections(
|
||||
$participantData,
|
||||
$liveSelections[$index]['services'] ?? [],
|
||||
$blocked,
|
||||
$index,
|
||||
$reverted,
|
||||
);
|
||||
|
||||
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
|
||||
}
|
||||
|
||||
$repaired = $this->changeTracker->extractUserData($repairedDto);
|
||||
|
||||
$this->report($io, $formData, $repaired, $reverted, $blocked);
|
||||
|
||||
if (false === $input->getOption('apply')) {
|
||||
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
|
||||
$io->success(sprintf('Original form data backed up to %s', $backupPath));
|
||||
|
||||
$draft->setFormData($repaired);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
|
||||
{
|
||||
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
|
||||
|
||||
if ([] === $drafts) {
|
||||
$io->error(sprintf('No draft found for booking %d.', $bookingId));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($drafts) > 1) {
|
||||
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$draft = $drafts[0];
|
||||
$io->definitionList(
|
||||
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
|
||||
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
|
||||
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
|
||||
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
|
||||
);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
||||
{
|
||||
$result = $this->bookingClient->getBooking(
|
||||
(string) $user->getEmail(),
|
||||
$this->crypt->decrypt((string) $user->getPassword()),
|
||||
$bookingId,
|
||||
);
|
||||
|
||||
if ($result instanceof Notification) {
|
||||
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to repair a draft whose positions no longer line up with the live booking.
|
||||
*
|
||||
* Drafts are merged positionally, by array index, with no identity check. Empty template
|
||||
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
|
||||
* date of birth are the only anchors available. If one of those has moved, every later
|
||||
* position is suspect and rebuilding would quietly graft data onto the wrong people.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $draftParticipants
|
||||
*/
|
||||
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
|
||||
{
|
||||
$liveCount = count($booking->participants);
|
||||
$draftCount = count($draftParticipants);
|
||||
|
||||
if ($liveCount !== $draftCount) {
|
||||
$io->warning(sprintf(
|
||||
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
|
||||
$liveCount,
|
||||
$draftCount,
|
||||
));
|
||||
}
|
||||
|
||||
$anchors = 0;
|
||||
$mismatches = [];
|
||||
|
||||
foreach (array_values($booking->participants) as $index => $participant) {
|
||||
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
|
||||
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$anchors;
|
||||
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
|
||||
|
||||
if (null !== $draftDob && $draftDob !== $liveDob) {
|
||||
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] !== $mismatches) {
|
||||
$io->error('Draft positions no longer match the live booking:');
|
||||
$io->listing($mismatches);
|
||||
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the services BusPro will not accept as an addition.
|
||||
*
|
||||
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
|
||||
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
|
||||
* Live availability is preferred over the travel data, which lags behind it.
|
||||
*
|
||||
* @return array<int, string> service id => status
|
||||
*/
|
||||
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
|
||||
{
|
||||
$statuses = [];
|
||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||
if (null !== $service->id && null !== $service->status) {
|
||||
$statuses[$service->id] = $service->status;
|
||||
}
|
||||
}
|
||||
|
||||
$live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
|
||||
if ($live instanceof Notification) {
|
||||
$io->warning(sprintf(
|
||||
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
||||
(int) $live->code,
|
||||
(string) $live->message,
|
||||
));
|
||||
} else {
|
||||
foreach ($live->getServices() as $availability) {
|
||||
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
|
||||
$statuses[$availability->serviceId] = $availability->status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
|
||||
|
||||
if ([] === $blocked) {
|
||||
$io->text('Every service on this travel is currently "Frei".');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($blocked as $serviceId => $status) {
|
||||
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
|
||||
$label = $service instanceof Service ? (string) $service->label : '?';
|
||||
$rows[] = [$serviceId, $label, $status];
|
||||
}
|
||||
|
||||
$io->section('Services that cannot take new participants');
|
||||
$io->table(['id', 'Leistung', 'status'], $rows);
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the draft's selections that would add a participant to a service BusPro has closed.
|
||||
*
|
||||
* Removing a key leaves the live booking's own value in place, because the merger applies
|
||||
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
|
||||
* preference - are kept, so the customer loses as little as possible.
|
||||
*
|
||||
* @param array<string, mixed> $participantData
|
||||
* @param array<string, mixed> $liveServices
|
||||
* @param array<int, string> $blocked
|
||||
* @param list<string> $reverted
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filterServiceSelections(
|
||||
array $participantData,
|
||||
array $liveServices,
|
||||
array $blocked,
|
||||
int $index,
|
||||
array &$reverted,
|
||||
): array {
|
||||
if (false === isset($participantData['services']) || [] === $blocked) {
|
||||
return $participantData;
|
||||
}
|
||||
|
||||
$services = $participantData['services'];
|
||||
$position = $index + 1;
|
||||
|
||||
foreach (self::SINGLE_SERVICE_KEYS as $key) {
|
||||
$selected = $services[$key] ?? null;
|
||||
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
|
||||
unset($services[$key]);
|
||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::LIST_SERVICE_KEYS as $key) {
|
||||
$selected = $services[$key] ?? null;
|
||||
if (false === is_array($selected)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$liveList = $liveServices[$key] ?? [];
|
||||
$kept = [];
|
||||
foreach ($selected as $serviceId) {
|
||||
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
|
||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$kept[] = $serviceId;
|
||||
}
|
||||
|
||||
$services[$key] = $kept;
|
||||
}
|
||||
|
||||
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
|
||||
$selected = $services[$key] ?? null;
|
||||
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||
|
||||
// Transport reverts as a unit, pickup and drop-off included.
|
||||
foreach (self::TRANSPORT_KEYS as $transportKey) {
|
||||
unset($services[$transportKey]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$participantData['services'] = $services;
|
||||
|
||||
return $participantData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $original
|
||||
* @param array<string, mixed> $repaired
|
||||
* @param list<string> $reverted
|
||||
* @param array<int, string> $blocked
|
||||
*/
|
||||
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
|
||||
{
|
||||
$io->section('Typed data preserved');
|
||||
|
||||
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
|
||||
$kept = 0;
|
||||
$lost = [];
|
||||
|
||||
foreach ($original['participants'] ?? [] as $index => $participantData) {
|
||||
foreach ($fields as $field) {
|
||||
$before = $participantData['personalData'][$field] ?? null;
|
||||
if (null === $before || '' === $before) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
|
||||
if ($before === $after) {
|
||||
++$kept;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
|
||||
}
|
||||
}
|
||||
|
||||
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
|
||||
|
||||
if ([] !== $lost) {
|
||||
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
|
||||
$io->listing(array_slice($lost, 0, 25));
|
||||
}
|
||||
|
||||
$io->section('Service selections reverted');
|
||||
|
||||
if ([] === $reverted) {
|
||||
$io->text([] === $blocked
|
||||
? 'None - no service on this travel is closed.'
|
||||
: 'None - the draft selects no closed service.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
|
||||
$io->listing($reverted);
|
||||
}
|
||||
|
||||
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
|
||||
{
|
||||
$directory = $this->projectDir.'/'.trim($backupDir, '/');
|
||||
if (false === is_dir($directory)) {
|
||||
mkdir($directory, 0o775, true);
|
||||
}
|
||||
|
||||
$path = sprintf(
|
||||
'%s/draft-%d-booking-%d-%s.json',
|
||||
$directory,
|
||||
(int) $draft->getId(),
|
||||
$draft->getBookingId(),
|
||||
(new \DateTimeImmutable())->format('Ymd-His'),
|
||||
);
|
||||
|
||||
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
|
||||
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
|
||||
* to re-sync a single accommodation while debugging.
|
||||
* to re-sync a single hotel code while debugging.
|
||||
*
|
||||
* Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus
|
||||
* TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both
|
||||
* sources, so the snapshot tables only ever describe hotels that are still synced.
|
||||
*/
|
||||
class BpnSyncContingentsCommand extends Command
|
||||
{
|
||||
use LockableTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly ContingentDayRepository $dayRepository,
|
||||
private readonly ContingentSnapshotManager $snapshotManager,
|
||||
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
|
||||
{
|
||||
$this
|
||||
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
|
||||
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
|
||||
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
|
||||
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code')
|
||||
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
|
||||
;
|
||||
}
|
||||
@@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
$dateFrom = $today->toDateTimeImmutable();
|
||||
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
|
||||
|
||||
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
|
||||
$this->hotelDirectory->refresh();
|
||||
// array_keys() turns a numeric code into an int.
|
||||
$allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all()));
|
||||
$hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes;
|
||||
|
||||
if ([] === $accommodations) {
|
||||
$io->warning('No accommodation with a calendarCode matched.');
|
||||
if ([] === $hotelCodes) {
|
||||
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
|
||||
$changed = 0;
|
||||
$failedCodes = [];
|
||||
|
||||
foreach ($accommodations as $accommodation) {
|
||||
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
|
||||
foreach ($hotelCodes as $code) {
|
||||
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
|
||||
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
|
||||
++$processed;
|
||||
|
||||
if (!$result->successful) {
|
||||
$failedCodes[] = (string) $accommodation->getCalendarCode();
|
||||
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
|
||||
$failedCodes[] = $code;
|
||||
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
|
||||
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||
(string) $accommodation->getCalendarCode(),
|
||||
$code,
|
||||
$result->changed ? 'changed' : 'unchanged',
|
||||
$result->added,
|
||||
$result->updated,
|
||||
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
|
||||
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
||||
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
|
||||
|
||||
// Only a full run knows the whole set. A code that left both sources is not served any
|
||||
// more (the availability endpoint answers 400 for it), so its snapshot is dead weight;
|
||||
// should it come back, the next run rebuilds it.
|
||||
if (!is_string($hotelCode)) {
|
||||
$orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||
$orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||
|
||||
if ($orphanedStates > 0) {
|
||||
$this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [
|
||||
'hotels' => $orphanedStates,
|
||||
'days' => $orphanedDays,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Only a *total* failure is worth failing the task over: that means upstream is down or
|
||||
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
|
||||
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
|
||||
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Accommodation[]
|
||||
*/
|
||||
private function resolveAccommodations(?string $hotelCode): array
|
||||
{
|
||||
if (null === $hotelCode) {
|
||||
return $this->accommodationRepository->findAllWithCalendarCode();
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
|
||||
return null !== $accommodation ? [$accommodation] : [];
|
||||
}
|
||||
|
||||
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
|
||||
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
|
||||
{
|
||||
if (0 === $staleAfterMinutes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use App\Service\TravelMailingListManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cleanup:travel-mailing-lists',
|
||||
description: 'Deletes travel mailing lists and their orphaned contacts from Mailjet 30 days after the travel started',
|
||||
)]
|
||||
class CleanupTravelMailingListsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||
private readonly TravelMailingListManager $travelMailingListManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$mailingLists = $this->travelMailingListRepository->findDueForDeletion(new \DateTimeImmutable());
|
||||
if ([] === $mailingLists) {
|
||||
$io->success('No travel mailing lists due for deletion.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// A travel's lists are deleted together (see TravelMailingListManager::delete()), including
|
||||
// one that is not due yet because the travel's start date moved after it was created.
|
||||
$travelCodes = array_unique(array_map(static fn (TravelMailingList $mailingList) => $mailingList->getTravelCode(), $mailingLists));
|
||||
|
||||
$failed = 0;
|
||||
$deletedLists = 0;
|
||||
foreach ($travelCodes as $travelCode) {
|
||||
$travelLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||
$listIds = implode(', ', array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $travelLists));
|
||||
|
||||
try {
|
||||
$deletedContacts = $this->travelMailingListManager->delete($travelLists);
|
||||
$deletedLists += count($travelLists);
|
||||
$io->writeln(sprintf('Deleted %s (lists %s) and %d orphaned contact(s).', $travelCode, $listIds, $deletedContacts));
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
// The rows of lists not yet deleted stay, so the next run picks them up again.
|
||||
++$failed;
|
||||
$this->logger->error('Delete travel mailing lists failed', [
|
||||
'travel_code' => $travelCode,
|
||||
'mailjet_list_ids' => $listIds,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
$io->error(sprintf('%s (lists %s): %s', $travelCode, $listIds, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Deleted %d travel mailing list(s).', $deletedLists));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\TravelMailing;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\BusProNet\Utility\DateCodeUtility;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use App\Service\TravelDataProvider;
|
||||
use App\Service\TravelMailingListManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||
class CreateListController extends AbstractController
|
||||
{
|
||||
public const string CSRF_TOKEN_ID = 'create_travel_mailing_list';
|
||||
|
||||
public function __construct(
|
||||
private readonly TravelsClient $travelsClient,
|
||||
private readonly TravelDataProvider $travelDataProvider,
|
||||
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||
private readonly TravelMailingListManager $travelMailingListManager,
|
||||
private readonly LoggerInterface $adminLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/travel-mailing/list', name: 'app_admin_travelmailing_createlist', methods: ['POST'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$lookup = new TravelMailingLookupDto();
|
||||
$lookup->travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
|
||||
$lookup->busOnly = $request->request->getBoolean('busOnly');
|
||||
$travelCode = $lookup->travelCode;
|
||||
$busLeg = $lookup->getBusLeg();
|
||||
$returnUrl = $this->generateUrl('app_admin_travelmailing', array_filter([
|
||||
'travelCode' => $travelCode,
|
||||
'busOnly' => $lookup->busOnly ? 1 : null,
|
||||
]));
|
||||
|
||||
$name = TravelMailingList::nameFor($travelCode, $busLeg);
|
||||
if (null !== $this->travelMailingListRepository->findOneByName($name)) {
|
||||
$this->addFlash('error', sprintf('Die Mailjet-Liste %s gibt es bereits.', $name));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
|
||||
if (null === $travelStartsAt) {
|
||||
$this->addFlash('error', sprintf('Für %s ist kein Reisebeginn bekannt, die Liste könnte nicht automatisch gelöscht werden.', $travelCode));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
// Fetched again rather than posted from the page, so the list holds what BusPro reports now.
|
||||
try {
|
||||
$recipients = $this->travelsClient->getMailingRecipients($travelCode, $busLeg)->data;
|
||||
} catch (BpnConnectException) {
|
||||
$this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.');
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
if ([] === $recipients) {
|
||||
$this->addFlash('error', sprintf('Keine Empfänger für %s gefunden.', $travelCode));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $busLeg, $travelStartsAt);
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->adminLogger->error('Create travel mailing list failed', [
|
||||
'travel_code' => $travelCode,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
$this->addFlash('error', 'Die Mailjet-Liste konnte nicht angelegt werden: '.$exception->getMessage());
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
$this->adminLogger->info('Create travel mailing list', [
|
||||
'travel_code' => $travelCode,
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
'bus_leg' => $busLeg?->value,
|
||||
'recipients' => count($recipients),
|
||||
]);
|
||||
$this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $mailingList->getName(), count($recipients)));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ namespace App\Controller\Admin\TravelMailing;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\BusProNet\Utility\DateCodeUtility;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Form\Admin\TravelMailingLookupType;
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -22,6 +24,7 @@ class IndexController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly TravelsClient $travelsClient,
|
||||
private readonly TravelDataProvider $travelDataProvider,
|
||||
private readonly TravelMailingListRepository $travelMailingListRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,6 +39,10 @@ class IndexController extends AbstractController
|
||||
|
||||
$result = null;
|
||||
$travelLabel = null;
|
||||
$travelStartsAt = null;
|
||||
$mailingLists = [];
|
||||
$mailingList = null;
|
||||
$listName = null;
|
||||
$failed = false;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126"
|
||||
@@ -43,8 +50,17 @@ class IndexController extends AbstractController
|
||||
$travelCode = (new DateCodeUtility())->sanitize((string) $lookup->travelCode);
|
||||
|
||||
try {
|
||||
$result = $this->travelsClient->getMailingRecipients($travelCode);
|
||||
$result = $this->travelsClient->getMailingRecipients($travelCode, $lookup->getBusLeg());
|
||||
$travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode);
|
||||
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
|
||||
$mailingLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||
// The list matching the current filter, if one exists, replaces the create button.
|
||||
$listName = TravelMailingList::nameFor($travelCode, $lookup->getBusLeg());
|
||||
foreach ($mailingLists as $existing) {
|
||||
if ($listName === $existing->getName()) {
|
||||
$mailingList = $existing;
|
||||
}
|
||||
}
|
||||
} catch (BpnConnectException) {
|
||||
// The client has already logged the cause; the page only needs to say it failed.
|
||||
$failed = true;
|
||||
@@ -55,6 +71,10 @@ class IndexController extends AbstractController
|
||||
'form' => $form,
|
||||
'result' => $result,
|
||||
'travel_label' => $travelLabel,
|
||||
'travel_starts_at' => $travelStartsAt,
|
||||
'mailing_lists' => $mailingLists,
|
||||
'mailing_list' => $mailingList,
|
||||
'list_name' => $listName,
|
||||
'failed' => $failed,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentPricesQuery;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotReader;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -33,6 +35,8 @@ class ContingentController extends AbstractController
|
||||
private readonly ContingentSnapshotReader $snapshotReader,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -107,7 +111,7 @@ class ContingentController extends AbstractController
|
||||
// An empty or stale snapshot must not be served as though every day were blocked: that
|
||||
// is a plausible-looking 200 nobody can distinguish from real data. Fail the way this
|
||||
// endpoint always failed instead, so existing consumers need no change.
|
||||
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
|
||||
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
if (null === $statuses) {
|
||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||
@@ -133,6 +137,86 @@ class ContingentController extends AbstractController
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are
|
||||
* left out rather than blocked, so a portal shows them as free like it did with the feed
|
||||
* that read bpn-connect directly.
|
||||
*
|
||||
* Serves every synced hotel code, including TYPO3's calendar hotels that have no
|
||||
* accommodation (winter-only hotels).
|
||||
*/
|
||||
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
|
||||
public function availability(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ContingentCalendarQuery $query,
|
||||
): JsonResponse {
|
||||
if (null === $this->hotelDirectory->find($query->hotelCode)) {
|
||||
$this->logger->error('Hotel not found for hotelcode '.$query->hotelCode);
|
||||
|
||||
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Without a range: from today to wherever the snapshot ends.
|
||||
$dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0);
|
||||
$dateTo = $query->dateToDate();
|
||||
|
||||
// Same reasoning as calendar(): a missing or stale snapshot must not pass for real data.
|
||||
// Here it matters even more, since an empty feed would show every day as free.
|
||||
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
if (null === $statuses) {
|
||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||
}
|
||||
|
||||
ksort($statuses);
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($statuses as $date => $status) {
|
||||
$data[] = ['date' => (string) $date, 'status' => $status->value];
|
||||
}
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per synced hotel code, for the overview of which hotel supports which calendar.
|
||||
*
|
||||
* `available` is whether `availability` currently serves the hotel; calendar and price table
|
||||
* additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show.
|
||||
*/
|
||||
#[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$states = $this->syncStateRepository->findAllIndexedByHotelCode();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($this->hotelDirectory->all() as $hotel) {
|
||||
$state = $states[$hotel->code] ?? null;
|
||||
$accommodation = $hotel->accommodation;
|
||||
$bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null;
|
||||
|
||||
$data[] = [
|
||||
'hotelCode' => $hotel->code,
|
||||
'name' => $hotel->calendarHotel?->getName(),
|
||||
'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()),
|
||||
'accommodation' => null !== $accommodation ? [
|
||||
'name' => $accommodation->getName(),
|
||||
'cmsCode' => $accommodation->getEffectiveCmsCode(),
|
||||
] : null,
|
||||
'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null,
|
||||
'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null,
|
||||
'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM),
|
||||
'lastError' => $state?->getLastError(),
|
||||
'failureCount' => $state?->getFailureCount() ?? 0,
|
||||
'available' => $this->snapshotReader->isCurrent($state),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array<string, true> $covered
|
||||
|
||||
@@ -240,7 +240,7 @@ class Step1Controller extends AbstractController
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
$contingentStatus = null !== $accommodation
|
||||
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
|
||||
? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? []
|
||||
: [];
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Local copy of the last calendar hotel list TYPO3 answered successfully.
|
||||
*
|
||||
* Kept so the contingent sync still covers TYPO3's hotels while TYPO3 is unreachable. Written
|
||||
* only by {@see \App\Service\ContingentHotelDirectory::refresh()}, never by hand: the list is
|
||||
* TYPO3's, this is not a third place to maintain hotel codes in.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CalendarHotelRepository::class)]
|
||||
#[ORM\Table(name: 'calendar_hotel')]
|
||||
class CalendarHotel
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): self
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
/**
|
||||
* Local snapshot of a single day of contingent availability as reported by bpn-connect.
|
||||
*
|
||||
* Keyed by hotel code, not by accommodation: the snapshot also covers TYPO3's calendar hotels,
|
||||
* which have no `Accommodation`.
|
||||
*
|
||||
* Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ContingentDayRepository::class)]
|
||||
#[ORM\Table(name: 'contingent_day')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_hotel_code_date', columns: ['hotel_code', 'date'])]
|
||||
class ContingentDay
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -24,8 +27,15 @@ class ContingentDay
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode and no longer written. Only rows from before the switch still
|
||||
* carry it; it stays mapped until the column is dropped, so the switch can be rolled back.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
@@ -39,6 +49,18 @@ class ContingentDay
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function setHotelCode(string $hotelCode): self
|
||||
{
|
||||
$this->hotelCode = $hotelCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
||||
* Bookkeeping for the scheduled contingent sync of a single hotel code.
|
||||
*
|
||||
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||
@@ -23,8 +23,14 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode, see {@see ContingentDay::$accommodation}.
|
||||
*/
|
||||
#[ORM\OneToOne]
|
||||
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, unique: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
/**
|
||||
@@ -48,9 +54,9 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private int $failureCount = 0;
|
||||
|
||||
public function __construct(?Accommodation $accommodation = null)
|
||||
public function __construct(?string $hotelCode = null)
|
||||
{
|
||||
$this->accommodation = $accommodation;
|
||||
$this->hotelCode = $hotelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -58,6 +64,11 @@ class ContingentSyncState
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A Mailjet contact list created for one travel's participants. The row exists so the list can
|
||||
* be deleted again once the travel is past; the list itself is never updated after creation.
|
||||
*
|
||||
* A travel can have two lists, one for all participants and one for bus passengers only, told
|
||||
* apart by their name.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TravelMailingListRepository::class)]
|
||||
#[ORM\Index(columns: ['travel_code'])]
|
||||
class TravelMailingList
|
||||
{
|
||||
private const string BUS_ONLY_SUFFIX = ' nur Bus';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $travelCode;
|
||||
|
||||
/** The Mailjet list name. */
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $mailjetListId;
|
||||
|
||||
/** The bus leg its participants were filtered by; null when it holds all participants. */
|
||||
#[ORM\Column(length: 20, nullable: true, enumType: BusLeg::class)]
|
||||
private ?BusLeg $busLeg;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $deleteAfter;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(string $travelCode, int $mailjetListId, ?BusLeg $busLeg, \DateTimeImmutable $deleteAfter)
|
||||
{
|
||||
$this->travelCode = $travelCode;
|
||||
$this->name = self::nameFor($travelCode, $busLeg);
|
||||
$this->mailjetListId = $mailjetListId;
|
||||
$this->busLeg = $busLeg;
|
||||
$this->deleteAfter = $deleteAfter;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public static function nameFor(string $travelCode, ?BusLeg $busLeg): string
|
||||
{
|
||||
return null !== $busLeg ? $travelCode.self::BUS_ONLY_SUFFIX : $travelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTravelCode(): string
|
||||
{
|
||||
return $this->travelCode;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getMailjetListId(): int
|
||||
{
|
||||
return $this->mailjetListId;
|
||||
}
|
||||
|
||||
public function getBusLeg(): ?BusLeg
|
||||
{
|
||||
return $this->busLeg;
|
||||
}
|
||||
|
||||
public function getDeleteAfter(): \DateTimeImmutable
|
||||
{
|
||||
return $this->deleteAfter;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Which bus leg a travel participant must have booked to be selected, as bpn-connect's travel
|
||||
* mailing endpoint takes it (`busLeg`).
|
||||
*/
|
||||
enum BusLeg: string
|
||||
{
|
||||
case Outbound = 'outbound';
|
||||
case Inbound = 'inbound';
|
||||
case Any = 'any';
|
||||
case Both = 'both';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Outbound => 'Busreisende (Hinfahrt)',
|
||||
self::Inbound => 'Busreisende (Rückfahrt)',
|
||||
self::Any => 'Busreisende (Hin- oder Rückfahrt)',
|
||||
self::Both => 'Busreisende (Hin- und Rückfahrt)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Where a hotel code in the contingent snapshot comes from. A code can have both.
|
||||
*/
|
||||
enum ContingentHotelSource: string
|
||||
{
|
||||
/** The calendarCode of a group house (`Accommodation`). */
|
||||
case Accommodation = 'accommodation';
|
||||
|
||||
/** The calendar hotel of a visible TYPO3 product, from TYPO3's `/api/calendar-hotels`. */
|
||||
case Typo3 = 'typo3';
|
||||
}
|
||||
@@ -6,13 +6,14 @@ namespace App\Form\Admin;
|
||||
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
|
||||
* and can be bookmarked or shared.
|
||||
* (plus `&busOnly=1`) and can be bookmarked or shared.
|
||||
*
|
||||
* @extends AbstractType<TravelMailingLookupDto>
|
||||
*/
|
||||
@@ -26,6 +27,10 @@ class TravelMailingLookupType extends AbstractType
|
||||
'placeholder' => 'z. B. LPJMP281126',
|
||||
],
|
||||
]);
|
||||
$builder->add('busOnly', CheckboxType::class, [
|
||||
'label' => 'nur '.TravelMailingLookupDto::BUS_LEG->label(),
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -4,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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+147
-1
@@ -12,6 +12,7 @@ class ApiClient
|
||||
{
|
||||
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
||||
private const int NAME_MAX_LENGTH = 255;
|
||||
private const int PAGE_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
|
||||
@@ -141,6 +142,139 @@ class ApiClient
|
||||
]);
|
||||
}
|
||||
|
||||
public function createContactsList(string $name): int
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$response = $this->request('POST', 'contactslist', [
|
||||
'json' => [
|
||||
'Name' => $name,
|
||||
],
|
||||
]);
|
||||
|
||||
$listId = $response['Data'][0]['ID'] ?? null;
|
||||
if (null === $listId) {
|
||||
throw new NewsletterProviderException(sprintf('Mailjet did not return an id for the new contact list %s', $name));
|
||||
}
|
||||
|
||||
return (int) $listId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the contacts as subscribed, including any who unsubscribed from this list before.
|
||||
* Mailjet processes the request as an asynchronous job.
|
||||
*
|
||||
* @param list<array{email: string, firstName: ?string}> $contacts
|
||||
*
|
||||
* @return int|null the job id
|
||||
*/
|
||||
public function addContactsToList(int $listId, array $contacts): ?int
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$payload = [];
|
||||
foreach ($contacts as $contact) {
|
||||
$entry = ['Email' => $this->normalizeEmail($contact['email'])];
|
||||
|
||||
$firstName = $this->normalizeName($contact['firstName']);
|
||||
if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
|
||||
$entry['Properties'] = [$this->contactMetadataFields['firstName'] => $firstName];
|
||||
}
|
||||
|
||||
$payload[] = $entry;
|
||||
}
|
||||
|
||||
$response = $this->request('POST', sprintf('contactslist/%d/managemanycontacts', $listId), [
|
||||
'json' => [
|
||||
'Action' => 'addforce',
|
||||
'Contacts' => $payload,
|
||||
],
|
||||
]);
|
||||
|
||||
$jobId = $response['Data'][0]['JobID'] ?? null;
|
||||
|
||||
return null !== $jobId ? (int) $jobId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getListContactIds(int $listId): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$contactIds = [];
|
||||
$offset = 0;
|
||||
do {
|
||||
$response = $this->request('GET', 'listrecipient', [
|
||||
'query' => [
|
||||
'ContactsList' => $listId,
|
||||
'Limit' => self::PAGE_LIMIT,
|
||||
'Offset' => $offset,
|
||||
],
|
||||
]);
|
||||
|
||||
$entries = true === is_array($response['Data'] ?? null) ? $response['Data'] : [];
|
||||
foreach ($entries as $entry) {
|
||||
if (true === is_array($entry) && true === isset($entry['ContactID'])) {
|
||||
$contactIds[] = (int) $entry['ContactID'];
|
||||
}
|
||||
}
|
||||
|
||||
$offset += self::PAGE_LIMIT;
|
||||
} while (self::PAGE_LIMIT === count($entries));
|
||||
|
||||
return array_values(array_unique($contactIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the contact is on, subscribed or not. Mailjet includes lists deleted within the last 60 days.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getContactListIds(int $contactId): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$response = $this->request('GET', sprintf('contact/%d/getcontactslists', $contactId), [
|
||||
'allow_404' => true,
|
||||
]);
|
||||
|
||||
$listIds = [];
|
||||
foreach ($response['Data'] ?? [] as $entry) {
|
||||
if (true === is_array($entry) && true === isset($entry['ListID'])) {
|
||||
$listIds[] = (int) $entry['ListID'];
|
||||
}
|
||||
}
|
||||
|
||||
return $listIds;
|
||||
}
|
||||
|
||||
public function deleteContactsList(int $listId): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$this->request('DELETE', sprintf('contactslist/%d', $listId), [
|
||||
'allow_404' => true,
|
||||
'allow_empty' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GDPR deletion: Mailjet anonymises the contact immediately and purges it after 30 days.
|
||||
* This cannot be undone.
|
||||
*/
|
||||
public function deleteContact(int $contactId): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$this->request('DELETE', sprintf('contacts/%d', $contactId), [
|
||||
'allow_404' => true,
|
||||
'allow_empty' => true,
|
||||
'api_version' => 'v4',
|
||||
]);
|
||||
}
|
||||
|
||||
private function findOrCreateContactId(string $email): int
|
||||
{
|
||||
$contactId = $this->resolveContactId($email);
|
||||
@@ -246,15 +380,17 @@ class ApiClient
|
||||
{
|
||||
$allow404 = true === ($options['allow_404'] ?? false);
|
||||
$allowEmpty = true === ($options['allow_empty'] ?? false);
|
||||
$baseUrl = 'v4' === ($options['api_version'] ?? null) ? $this->getV4BaseUrl() : $this->getBaseUrl();
|
||||
unset($options['allow_404']);
|
||||
unset($options['allow_empty']);
|
||||
unset($options['api_version']);
|
||||
|
||||
$resourcePath = trim($resource, '/');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
$method,
|
||||
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
|
||||
sprintf('%s/%s', $baseUrl, $resourcePath),
|
||||
array_merge($options, [
|
||||
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
|
||||
])
|
||||
@@ -310,6 +446,16 @@ class ApiClient
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured base URL points at v3 (`…/v3/REST`); the v4 API sits beside it on the same host.
|
||||
*/
|
||||
private function getV4BaseUrl(): string
|
||||
{
|
||||
$baseUrl = preg_replace('~/v3/REST$~i', '', $this->getBaseUrl());
|
||||
|
||||
return sprintf('%s/v4', $baseUrl);
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* One entry of TYPO3's `/api/calendar-hotels`: the calendar hotel of a visible product.
|
||||
*
|
||||
* TYPO3 also lists the products per hotel; only the code and the name are kept here.
|
||||
*/
|
||||
final readonly class CmsCalendarHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery
|
||||
$hasFrom = null !== $from && '' !== $from;
|
||||
$hasTo = null !== $to && '' !== $to;
|
||||
|
||||
// Neither supplied: the caller wants the full priced span, which the persisted prices define.
|
||||
// Neither supplied: the caller wants the full span, which each endpoint defines for itself.
|
||||
if (!$hasFrom && !$hasTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Enum\ContingentHotelSource;
|
||||
|
||||
/**
|
||||
* A hotel code the contingent snapshot is kept for, with whatever it is known as locally.
|
||||
*/
|
||||
final readonly class ContingentHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public ?Accommodation $accommodation,
|
||||
public ?CalendarHotel $calendarHotel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContingentHotelSource>
|
||||
*/
|
||||
public function sources(): array
|
||||
{
|
||||
$sources = [];
|
||||
|
||||
if (null !== $this->accommodation) {
|
||||
$sources[] = ContingentHotelSource::Accommodation;
|
||||
}
|
||||
|
||||
if (null !== $this->calendarHotel) {
|
||||
$sources[] = ContingentHotelSource::Typo3;
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CalendarHotel>
|
||||
*/
|
||||
class CalendarHotelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CalendarHotel::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CalendarHotel>
|
||||
*/
|
||||
public function findAllIndexedByCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $hotel) {
|
||||
$indexed[(string) $hotel->getCode()] = $hotel;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -20,53 +19,30 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
|
||||
* identified by the given hotel code, keyed by Y-m-d.
|
||||
* Returns the snapshot days of a hotel code within [dateFrom, dateTo], keyed by Y-m-d.
|
||||
* A null dateTo means everything from dateFrom to the end of the snapshot.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
public function findByHotelCodeAndDateRange(
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
/** @var ContingentDay[] $days */
|
||||
$days = $this->createQueryBuilder('cd')
|
||||
->join('cd.accommodation', 'a')
|
||||
->where('a.calendarCode = :hotelCode')
|
||||
$qb = $this->createQueryBuilder('cd')
|
||||
->where('cd.hotelCode = :hotelCode')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('hotelCode', $hotelCode)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->orderBy('cd.date', 'ASC');
|
||||
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
if (null !== $dateTo) {
|
||||
$qb->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('dateTo', $dateTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as above but for a known accommodation; used by the sync to diff against.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
public function findByAccommodationAndDateRange(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
/** @var ContingentDay[] $days */
|
||||
$days = $this->createQueryBuilder('cd')
|
||||
->where('cd.accommodation = :accommodation')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('accommodation', $accommodation)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$days = $qb->getQuery()->getResult();
|
||||
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
@@ -84,6 +60,21 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the days of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('cd')
|
||||
->delete()
|
||||
->where('cd.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ContingentDay[] $days
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -19,30 +18,37 @@ class ContingentSyncStateRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, ContingentSyncState::class);
|
||||
}
|
||||
|
||||
public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState
|
||||
public function findOneByHotelCode(string $hotelCode): ?ContingentSyncState
|
||||
{
|
||||
return $this->findOneBy(['accommodation' => $accommodation]);
|
||||
return $this->findOneBy(['hotelCode' => $hotelCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every sync state keyed by accommodation id, for the revisions endpoint.
|
||||
*
|
||||
* @return array<int, ContingentSyncState>
|
||||
* @return array<string, ContingentSyncState>
|
||||
*/
|
||||
public function findAllIndexedByAccommodationId(): array
|
||||
public function findAllIndexedByHotelCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $state) {
|
||||
$accommodation = $state->getAccommodation();
|
||||
|
||||
if (null === $accommodation || null === $accommodation->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$accommodation->getId()] = $state;
|
||||
$indexed[(string) $state->getHotelCode()] = $state;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the sync state of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->delete()
|
||||
->where('s.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TravelMailingList;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TravelMailingList>
|
||||
*/
|
||||
class TravelMailingListRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TravelMailingList::class);
|
||||
}
|
||||
|
||||
public function findOneByName(string $name): ?TravelMailingList
|
||||
{
|
||||
return $this->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TravelMailingList>
|
||||
*/
|
||||
public function findByTravelCode(string $travelCode): array
|
||||
{
|
||||
return $this->findBy(['travelCode' => $travelCode], ['id' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TravelMailingList>
|
||||
*/
|
||||
public function findDueForDeletion(\DateTimeImmutable $now): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->where('l.deleteAfter <= :now')
|
||||
->setParameter('now', $now)
|
||||
->orderBy('l.deleteAfter', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ class BookingEditDraftMerger
|
||||
|
||||
// Room assignment
|
||||
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment']);
|
||||
}
|
||||
|
||||
// License plate
|
||||
@@ -177,26 +177,19 @@ class BookingEditDraftMerger
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the drafted room assignment to the participant.
|
||||
* Applies the drafted room remarks to the participant.
|
||||
*
|
||||
* The room id uses the same merge strategy as single-select services: it is only
|
||||
* applied when it resolves to a room of the current travel. A draft may therefore move
|
||||
* a participant to another room, but never unassign one. Edit mode renders the room as
|
||||
* static text and never submits assignedRoomId, so a null in the draft is only ever an
|
||||
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
|
||||
* would drop the participant's room, both in the UI and in the outbound zuordnung.
|
||||
* The drafted assignedRoomId is ignored. Edit mode renders the room as static text and
|
||||
* never submits it, so the draft only holds what BusPro reported when it was saved, and
|
||||
* replaying it would undo a reassignment the office made since. The room always comes
|
||||
* from the booking as loaded.
|
||||
*
|
||||
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
|
||||
{
|
||||
$assignedRoomId = $data['assignedRoomId'] ?? null;
|
||||
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
|
||||
$participant->assignedRoomId = (int) $assignedRoomId;
|
||||
}
|
||||
|
||||
if (true === array_key_exists('remarksRoom', $data)) {
|
||||
$participant->remarksRoom = $data['remarksRoom'];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Model\CmsCalendarHotel;
|
||||
use App\Model\CmsHotelData;
|
||||
use App\Model\CmsRegionData;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -169,6 +170,40 @@ class CmsDataProvider
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a
|
||||
* usable list. Uncached: the only caller keeps its own copy of the last good answer.
|
||||
*
|
||||
* @return list<CmsCalendarHotel>|null
|
||||
*/
|
||||
public function fetchCalendarHotels(): ?array
|
||||
{
|
||||
try {
|
||||
$data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray();
|
||||
} catch (ExceptionInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_is_list($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
$code = $entry['code'] ?? null;
|
||||
|
||||
if (!is_string($code) || '' === $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $entry['name'] ?? null;
|
||||
$hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code);
|
||||
}
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
|
||||
* the calendar hotels of TYPO3's visible products.
|
||||
*
|
||||
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
|
||||
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
|
||||
* TYPO3 does not make its hotels drop out of the sync.
|
||||
*/
|
||||
class ContingentHotelDirectory
|
||||
{
|
||||
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
|
||||
// for is not worth syncing, and it would not fit the column either.
|
||||
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
|
||||
|
||||
public function __construct(
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly CalendarHotelRepository $calendarHotelRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
|
||||
*
|
||||
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
|
||||
* a fault on its side, not every winter hotel being withdrawn at once.
|
||||
*/
|
||||
public function refresh(): bool
|
||||
{
|
||||
$fetched = [];
|
||||
|
||||
foreach ($this->cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) {
|
||||
if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) {
|
||||
$this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fetched[$hotel->code] = $hotel;
|
||||
}
|
||||
|
||||
if ([] === $fetched) {
|
||||
$this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
foreach ($fetched as $code => $hotel) {
|
||||
$calendarHotel = $stored[$code] ?? null;
|
||||
|
||||
if (null === $calendarHotel) {
|
||||
$this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name));
|
||||
} elseif ($calendarHotel->getName() !== $hotel->name) {
|
||||
$calendarHotel->setName($hotel->name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_diff_key($stored, $fetched) as $calendarHotel) {
|
||||
$this->entityManager->remove($calendarHotel);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentHotel> keyed and sorted by hotel code
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$accommodations = [];
|
||||
|
||||
foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) {
|
||||
$accommodations[(string) $accommodation->getCalendarCode()] = $accommodation;
|
||||
}
|
||||
|
||||
$calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach (array_keys($accommodations + $calendarHotels) as $code) {
|
||||
$code = (string) $code;
|
||||
$hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null);
|
||||
}
|
||||
|
||||
ksort($hotels, \SORT_STRING);
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
public function find(string $hotelCode): ?ContingentHotel
|
||||
{
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
$calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]);
|
||||
|
||||
if (null === $accommodation && null === $calendarHotel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ContingentHotel($hotelCode, $accommodation, $calendarHotel);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Service;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Model\ContingentSyncResult;
|
||||
@@ -34,24 +33,22 @@ class ContingentSnapshotManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes [dateFrom, dateTo] for a single accommodation.
|
||||
* Refreshes [dateFrom, dateTo] for a single hotel code.
|
||||
*
|
||||
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
|
||||
* the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a
|
||||
* full-horizon run already stored.
|
||||
*/
|
||||
public function sync(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ContingentSyncResult {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
|
||||
if ('' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('No hotel code given.');
|
||||
}
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode);
|
||||
|
||||
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||
// are the window growing, not the contingent situation changing.
|
||||
@@ -67,7 +64,7 @@ class ContingentSnapshotManager
|
||||
return $this->recordFailure($state, $hotelCode, $e->getMessage());
|
||||
}
|
||||
|
||||
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
|
||||
$existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
// An empty upstream response would otherwise wipe the window and render the whole
|
||||
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
|
||||
@@ -103,7 +100,7 @@ class ContingentSnapshotManager
|
||||
|
||||
if (null === $day) {
|
||||
$day = (new ContingentDay())
|
||||
->setAccommodation($accommodation)
|
||||
->setHotelCode($hotelCode)
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -41,22 +41,20 @@ class ContingentSnapshotReader
|
||||
* Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel.
|
||||
*
|
||||
* Days inside the range that the snapshot does not cover are simply absent from the map;
|
||||
* what that means is the caller's decision.
|
||||
* what that means is the caller's decision. A null dateTo reads to the end of the snapshot.
|
||||
*
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
public function statusesFor(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): ?array {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isUsable($accommodation, $hotelCode)) {
|
||||
if (!$this->isUsable($hotelCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,25 +67,36 @@ class ContingentSnapshotReader
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
|
||||
/**
|
||||
* Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without
|
||||
* the logging: for overviews that ask about every hotel at once.
|
||||
*/
|
||||
public function isCurrent(?ContingentSyncState $state): bool
|
||||
{
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE);
|
||||
}
|
||||
|
||||
private function isUsable(string $hotelCode): bool
|
||||
{
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode);
|
||||
|
||||
if ($this->isCurrent($state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
|
||||
} else {
|
||||
$this->logger->error('Contingent snapshot is stale', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -147,12 +147,8 @@ class RoomPricingCalculator
|
||||
}
|
||||
}
|
||||
|
||||
// Count current participant assignments from the DTO (reflects live state
|
||||
// including new assignments not yet in the booking entity)
|
||||
$assignmentCounts = $bookingDto->getRoomAssignmentCounts();
|
||||
|
||||
// Build pricing array
|
||||
foreach ($roomGroups as $roomId => $data) {
|
||||
foreach ($roomGroups as $data) {
|
||||
$room = $data['room'];
|
||||
$participantCount = $data['participantCount'];
|
||||
$totalPrice = $data['totalPrice'];
|
||||
@@ -160,14 +156,11 @@ class RoomPricingCalculator
|
||||
// Calculate average unit price (price per person)
|
||||
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
|
||||
|
||||
// Use live assignment count for quantity display so the sidebar reflects
|
||||
// new room assignments that haven't been persisted to the booking entity yet
|
||||
$quantity = $assignmentCounts[$roomId] ?? $room->totalCount;
|
||||
|
||||
$roomPricing[] = [
|
||||
'roomId' => $room->id,
|
||||
'label' => $room->label,
|
||||
'quantity' => $quantity,
|
||||
// Rooms, not occupants: room assignments cannot change in edit mode
|
||||
'quantity' => $room->totalCount,
|
||||
'participantCount' => $participantCount,
|
||||
'unitPrice' => $unitPrice,
|
||||
'totalPrice' => $totalPrice,
|
||||
|
||||
@@ -219,6 +219,10 @@ class ServiceAvailabilityCalculator
|
||||
/**
|
||||
* Calculate how many times each service has been selected by other participants.
|
||||
*
|
||||
* Only new selections count. In edit mode the form is prefilled with the live booking, whose
|
||||
* seats BusPro has already deducted from frei; counting them again would report a contingent
|
||||
* as exhausted while seats are still free.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data with participant selections
|
||||
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
||||
*
|
||||
@@ -238,6 +242,10 @@ class ServiceAvailabilityCalculator
|
||||
$participantUsage = $this->countParticipantServiceUsage($participant);
|
||||
|
||||
foreach ($participantUsage as $serviceId => $count) {
|
||||
if (null !== $bookingDto->booking && $bookingDto->booking->hasServiceForParticipant($index, $serviceId)) {
|
||||
--$count;
|
||||
}
|
||||
|
||||
$serviceUsage[$serviceId] = ($serviceUsage[$serviceId] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,18 @@ class TravelDataProvider
|
||||
return $this->travelLookupService->mapDateCodeToLabel($dateCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map date code to the travel's start date.
|
||||
*
|
||||
* @param string $dateCode The date code to map
|
||||
*
|
||||
* @return \DateTimeImmutable|null The start date or null if not found
|
||||
*/
|
||||
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->travelLookupService->mapDateCodeToStartDate($dateCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map hotel code to hotel ID.
|
||||
*
|
||||
|
||||
@@ -127,6 +127,26 @@ class TravelIndex
|
||||
return $this->generateFilesMap()[$dateId]['label'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a date code to the travel's start date.
|
||||
*
|
||||
* @param string $dateCode The date code to resolve
|
||||
*
|
||||
* @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none
|
||||
*/
|
||||
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
|
||||
{
|
||||
$dateId = $this->mapDateCodeToId($dateCode);
|
||||
|
||||
if (null === $dateId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null;
|
||||
|
||||
return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotel code string to its corresponding hotel ID.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Enum\BusLeg;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Mailjet\ApiClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Creates Mailjet contact lists for a travel (all participants, bus passengers only) and deletes
|
||||
* them again 30 days after the travel started, together with every contact that is on no other
|
||||
* Mailjet list.
|
||||
*/
|
||||
class TravelMailingListManager
|
||||
{
|
||||
private const string DELETE_AFTER = '+30 days';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $mailjet,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $recipients
|
||||
* @param ?BusLeg $busLeg the bus leg the recipients were filtered by, if any
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function create(string $travelCode, array $recipients, ?BusLeg $busLeg, \DateTimeImmutable $travelStartsAt): TravelMailingList
|
||||
{
|
||||
$listId = $this->mailjet->createContactsList(TravelMailingList::nameFor($travelCode, $busLeg));
|
||||
|
||||
// Tracked before the contacts go in, so a list Mailjet already holds is never left without a row
|
||||
// that the cleanup would find.
|
||||
$mailingList = new TravelMailingList($travelCode, $listId, $busLeg, $travelStartsAt->modify(self::DELETE_AFTER));
|
||||
$this->entityManager->persist($mailingList);
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients));
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->discardEmptyList($mailingList);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logger->info('Created Mailjet travel mailing list', [
|
||||
'travel_code' => $travelCode,
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $listId,
|
||||
'mailjet_job_id' => $jobId,
|
||||
'bus_leg' => $busLeg?->value,
|
||||
'recipients' => count($recipients),
|
||||
]);
|
||||
|
||||
return $mailingList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all lists of one travel together. Taken one at a time, a contact on both would
|
||||
* never count as orphaned: Mailjet still reports a list for 60 days after its deletion.
|
||||
*
|
||||
* The orphaned contacts go first and the lists last, so a run that fails halfway can be
|
||||
* repeated: the lists still name whoever is left.
|
||||
*
|
||||
* @param TravelMailingList[] $mailingLists
|
||||
*
|
||||
* @return int the number of contacts deleted
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function delete(array $mailingLists): int
|
||||
{
|
||||
$listIds = array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $mailingLists);
|
||||
|
||||
$contactIds = [];
|
||||
foreach ($listIds as $listId) {
|
||||
array_push($contactIds, ...$this->mailjet->getListContactIds($listId));
|
||||
}
|
||||
|
||||
$deletedContacts = 0;
|
||||
foreach (array_unique($contactIds) as $contactId) {
|
||||
// Anyone who is, or recently was, on another list is kept: their newsletter consent or
|
||||
// another travel's mailing still needs the contact.
|
||||
if ([] !== array_diff($this->mailjet->getContactListIds($contactId), $listIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailjet->deleteContact($contactId);
|
||||
++$deletedContacts;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted orphaned travel mailing contacts', [
|
||||
'mailjet_list_ids' => $listIds,
|
||||
'deleted_contacts' => $deletedContacts,
|
||||
]);
|
||||
|
||||
foreach ($mailingLists as $mailingList) {
|
||||
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
|
||||
|
||||
$this->entityManager->remove($mailingList);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Deleted Mailjet travel mailing list', [
|
||||
'travel_code' => $mailingList->getTravelCode(),
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $deletedContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $recipients
|
||||
*
|
||||
* @return list<array{email: string, firstName: ?string}>
|
||||
*/
|
||||
private function buildContacts(array $recipients): array
|
||||
{
|
||||
// Several participants can share one address; the first name that comes with it wins.
|
||||
$contacts = [];
|
||||
foreach ($recipients as $recipient) {
|
||||
$email = mb_strtolower(trim($recipient->email));
|
||||
if ('' === $email || true === isset($contacts[$email])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contacts[$email] = [
|
||||
'email' => $email,
|
||||
'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($contacts);
|
||||
}
|
||||
|
||||
private function discardEmptyList(TravelMailingList $mailingList): void
|
||||
{
|
||||
try {
|
||||
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
// The row stays, so the scheduled cleanup removes the list later.
|
||||
$this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [
|
||||
'travel_code' => $mailingList->getTravelCode(),
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entityManager->remove($mailingList);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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('�', 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' => ['�'];
|
||||
yield 'hex NUL, padded and upper case' => ['�'];
|
||||
yield 'decimal NUL' => ['�'];
|
||||
yield 'vertical tab' => [''];
|
||||
yield 'decimal 31' => [''];
|
||||
yield 'surrogate' => ['�'];
|
||||
yield 'noncharacter FFFE' => [''];
|
||||
yield 'beyond Unicode' => ['�'];
|
||||
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' => ['	'];
|
||||
yield 'line feed' => [' '];
|
||||
yield 'carriage return' => ['
'];
|
||||
yield 'space' => [' '];
|
||||
yield 'umlaut' => ['ü'];
|
||||
yield 'emoji' => ['😀'];
|
||||
yield 'named entity' => ['&'];
|
||||
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"?>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'))]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user