Files
myep/src/Form/Model/BookingDto.php
T

400 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Unified DTO for both booking creation and editing workflows.
*
* This DTO consolidates the previously separate BookingCreateDto and BookingEditDto
* into a single class that handles both modes. All service selections are stored
* in participant DTOs regardless of mode, ensuring consistent data structure and
* simplifying pricing calculations, field handlers, and template rendering.
*/
#[AppAssert\RoomSelection(groups: ['booking_create_step_1'])]
class BookingDto
{
public const MODE_CREATE = 'create';
public const MODE_EDIT = 'edit';
public int $currentStep = 1;
/**
* @var array<int, RoomSelectionDto>
*/
#[Assert\Valid]
public array $roomSelections = [];
/**
* @var array<int, ParticipantDto>
*/
#[Assert\Valid]
public array $participants = [];
#[Assert\Choice(
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
message: 'Bitte wähle eine gültige Zahlungsart.'
)]
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
public ?BankAccountDto $bankAccount = null;
public ?int $agencyId = null;
public ?string $agencyCode = null;
/**
* Booking status code for API submission.
* Values: 'F' (Fest), 'O' (Option), 'A' (Anfrage/Inquiry).
*/
public string $bookingStatus = 'F';
/**
* Reference to booking entity (only populated in edit mode).
*/
public ?Booking $booking = null;
/**
* Fingerprint of the booking state when loaded from API (edit mode only).
* This property stores the original state and is never updated after initial load.
* Used to detect unsaved changes in edit mode by comparing with current state.
*/
public ?string $originalFingerprint = null;
public function __construct(public Travel $travel, public int $hotelId)
{
}
/**
* Builds AcceptedVouchers from validated participant voucher data.
*
* Computes voucher discounts from validated vouchers stored on participants.
* This allows displaying voucher savings as soon as vouchers are validated
* in Step 2, rather than waiting for Step 3 API confirmation.
*
* For promo vouchers with percentage discounts, we need the participant price
* to calculate the actual discount amount.
*
* @param array<int, float>|null $participantPrices Prices per participant index for percentage calculation
*/
public function getAcceptedVouchers(?array $participantPrices = null): ?AcceptedVouchersDto
{
$acceptedVouchers = new AcceptedVouchersDto();
$processedPromoCodes = [];
foreach ($this->participants as $index => $participant) {
// Process purchase voucher
if (null !== $participant->validatedPurchaseVoucher) {
$voucher = $participant->validatedPurchaseVoucher;
$acceptedVouchers->addVoucher(new AcceptedVoucherDto(
type: $voucher->isGoodwill() ? AcceptedVoucherDto::TYPE_GOODWILL : AcceptedVoucherDto::TYPE_PURCHASE,
code: $voucher->voucherNumber,
amount: $voucher->remainingBalance,
description: $voucher->isGoodwill() ? 'Kulanzgutschein' : 'Kaufgutschein',
));
}
// Process promo voucher
if (null !== $participant->validatedPromoVoucher) {
$voucher = $participant->validatedPromoVoucher;
// For per-booking vouchers, only count once
if ($voucher->isPerBooking()) {
if (isset($processedPromoCodes[$voucher->code])) {
continue;
}
$processedPromoCodes[$voucher->code] = true;
}
// Calculate discount amount
$discountAmount = $voucher->discountAmount;
if ($voucher->discountPercentage > 0 && null !== $participantPrices && isset($participantPrices[$index])) {
$discountAmount = round($participantPrices[$index] * ($voucher->discountPercentage / 100), 2);
}
$acceptedVouchers->addVoucher(new AcceptedVoucherDto(
type: AcceptedVoucherDto::TYPE_PROMOTIONAL,
code: $voucher->code,
amount: $discountAmount,
description: $voucher->description,
));
}
}
return $acceptedVouchers->hasVouchers() ? $acceptedVouchers : null;
}
public function getMode(): string
{
return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE;
}
/**
* @return array<int, RoomSelectionDto>
*/
public function getSelectedRooms(): array
{
// In edit mode, rooms are fixed - return empty array
if (self::MODE_EDIT === $this->getMode()) {
return [];
}
return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) {
return 0 < $roomSelection->quantity;
});
}
/** @return array<int, ParticipantDto> */
public function getParticipants(): array
{
return $this->participants;
}
public function getParticipant(int $index): ?ParticipantDto
{
return $this->participants[$index] ?? null;
}
/**
* Determines if this is a family booking based on participant age distribution.
*
* A family booking is defined as:
* - 1 or 2 participants aged 18 or older (adults)
* - At least 1 participant younger than 18 (children)
*/
public function isFamilyBooking(): bool
{
$adults = 0;
$children = 0;
$travelStartDate = $this->travel->dateFrom;
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if (null === $age) {
continue;
}
if ($age >= 18) {
++$adults;
} else {
++$children;
}
}
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
}
#[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void
{
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
return;
}
if (null === $this->bankAccount) {
$context->buildViolation('Bitte gib deine Bankverbindung an.')
->atPath('bankAccount')
->addViolation();
return;
}
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
$context->buildViolation('Bitte gib deine IBAN ein.')
->atPath('bankAccount.iban')
->addViolation();
}
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
$context->buildViolation('Bitte gib den Kontoinhaber ein.')
->atPath('bankAccount.accountHolder')
->addViolation();
}
if (false === $this->bankAccount->sepaMandateAccepted) {
$context->buildViolation('Bitte akzeptiere das SEPA-Mandat.')
->atPath('bankAccount.sepaMandateAccepted')
->addViolation();
}
}
/**
* Checks if this is an inquiry booking (status='A').
*
* Inquiry bookings occur when a travel is fully booked but still accepts inquiries.
*/
public function isInquiryBooking(): bool
{
return 'A' === $this->bookingStatus;
}
/**
* Checks if any participants have entered goodwill vouchers.
*
* Goodwill vouchers (Kulanz) are a special type of purchase voucher that cannot be
* redeemed in inquiry bookings. This method is used to display appropriate warnings.
*
* @return bool True if any goodwill vouchers are present
*/
public function hasGoodwillVouchers(): bool
{
foreach ($this->participants as $participant) {
if ($participant->hasGoodwillVoucher()) {
return true;
}
}
return false;
}
/**
* Calculates the number of participants assigned to each room ID.
*
* @return array<int, int> Array where key is room ID and value is count of assigned participants
*/
public function getRoomAssignmentCounts(): array
{
$counts = [];
foreach ($this->participants as $participant) {
if (null !== $participant->assignedRoomId) {
$counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1;
}
}
return $counts;
}
/**
* Resets all participant room assignments.
*
* Clears room assignments when room selections change to prevent
* invalid assignments.
*/
public function resetParticipantAssignments(): void
{
foreach ($this->participants as $participant) {
$participant->assignedRoomId = null;
}
}
/**
* Creates a snapshot of the current room selection state.
*
* @return array<int, array{int, int}> Array of [roomId, quantity] pairs
*/
public function createRoomSelectionSnapshot(): array
{
return array_map(
fn ($roomSelection) => [(int) $roomSelection->id, (int) $roomSelection->quantity],
$this->roomSelections
);
}
/**
* Checks if room selection has changed compared to a previous snapshot.
*
* @param array<int, array{int, int}> $oldSnapshot The baseline room selection snapshot
*
* @return bool True if room selections have changed, false otherwise
*/
public function hasRoomSelectionChanged(array $oldSnapshot): bool
{
$newSnapshot = $this->createRoomSelectionSnapshot();
return $oldSnapshot !== $newSnapshot;
}
/**
* Determines if this booking is initiated by the internal agency.
*
* Internal agency bookings have a different relationship between applicant
* and first participant: the applicant is a staff member, not the first
* participant. This affects personal data editability rules.
*/
public function isInternalAgencyBooking(): bool
{
return AgencyLoader::INTERNAL_AGENCY_CODE === $this->agencyCode;
}
/**
* Controls session serialization to exclude the heavy Travel object graph.
*
* Replaces the full Travel object with just its integer ID. The Booking's
* travelData reference is also removed since it points to the same object.
* BookingConfigurator::hydrate() restores the Travel from cache after session read.
*
* @return array<string, mixed>
*/
public function __serialize(): array
{
$data = get_object_vars($this);
// Replace the full Travel object graph with just its ID
$data['travel'] = $this->travel->id;
// Clone Booking to avoid mutating the live object, then strip its
// travelData reference which points to the same heavy Travel graph
if (null !== $this->booking) {
$booking = clone $this->booking;
$booking->travelData = null;
$data['booking'] = $booking;
}
return $data;
}
/**
* Restores the DTO from session data with a minimal Travel placeholder.
*
* Creates a Travel object containing only the ID. BookingConfigurator::hydrate()
* replaces this with the full Travel from cache on every session read.
*
* @param array<string, mixed> $data
*/
public function __unserialize(array $data): void
{
// Extract the travel ID before the property loop — 'travel' in the
// serialized data is an int, not a Travel object
$travelId = $data['travel'];
unset($data['travel']);
// Skip keys that no longer exist as declared properties to avoid
// dynamic property creation (deprecated since PHP 8.2)
foreach ($data as $key => $value) {
if (false === property_exists($this, $key)) {
continue;
}
$this->$key = $value;
}
foreach ($this->participants as $participant) {
if ($participant instanceof ParticipantDto) {
// Temporary cleanup for older serialized participants restored from session.
$participant->normalizeBodyDimensions();
}
}
// Old sessions (before c373a989) still carry a full Travel object;
// new sessions carry only the int ID. Handle both formats.
// TODO: Remove Travel instance branch once all pre-deploy sessions have expired
if ($travelId instanceof Travel) {
$this->travel = $travelId;
} else {
$travel = new Travel();
$travel->id = $travelId;
$this->travel = $travel;
}
}
}