Compare commits

...
5 Commits
26 changed files with 1062 additions and 409 deletions

No files matched your search

Generated
+510 -246
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261109074654 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
// Every existing list holds all participants and is named by its travel code alone.
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
}
public function down(Schema $schema): void
{
// Fails while a travel has two lists; delete the bus-only one first.
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\BpnConnect\Model;
use App\Enum\BusLeg;
readonly class TravelMailingResponse
{
/**
@@ -12,6 +14,7 @@ readonly class TravelMailingResponse
public function __construct(
public string $travelCode,
public array $data,
public ?BusLeg $busLeg = null,
) {
}
}
+25 -5
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\Model\TravelMailingResponse;
use App\Enum\BusLeg;
class TravelsClient extends AbstractApiClient
{
@@ -14,12 +16,30 @@ class TravelsClient extends AbstractApiClient
/**
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
* apart from a travel without confirmed participants.
*
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
*/
public function getMailingRecipients(string $travelCode): TravelMailingResponse
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
{
$payload = $this->request(self::MAILING_PATH, [
'travelCode' => $travelCode,
]);
$query = ['travelCode' => $travelCode];
if (null !== $busLeg) {
$query['busLeg'] = $busLeg->value;
}
$payload = $this->request(self::MAILING_PATH, $query);
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
// must not end up on a list meant for bus passengers only.
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
if ($appliedBusLeg !== $busLeg?->value) {
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
'travel_code' => $travelCode,
'requested_bus_leg' => $busLeg?->value,
'applied_bus_leg' => $appliedBusLeg,
]);
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
}
$recipients = array_map(
static fn (array $item) => new TravelMailingRecipient(
@@ -30,6 +50,6 @@ class TravelsClient extends AbstractApiClient
$payload['data'] ?? [],
);
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients);
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
}
}
@@ -153,7 +153,6 @@ class BookingDataProcessor
$roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection;
}
}
@@ -341,6 +340,7 @@ class BookingDataProcessor
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
}
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
$this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants,
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
* Processes participant service selections for booking updates.
*
* Handles the assignment and mapping of services (additional, transportation,
* pickup, room, insurance) from participant DTOs to the booking model during
* the update request workflow.
* pickup, insurance) from participant DTOs to the booking model during the
* update request workflow. Rooms are not taken from the DTOs: they cannot be
* changed in edit mode, so the booking's own room assignments are sent back.
*/
class ParticipantServiceProcessor
{
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
* Resets all existing participant-to-service mappings to start with a clean slate.
*
* This ensures that service assignments are rebuilt from scratch based on current form selections.
* Includes resetting room mappings to allow room reassignments during edit.
* Room mappings are left alone, see removeCanceledParticipantsFromRooms().
*
* @param Booking $bookingData The booking data object containing services to reset
*/
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
...$bookingData->transportationServices,
...$bookingData->pickups,
...$bookingData->dropOffs,
...$bookingData->rooms,
...$bookingData->insurances,
];
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
* Processes all services for a single participant.
*
* This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, pickup locations, and room assignments.
* including additional services, transportation services, pickup locations, and insurance.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
$this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData);
}
@@ -271,27 +270,23 @@ class ParticipantServiceProcessor
}
/**
* Processes room assignment for a participant.
* Removes canceled participants from the booking's room assignments.
*
* Maps the participant to their assigned room. Allows room reassignment during
* booking edits while maintaining the constraint that participants can only be
* assigned to room types that have already been booked.
* Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
* them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
* session would undo any reassignment made in BusPro while the edit was open, and send a
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
* other service.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Booking $bookingData The booking data object to update
*/
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void
public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
{
if (null === $participant->assignedRoomId) {
return;
}
// Find the room in the existing booking by ID
foreach ($bookingData->rooms as $room) {
if ($room->id === $participant->assignedRoomId) {
$room->mapping[] = $participant->index;
break;
}
$room->mapping = array_values(array_filter(
$room->mapping,
fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
));
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\TravelMailingList;
use App\Exception\NewsletterProviderException;
use App\Repository\TravelMailingListRepository;
use App\Service\TravelMailingListManager;
@@ -39,20 +40,29 @@ class CleanupTravelMailingListsCommand extends Command
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;
foreach ($mailingLists as $mailingList) {
$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($mailingList);
$io->writeln(sprintf('Deleted %s (list %d) and %d orphaned contact(s).', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $deletedContacts));
$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 row stays, so the next run picks the list up again.
// The rows of lists not yet deleted stay, so the next run picks them up again.
++$failed;
$this->logger->error('Delete travel mailing list failed', [
'travel_code' => $mailingList->getTravelCode(),
'mailjet_list_id' => $mailingList->getMailjetListId(),
$this->logger->error('Delete travel mailing lists failed', [
'travel_code' => $travelCode,
'mailjet_list_ids' => $listIds,
'error' => $exception->getMessage(),
]);
$io->error(sprintf('%s (list %d): %s', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $exception->getMessage()));
$io->error(sprintf('%s (lists %s): %s', $travelCode, $listIds, $exception->getMessage()));
}
}
@@ -60,7 +70,7 @@ class CleanupTravelMailingListsCommand extends Command
return Command::FAILURE;
}
$io->success(sprintf('Deleted %d travel mailing list(s).', count($mailingLists)));
$io->success(sprintf('Deleted %d travel mailing list(s).', $deletedLists));
return Command::SUCCESS;
}
@@ -7,7 +7,9 @@ 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;
@@ -39,11 +41,19 @@ class CreateListController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
$returnUrl = $this->generateUrl('app_admin_travelmailing', ['travelCode' => $travelCode]);
$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,
]));
if (null !== $this->travelMailingListRepository->findOneByTravelCode($travelCode)) {
$this->addFlash('error', sprintf('Für %s gibt es bereits eine Mailjet-Liste.', $travelCode));
$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);
}
@@ -57,7 +67,7 @@ class CreateListController extends AbstractController
// Fetched again rather than posted from the page, so the list holds what BusPro reports now.
try {
$recipients = $this->travelsClient->getMailingRecipients($travelCode)->data;
$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.');
@@ -71,7 +81,7 @@ class CreateListController extends AbstractController
}
try {
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $travelStartsAt);
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $busLeg, $travelStartsAt);
} catch (NewsletterProviderException $exception) {
$this->adminLogger->error('Create travel mailing list failed', [
'travel_code' => $travelCode,
@@ -85,9 +95,10 @@ class CreateListController extends AbstractController
$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.', $travelCode, 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,6 +7,7 @@ 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;
@@ -39,7 +40,9 @@ 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"
@@ -47,10 +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);
$mailingList = $this->travelMailingListRepository->findOneByTravelCode($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;
@@ -62,7 +72,9 @@ class IndexController extends AbstractController
'result' => $result,
'travel_label' => $travelLabel,
'travel_starts_at' => $travelStartsAt,
'mailing_lists' => $mailingLists,
'mailing_list' => $mailingList,
'list_name' => $listName,
'failed' => $failed,
]);
}
+34 -2
View File
@@ -4,41 +4,63 @@ 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, unique: true)]
#[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, \DateTimeImmutable $deleteAfter)
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;
@@ -49,11 +71,21 @@ class TravelMailingList
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;
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Which bus leg a travel participant must have booked to be selected, as bpn-connect's travel
* mailing endpoint takes it (`busLeg`).
*/
enum BusLeg: string
{
case Outbound = 'outbound';
case Inbound = 'inbound';
case Any = 'any';
case Both = 'both';
public function label(): string
{
return match ($this) {
self::Outbound => 'Busreisende (Hinfahrt)',
self::Inbound => 'Busreisende (Rückfahrt)',
self::Any => 'Busreisende (Hin- oder Rückfahrt)',
self::Both => 'Busreisende (Hin- und Rückfahrt)',
};
}
}
+6 -1
View File
@@ -6,13 +6,14 @@ namespace App\Form\Admin;
use App\Form\Model\TravelMailingLookupDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
* and can be bookmarked or shared.
* (plus `&busOnly=1`) and can be bookmarked or shared.
*
* @extends AbstractType<TravelMailingLookupDto>
*/
@@ -26,6 +27,10 @@ class TravelMailingLookupType extends AbstractType
'placeholder' => 'z. B. LPJMP281126',
],
]);
$builder->add('busOnly', CheckboxType::class, [
'label' => 'nur '.TravelMailingLookupDto::BUS_LEG->label(),
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
+13
View File
@@ -4,10 +4,23 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\Enum\BusLeg;
use Symfony\Component\Validator\Constraints as Assert;
class TravelMailingLookupDto
{
/**
* The leg "bus passengers only" stands for: whoever takes the bus out, whatever their return.
*/
public const BusLeg BUS_LEG = BusLeg::Outbound;
#[Assert\NotBlank(message: 'Bitte einen Reisecode angeben.')]
public ?string $travelCode = null;
public bool $busOnly = false;
public function getBusLeg(): ?BusLeg
{
return $this->busOnly ? self::BUS_LEG : null;
}
}
@@ -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.
*
+10 -2
View File
@@ -18,9 +18,17 @@ class TravelMailingListRepository extends ServiceEntityRepository
parent::__construct($registry, TravelMailingList::class);
}
public function findOneByTravelCode(string $travelCode): ?TravelMailingList
public function findOneByName(string $name): ?TravelMailingList
{
return $this->findOneBy(['travelCode' => $travelCode]);
return $this->findOneBy(['name' => $name]);
}
/**
* @return list<TravelMailingList>
*/
public function findByTravelCode(string $travelCode): array
{
return $this->findBy(['travelCode' => $travelCode], ['id' => 'ASC']);
}
/**
+7 -14
View File
@@ -63,7 +63,7 @@ class BookingEditDraftMerger
// Room assignment
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
$this->applyRoomAssignment($participant, $data['roomAssignment']);
}
// License plate
@@ -177,26 +177,19 @@ class BookingEditDraftMerger
}
/**
* Applies the drafted room assignment to the participant.
* Applies the drafted room remarks to the participant.
*
* The room id uses the same merge strategy as single-select services: it is only
* applied when it resolves to a room of the current travel. A draft may therefore move
* a participant to another room, but never unassign one. Edit mode renders the room as
* static text and never submits assignedRoomId, so a null in the draft is only ever an
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
* would drop the participant's room, both in the UI and in the outbound zuordnung.
* The drafted assignedRoomId is ignored. Edit mode renders the room as static text and
* never submits it, so the draft only holds what BusPro reported when it was saved, and
* replaying it would undo a reassignment the office made since. The room always comes
* from the booking as loaded.
*
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
*
* @param array<string, mixed> $data
*/
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
{
$assignedRoomId = $data['assignedRoomId'] ?? null;
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
$participant->assignedRoomId = (int) $assignedRoomId;
}
if (true === array_key_exists('remarksRoom', $data)) {
$participant->remarksRoom = $data['remarksRoom'];
}
+3 -10
View File
@@ -147,12 +147,8 @@ class RoomPricingCalculator
}
}
// Count current participant assignments from the DTO (reflects live state
// including new assignments not yet in the booking entity)
$assignmentCounts = $bookingDto->getRoomAssignmentCounts();
// Build pricing array
foreach ($roomGroups as $roomId => $data) {
foreach ($roomGroups as $data) {
$room = $data['room'];
$participantCount = $data['participantCount'];
$totalPrice = $data['totalPrice'];
@@ -160,14 +156,11 @@ class RoomPricingCalculator
// Calculate average unit price (price per person)
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
// Use live assignment count for quantity display so the sidebar reflects
// new room assignments that haven't been persisted to the booking entity yet
$quantity = $assignmentCounts[$roomId] ?? $room->totalCount;
$roomPricing[] = [
'roomId' => $room->id,
'label' => $room->label,
'quantity' => $quantity,
// Rooms, not occupants: room assignments cannot change in edit mode
'quantity' => $room->totalCount,
'participantCount' => $participantCount,
'unitPrice' => $unitPrice,
'totalPrice' => $totalPrice,
+42 -19
View File
@@ -6,14 +6,16 @@ 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 one Mailjet contact list per travel and deletes it again 30 days after the travel
* started, together with every contact that is on no other Mailjet list.
* 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
{
@@ -28,16 +30,17 @@ class TravelMailingListManager
/**
* @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, \DateTimeImmutable $travelStartsAt): TravelMailingList
public function create(string $travelCode, array $recipients, ?BusLeg $busLeg, \DateTimeImmutable $travelStartsAt): TravelMailingList
{
$listId = $this->mailjet->createContactsList($travelCode);
$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, $travelStartsAt->modify(self::DELETE_AFTER));
$mailingList = new TravelMailingList($travelCode, $listId, $busLeg, $travelStartsAt->modify(self::DELETE_AFTER));
$this->entityManager->persist($mailingList);
$this->entityManager->flush();
@@ -51,8 +54,10 @@ class TravelMailingListManager
$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),
]);
@@ -60,22 +65,32 @@ class TravelMailingListManager
}
/**
* Deletes the orphaned contacts first and the list last, so a run that fails halfway can be
* repeated: the list still names whoever is left.
* 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(TravelMailingList $mailingList): int
public function delete(array $mailingLists): int
{
$listId = $mailingList->getMailjetListId();
$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 ($this->mailjet->getListContactIds($listId) as $contactId) {
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 ([$listId] !== array_values(array_unique($this->mailjet->getContactListIds($contactId)))) {
if ([] !== array_diff($this->mailjet->getContactListIds($contactId), $listIds)) {
continue;
}
@@ -83,17 +98,24 @@ class TravelMailingListManager
++$deletedContacts;
}
$this->mailjet->deleteContactsList($listId);
$this->entityManager->remove($mailingList);
$this->entityManager->flush();
$this->logger->info('Deleted Mailjet travel mailing list', [
'travel_code' => $mailingList->getTravelCode(),
'mailjet_list_id' => $listId,
$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;
}
@@ -129,6 +151,7 @@ class TravelMailingListManager
// 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(),
]);
+18 -8
View File
@@ -9,6 +9,9 @@
<div class="w-64">
{{ form_row(form.travelCode) }}
</div>
<div>
{{ form_row(form.busOnly) }}
</div>
<button type="submit"
class="button button--primary button--small">
abrufen
@@ -26,7 +29,7 @@
{% if result.data is empty %}
{% include '_partials/_alert.html.twig' with {
level: 'warning',
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)],
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer%s.'|format(result.travelCode|e, result.busLeg ? ' unter den ' ~ result.busLeg.label : '')],
} %}
{% else %}
<div class="pb-4">
@@ -34,26 +37,33 @@
{{ 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">
{% if mailing_list is not null %}
{% for list in mailing_lists %}
<p>
Mailjet-Liste <code>{{ mailing_list.travelCode }}</code> (ID {{ mailing_list.mailjetListId }})
am {{ mailing_list.createdAt|date('d.m.Y, H:i') }} angelegt,
wird ab {{ mailing_list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
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') }}">
<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 anlegen
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
+38
View File
@@ -7,6 +7,7 @@ namespace App\Tests\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\TravelsClient;
use App\Enum\BusLeg;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
@@ -35,12 +36,49 @@ class TravelsClientTest extends TestCase
self::assertSame('https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126', $requests[0][1]);
self::assertContains('X-API-KEY: secret', $requests[0][2]['headers']);
self::assertSame('LPJMP281126', $response->travelCode);
self::assertNull($response->busLeg);
self::assertEquals([
new TravelMailingRecipient('[email protected]', 'Erika', 'Muster'),
new TravelMailingRecipient('[email protected]', 'Max', 'Muster'),
], $response->data);
}
public function testGetMailingRecipientsPassesTheBusLegAndAcceptsItsEcho(): void
{
$requests = [];
$http = new MockHttpClient(function (string $method, string $url) use (&$requests): MockResponse {
$requests[] = $url;
return $this->jsonResponse([
'meta' => ['travel_code' => 'LPJMP281126', 'bus_leg' => 'outbound', 'row_count' => 1, 'execution_time_ms' => 12],
'data' => [
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
],
]);
});
$response = $this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
self::assertSame(['https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126&busLeg=outbound'], $requests);
self::assertSame(BusLeg::Outbound, $response->busLeg);
self::assertCount(1, $response->data);
}
public function testABusLegTheServerDidNotApplyIsRejected(): void
{
// What a bpn-connect without the filter answers: everyone, and no bus_leg in meta.
$http = new MockHttpClient($this->jsonResponse([
'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 1, 'execution_time_ms' => 12],
'data' => [
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
],
]));
$this->expectException(BpnConnectException::class);
$this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
}
public function testAnUnknownTravelCodeYieldsAnEmptyList(): void
{
$http = new MockHttpClient($this->jsonResponse([
@@ -342,6 +342,66 @@ class BookingDataProcessorTest extends TestCase
$this->assertEquals('07.01.2024', $room['@abreise']);
}
public function testUpdatePayloadSendsBookedRoomsUnchanged(): void
{
$formData = $this->createFormDataWithBookedRooms();
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3,4'], 3 => ['3', '5,6,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadDropsCanceledParticipantsFromRooms(): void
{
$formData = $this->createFormDataWithBookedRooms();
$formData->participants[5]->status = 'S';
$formData->booking->participantsStatus[5] = 'S';
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3,4'], 3 => ['3', '5,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadKeepsRoomChangesMadeInBusProDuringTheEdit(): void
{
$formData = $this->createFormDataWithBookedRooms();
// The office moved participant 4 into the doubles after the edit session was loaded
$formData->booking->rooms[95]->mapping = [0, 1, 2];
$formData->booking->rooms[3]->totalCount = 4;
$formData->booking->rooms[3]->mapping = [3, 4, 5, 6, 7, 8, 9];
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '1,2,3'], 3 => ['4', '4,5,6,7,8,9,10']],
$this->roomsInPayload($result)
);
}
public function testUpdatePayloadKeepsARoomTypeAddedInBusProDuringTheEdit(): void
{
$formData = $this->createFormDataWithBookedRooms();
// The office moved participants 1 and 2 out of the 4-bed room into a new double
$formData->booking->rooms[95]->mapping = [2, 3];
$formData->booking->rooms[95]->totalCount = 1;
$formData->booking->rooms[7] = $this->createBookedRoom(7, 'DZ', 1, [0, 1]);
$result = $this->processor->createUpdateRequestPayload($formData);
$this->assertSame(
[95 => ['1', '3,4'], 3 => ['3', '5,6,7,8,9,10'], 7 => ['1', '1,2']],
$this->roomsInPayload($result)
);
}
public function testCommunicationObjectCreation(): void
{
$formData = $this->createFormDataWithoutExistingCommunication();
@@ -526,6 +586,60 @@ class BookingDataProcessorTest extends TestCase
return $formData;
}
/**
* Ten participants as BusPro reports them: four in one 4-bed room, six in three doubles.
*/
private function createFormDataWithBookedRooms(): BookingDto
{
$bookingDto = new BookingDto($this->createMockTravel(), 1);
$bookingDto->booking = $this->createMockBooking();
$bookingDto->booking->participants = [];
$bookingDto->booking->participantsStatus = [];
$bookingDto->participants = [];
for ($index = 0; $index < 10; ++$index) {
$bookingDto->booking->participants[$index] = $this->createMockPersonalData("Participant{$index}");
$bookingDto->booking->participantsStatus[$index] = 'F';
$bookingDto->participants[$index] = $this->createMockParticipantDto($index, 'F');
$bookingDto->participants[$index]->assignedRoomId = $index < 4 ? 95 : 3;
}
$bookingDto->booking->rooms = [
95 => $this->createBookedRoom(95, '4er', 1, [0, 1, 2, 3]),
3 => $this->createBookedRoom(3, 'DZ', 3, [4, 5, 6, 7, 8, 9]),
];
return $bookingDto;
}
/** @param list<int> $mapping */
private function createBookedRoom(int $id, string $category, int $totalCount, array $mapping): Room
{
$room = new Room();
$room->id = $id;
$room->category = $category;
$room->boardId = 2;
$room->totalCount = $totalCount;
$room->mapping = $mapping;
return $room;
}
/**
* @param array<string, mixed> $payload
*
* @return array<int, array{string, string}> Room id => [anzahl, zuordnung]
*/
private function roomsInPayload(array $payload): array
{
$rooms = [];
foreach ($payload['ferienzielunterbringungen']['ferienzielunterbringung'] as $room) {
$rooms[$room['@idzimmer']] = [(string) $room['@anzahl'], $room['@zuordnung']];
}
return $rooms;
}
private function createFormDataWithoutExistingCommunication(): BookingDto
{
$formData = $this->createCompleteFormData();
@@ -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
@@ -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
@@ -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
+34 -5
View File
@@ -6,6 +6,7 @@ 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;
@@ -18,7 +19,7 @@ class TravelMailingListManagerTest extends TestCase
public function testCreateTracksTheListAndAddsDeduplicatedContacts(): void
{
$mailjet = $this->createMock(ApiClient::class);
$mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126')->willReturn(42);
$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],
@@ -32,9 +33,11 @@ class TravelMailingListManagerTest extends TestCase
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
new TravelMailingRecipient('[email protected]', 'Lea', 'Muster'),
new TravelMailingRecipient('[email protected]', '', 'Test'),
], new \DateTimeImmutable('2026-11-28'));
], 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());
}
@@ -52,7 +55,7 @@ class TravelMailingListManagerTest extends TestCase
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
], new \DateTimeImmutable('2026-11-28'));
], null, new \DateTimeImmutable('2026-11-28'));
}
public function testDeleteRemovesOnlyContactsOnNoOtherList(): void
@@ -70,7 +73,33 @@ class TravelMailingListManagerTest extends TestCase
$entityManager->expects(self::once())->method('remove');
$deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
->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);
}
@@ -87,6 +116,6 @@ class TravelMailingListManagerTest extends TestCase
$this->expectException(NewsletterProviderException::class);
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
->delete([new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28'))]);
}
}