Compare commits
5
Commits
f5894a6bef
...
77960fe81f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77960fe81f | ||
|
|
9ce42de4c1 | ||
|
|
d50995a21c | ||
|
|
897a1ac7f9 | ||
|
|
2705401393 |
No files matched your search
Generated
+510
-246
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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.
|
||||
*
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user