400 lines
13 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|