wip: booking process
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(php -l:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,13 @@ GET {{base_url}}/api/travels/DWWWE211125
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API travel remote
|
||||
GET {{base_url}}/api/travels/1277/remote
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API hotel availability remote
|
||||
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller} from '@hotwired/stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['field']
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'fields', 'field', 'addButton', 'index' ]
|
||||
static values = {
|
||||
prototype: String,
|
||||
maxItems: Number,
|
||||
itemsCount: Number,
|
||||
}
|
||||
|
||||
connect () {
|
||||
this.index = this.itemsCountValue = this.fieldTargets.length
|
||||
}
|
||||
|
||||
addItem() {
|
||||
let prototype = JSON.parse(this.prototypeValue)
|
||||
const newField = prototype.replace(/__name__/g, this.index)
|
||||
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
|
||||
this.index++
|
||||
this.itemsCountValue++
|
||||
}
|
||||
|
||||
removeItem(event) {
|
||||
this.fieldTargets.forEach(element => {
|
||||
if (element.contains(event.target)) {
|
||||
element.remove()
|
||||
this.itemsCountValue--
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
itemsCountValueChanged(itemsCount) {
|
||||
if (this.hasIndexTarget) {
|
||||
this.indexTargets.forEach((target, index) => {
|
||||
target.innerText = index + 1
|
||||
})
|
||||
}
|
||||
if (false === this.hasAddButtonTarget || 0 === this.maxItemsValue) {
|
||||
return
|
||||
}
|
||||
const maxItemsReached = itemsCount >= this.maxItemsValue
|
||||
this.addButtonTarget.classList.toggle('hidden', maxItemsReached)
|
||||
}
|
||||
|
||||
}
|
||||
+45
-11
@@ -11,6 +11,7 @@ use App\BusProNet\Model\BookingUpdate;
|
||||
use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Traits\ApiClientTrait;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Form\Model\BookingEditDto;
|
||||
@@ -31,7 +32,9 @@ class ApiClient
|
||||
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
|
||||
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
|
||||
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
|
||||
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
|
||||
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
|
||||
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
|
||||
|
||||
private array $config;
|
||||
|
||||
@@ -121,7 +124,7 @@ class ApiClient
|
||||
'adressdaten' => $personalData->toPayload(),
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, $debug);
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,20 +199,19 @@ class ApiClient
|
||||
...$payload,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, $debug);
|
||||
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getMutableData(int $id): Notification|BaseData
|
||||
public function getMutableData(int $travelId): Notification|BaseData
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_MUTABLE_DATA],
|
||||
'art' => 'Vorgang_Details',
|
||||
'idreise' => $id,
|
||||
'idreise' => $travelId,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data);
|
||||
@@ -218,18 +220,35 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getAvailabilities(int $id): Notification|BaseData
|
||||
public function getAvailabilities(int $travelId): Notification|BaseData
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY),
|
||||
'satz' => ['@typ' => static::TYPE_AVAILABILITY],
|
||||
'idreise' => $id,
|
||||
'idreise' => $travelId,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getHotelAvailability(int $travelId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL),
|
||||
'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL],
|
||||
'idreise' => $travelId,
|
||||
'idpartner' => $hotelId,
|
||||
'terminbis' => $dateTo->format('d.m.Y'),
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
@@ -264,7 +283,7 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getDocuments(string $email, string $password, int $id, string $type): mixed
|
||||
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
@@ -273,7 +292,7 @@ class ApiClient
|
||||
'art' => $type,
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
'idbuchung' => $id,
|
||||
'idbuchung' => $bookingId,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
@@ -282,7 +301,22 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function sendRequest(string $type, array $data, bool $debug = false): mixed
|
||||
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
|
||||
'idprodukt' => $travelId,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
$requestId = date(DATE_ATOM).uniqid();
|
||||
|
||||
@@ -320,7 +354,7 @@ class ApiClient
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $xml);
|
||||
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
||||
} catch (ResponseParserException $e) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
@@ -101,7 +102,7 @@ class Travel
|
||||
$group = (array) $group;
|
||||
|
||||
$services = array_filter($this->additionalServices, function (Service $service) use ($group, $availableOnly) {
|
||||
return in_array($service->subType, $group) && (false === $availableOnly || $service->available > 0);
|
||||
return true === in_array($service->subType, $group) && (false === $availableOnly || 0 < $service->available);
|
||||
});
|
||||
|
||||
usort($services, function (Service $a, Service $b) {
|
||||
@@ -150,7 +151,7 @@ class Travel
|
||||
$services = [];
|
||||
|
||||
foreach (CrmSelectionGroup::$includedServicesMapping as $id => $label) {
|
||||
if (isset($this->selectionGroups[$id])) {
|
||||
if (true === isset($this->selectionGroups[$id])) {
|
||||
$services[] = $this->selectionGroups[$id];
|
||||
}
|
||||
}
|
||||
@@ -171,7 +172,7 @@ class Travel
|
||||
*/
|
||||
public function getRoomsByIds(array $ids): array
|
||||
{
|
||||
if (empty($ids)) {
|
||||
if (true === empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -180,4 +181,19 @@ class Travel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all available rooms for booking.
|
||||
*
|
||||
* Filters the rooms collection to return only rooms that have availability
|
||||
* greater than zero and have an available status. This ensures only
|
||||
* bookable rooms are returned for selection.
|
||||
*
|
||||
* @return array<Room> The filtered array of available rooms
|
||||
*/
|
||||
public function getAvailableRooms(): array
|
||||
{
|
||||
return array_filter($this->rooms, function (Room $room) {
|
||||
return $room->available > 0 && $room->status === Constants::STATUS_AVAILABLE;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,11 @@
|
||||
|
||||
namespace App\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\CrmSelection;
|
||||
use App\BusProNet\Model\CrmSelectionGroup;
|
||||
use App\BusProNet\Model\Guide;
|
||||
use App\BusProNet\Model\MutableData;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DayTimeUtility;
|
||||
use App\BusProNet\Utility\TravelCodeUtility;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
@@ -21,17 +14,41 @@ use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* XML loader for travel data from BusProNet XML exports.
|
||||
*
|
||||
* This loader processes XML files containing travel information including dates,
|
||||
* hotels, services, rooms, and pricing. It provides caching and mapping functionality
|
||||
* to efficiently load and parse travel data from XML exports.
|
||||
*/
|
||||
class TravelLoader extends AbstractLoader
|
||||
{
|
||||
/**
|
||||
* @param HotelLoader $hotelDataLoader Hotel data loader for hotel information
|
||||
* @param TravelParser $travelParser Parser for XML travel data
|
||||
* @param string $travelInfoBaseUrl Base URL for travel information pages
|
||||
* @param CacheInterface $cache Cache interface for performance optimization
|
||||
* @param FilesystemOperator $xmlExport Filesystem operator for XML file access
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly HotelLoader $hotelDataLoader,
|
||||
CacheInterface $cache,
|
||||
private readonly TravelParser $travelParser,
|
||||
private readonly string $travelInfoBaseUrl,
|
||||
CacheInterface $cache,
|
||||
FilesystemOperator $xmlExport,
|
||||
) {
|
||||
parent::__construct($cache, $xmlExport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cached mapping of travel IDs to their file locations and metadata.
|
||||
*
|
||||
* Scans all XML files starting with 'Ziel_' and extracts travel information
|
||||
* including ID, code, label, dates, and associated hotels. The mapping is
|
||||
* cached for 3 hours to improve performance.
|
||||
*
|
||||
* @return array<int, array> Mapping of travel IDs to their metadata and file paths
|
||||
*/
|
||||
public function generateFilesMap(): array
|
||||
{
|
||||
try {
|
||||
@@ -85,6 +102,15 @@ class TravelLoader extends AbstractLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a travel code to its corresponding travel ID.
|
||||
*
|
||||
* Uses the cached files mapping to find the travel ID associated with
|
||||
* the given travel code. Returns null if no matching travel is found.
|
||||
*
|
||||
* @param string $travelCode The travel code to look up
|
||||
* @return int|null The travel ID or null if not found
|
||||
*/
|
||||
public function mapCodeToId(string $travelCode): ?int
|
||||
{
|
||||
$mapping = $this->generateFilesMap();
|
||||
@@ -98,6 +124,17 @@ class TravelLoader extends AbstractLoader
|
||||
return $travelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a travel object by its ID and optional hotel ID.
|
||||
*
|
||||
* Retrieves travel data from XML exports. If filename is provided, loads directly
|
||||
* from that file. Otherwise, uses the cached mapping to find the appropriate file.
|
||||
*
|
||||
* @param int $travelId The travel ID to load
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
* @param string|null $filename Optional filename to load from directly
|
||||
* @return Travel|null The loaded travel object or null if not found
|
||||
*/
|
||||
public function loadById(int $travelId, ?int $hotelId = null, ?string $filename = null): ?Travel
|
||||
{
|
||||
if (null !== $filename) {
|
||||
@@ -115,217 +152,41 @@ class TravelLoader extends AbstractLoader
|
||||
return $this->loadXml($travelId, $hotelId, $filename);
|
||||
}
|
||||
|
||||
private function loadXml(int $id, ?int $hotelId, string $filename): ?Travel
|
||||
/**
|
||||
* Load travel data from a specific XML file.
|
||||
*
|
||||
* Reads and parses XML file content to extract travel information for
|
||||
* the specified travel ID and optional hotel ID.
|
||||
*
|
||||
* @param int $travelId The travel ID to load
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
* @param string $filename The XML filename to load from
|
||||
* @return Travel|null The loaded travel object or null if not found
|
||||
*/
|
||||
private function loadXml(int $travelId, ?int $hotelId, string $filename): ?Travel
|
||||
{
|
||||
$xml = $this->xmlExport->read($filename);
|
||||
$crawler = new Crawler($xml);
|
||||
|
||||
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $id));
|
||||
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $travelId));
|
||||
|
||||
if (0 === $travelNode->count()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->parseXml($travelNode->first(), $hotelId);
|
||||
}
|
||||
|
||||
public function parseXml(Crawler $node, ?int $hotelId): Travel
|
||||
{
|
||||
$hotelNode = $this->getHotelNode($node, $hotelId);
|
||||
|
||||
$dateFrom = $this->stringToDate($node->attr('termin'));
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->id = (int) $node->attr('idbuspro');
|
||||
$travel->hotelId = (int) $hotelNode->attr('idbuspro');
|
||||
$travel->label = $this->getStringOrNullValue($node->filterXPath('//text'));
|
||||
$travel->dateFrom = $dateFrom;
|
||||
$travel->dateTo = $this->stringToDate($node->attr('bis'));
|
||||
$travel->code = $node->attr('code');
|
||||
$travel->type = $node->attr('reiseart');
|
||||
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
|
||||
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
|
||||
$travel->additionalServices = $this
|
||||
->getAdditionalServices($node->filterXPath('//lei_sonstiges/leistung'));
|
||||
$travel->transportationServices = $this
|
||||
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
|
||||
$travel->rooms = $this->getRooms($hotelNode);
|
||||
$travel->pickupsTo = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom);
|
||||
$travel->pickupsFro = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'));
|
||||
$travel->guide = $this->getGuide($node);
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
public function getSelectionGroups(Crawler $node): array
|
||||
{
|
||||
$selectionGroups = [];
|
||||
|
||||
$node->each(function (Crawler $groupNode) use (&$selectionGroups) {
|
||||
$groupId = (int) $groupNode->attr('idbuspro');
|
||||
$selectionGroup = new CrmSelectionGroup();
|
||||
$selectionGroup->id = $groupId;
|
||||
$selectionGroup->label = $groupNode->attr('bezeichnung');
|
||||
|
||||
$groupNode
|
||||
->filterXPath('//selektion')
|
||||
->each(function (Crawler $selectionNode) use (&$selectionGroups, &$selectionGroup, $groupId) {
|
||||
$selectionId = (int) $selectionNode->attr('idbuspro');
|
||||
$selection = new CrmSelection();
|
||||
$selection->id = $selectionId;
|
||||
$selection->label = $selectionNode->attr('bezeichnung');
|
||||
$selectionGroups[$groupId]['selections'][$selectionId] = $selectionNode->attr('bezeichnung');
|
||||
$selectionGroup->selections[] = $selection;
|
||||
})
|
||||
;
|
||||
|
||||
$selectionGroups[$groupId] = $selectionGroup;
|
||||
});
|
||||
|
||||
return $selectionGroups;
|
||||
}
|
||||
|
||||
public function getAdditionalServices(Crawler $node): array
|
||||
{
|
||||
$additionalServices = [];
|
||||
|
||||
$node->each(function (Crawler $serviceNode) use (&$additionalServices) {
|
||||
$serviceId = (int) $serviceNode->attr('idbuspro');
|
||||
|
||||
$service = new Service();
|
||||
$service->source = Constants::SOURCE_TRAVEL;
|
||||
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||
$service->id = $serviceId;
|
||||
$service->subType = $serviceNode->attr('unterart');
|
||||
$service->mandatory = $this->stringToBool($serviceNode->attr('pflicht'));
|
||||
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
|
||||
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
|
||||
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
|
||||
$service->price = $this
|
||||
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('//preis')));
|
||||
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
|
||||
|
||||
$additionalServices[$serviceId] = $service;
|
||||
});
|
||||
|
||||
return $additionalServices;
|
||||
}
|
||||
|
||||
public function getGuide(Crawler $travelNode): ?Guide
|
||||
{
|
||||
$guideNodes = $travelNode
|
||||
->filterXPath('//lei_befoerderung/leistung[@unterart="BUS"]/zustiegsplanung/reiseleiter');
|
||||
|
||||
if (0 < $guideNodes->count()) {
|
||||
$guideNode = $guideNodes->first();
|
||||
|
||||
$guide = new Guide();
|
||||
$guide->name = $this->getStringOrNullValue($guideNode->filterXPath('//name'));
|
||||
$guide->phone = $this->getStringOrNullValue($guideNode->filterXPath('//telefon'));
|
||||
|
||||
return $guide;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getTransportationServices(Crawler $node): array
|
||||
{
|
||||
$transportationServices = [];
|
||||
|
||||
$node->each(function (Crawler $serviceNode) use (&$transportationServices) {
|
||||
$serviceId = (int) $serviceNode->attr('idbuspro');
|
||||
|
||||
$service = new Service();
|
||||
$service->source = Constants::SOURCE_TRAVEL;
|
||||
$service->category = Constants::CATEGORY_TRANSPORTATION;
|
||||
$service->id = $serviceId;
|
||||
$service->subType = $serviceNode->attr('unterart');
|
||||
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
|
||||
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
|
||||
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
|
||||
$service->direction = $this->getStringOrNullValue($serviceNode->filterXPath('//richtung'));
|
||||
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
|
||||
|
||||
if (null !== $timeFrom = $serviceNode->attr('uhrzeit_von')) {
|
||||
$service->timeFrom = $timeFrom;
|
||||
$service->dayTime = (new DayTimeUtility())->mapTime($timeFrom);
|
||||
}
|
||||
|
||||
$transportationServices[$serviceId] = $service;
|
||||
});
|
||||
|
||||
return $transportationServices;
|
||||
}
|
||||
|
||||
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
|
||||
{
|
||||
$pickups = [];
|
||||
|
||||
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) {
|
||||
$pickupId = (int) $pickupNode->attr('idbuspro');
|
||||
|
||||
$pickup = new Pickup();
|
||||
$pickup->id = $pickupId;
|
||||
$pickup->price = $pickupNode->attr('preis') ?
|
||||
$this->stringToFloat($pickupNode->attr('preis')) : null;
|
||||
|
||||
// parse date and time only for direction 'to' indicated by provided default date
|
||||
if (null !== $defaultDate) {
|
||||
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate);
|
||||
}
|
||||
|
||||
$pickups[$pickupId] = $pickup;
|
||||
});
|
||||
|
||||
return $pickups;
|
||||
}
|
||||
|
||||
public function getHotelNode(Crawler $node, ?int $hotelId): Crawler
|
||||
{
|
||||
// In case not hotel id is provided, take the first hotel node (which is most probably the only one)
|
||||
if (null === $hotelId) {
|
||||
return $node->filterXPath('//hotel')->first();
|
||||
}
|
||||
|
||||
$hotelNode = $node->filterXPath(sprintf('//hotel[@idbuspro="%d"]', $hotelId));
|
||||
|
||||
return $hotelNode->first();
|
||||
}
|
||||
|
||||
public function getRooms(Crawler $node): array
|
||||
{
|
||||
$roomNodes = $node->filterXPath('//zimmer/preis');
|
||||
|
||||
if (0 === $roomNodes->count()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rooms = [];
|
||||
|
||||
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
|
||||
$roomId = (int) $roomNode->attr('idbuspro_zimmer');
|
||||
|
||||
$room = new Room();
|
||||
$room->id = $roomId;
|
||||
$room->code = $roomNode->attr('zimmercode');
|
||||
$room->category = $roomNode->attr('kat');
|
||||
$room->boardId = (int) $roomNode->attr('idbuspro_vp');
|
||||
$room->label = $roomNode->attr('zimmertext');
|
||||
$room->minPax = (int) $roomNode->attr('minpax');
|
||||
$room->maxPax = (int) $roomNode->attr('maxpax');
|
||||
$room->nights = (int) $roomNode->attr('naechte');
|
||||
$room->price = $roomNode->attr('preis') ?
|
||||
$this->stringToFloat($roomNode->attr('preis')) : null;
|
||||
$room->status = $roomNode->attr('status');
|
||||
$room->available = (int) $roomNode->attr('verfuegbar');
|
||||
|
||||
$rooms[$roomId] = $room;
|
||||
});
|
||||
|
||||
return $rooms;
|
||||
return $this->travelParser->parse($travelNode->first(), $hotelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply mutability settings to travel object.
|
||||
*
|
||||
* Updates the travel object's mutability flags based on the provided
|
||||
* mutable data configuration. This controls which travel components
|
||||
* can be modified during booking.
|
||||
*
|
||||
* @param Travel $travel The travel object to update
|
||||
* @param BaseData $mutableData The mutability configuration data
|
||||
*/
|
||||
public function patchMutability(Travel $travel, BaseData $mutableData): void
|
||||
{
|
||||
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_ADDITIONAL_SERVICES)) {
|
||||
@@ -353,6 +214,15 @@ class TravelLoader extends AbstractLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply availability data to travel services.
|
||||
*
|
||||
* Updates the availability status of additional and transportation
|
||||
* services based on the provided availability data.
|
||||
*
|
||||
* @param Travel $travel The travel object to update
|
||||
* @param BaseData $availabilities The availability data for services
|
||||
*/
|
||||
public function patchAvailabilities(Travel $travel, BaseData $availabilities): void
|
||||
{
|
||||
$serviceAvailabilities = $availabilities->getItems();
|
||||
@@ -364,6 +234,14 @@ class TravelLoader extends AbstractLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance booking data with travel information and URLs.
|
||||
*
|
||||
* Loads travel data for each booking and generates travel info URLs
|
||||
* for bookings that are within 5 days of their travel date.
|
||||
*
|
||||
* @param BaseData $bookings The booking data to enhance
|
||||
*/
|
||||
public function patchBookings(BaseData $bookings): void
|
||||
{
|
||||
$travelCodeUtility = new TravelCodeUtility();
|
||||
|
||||
@@ -11,7 +11,7 @@ class ApiResponseParser extends AbstractParser
|
||||
/**
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function parseXmlString(string $type, string $xml): mixed
|
||||
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
||||
{
|
||||
$crawler = new Crawler($xml);
|
||||
|
||||
@@ -19,7 +19,7 @@ class ApiResponseParser extends AbstractParser
|
||||
throw new ResponseParserException('Empty response received from server');
|
||||
}
|
||||
|
||||
// Override type when present in XML to catch error responses
|
||||
// Override type unless present in XML to catch error responses
|
||||
$responseType = $this
|
||||
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type;
|
||||
|
||||
@@ -54,9 +54,14 @@ class ApiResponseParser extends AbstractParser
|
||||
case ApiClient::TYPE_MUTABLE_DATA:
|
||||
return (new MutableDataParser())->parse($resultNode);
|
||||
case ApiClient::TYPE_AVAILABILITY:
|
||||
return (new AvailabilitiesParser())->parse($resultNode);
|
||||
return (new AvailabilitiesParser())->parseServices($resultNode);
|
||||
case ApiClient::TYPE_AVAILABILITY_HOTEL:
|
||||
return (new AvailabilitiesParser())->parseRooms($resultNode);
|
||||
case ApiClient::TYPE_BOOKING_UPDATE:
|
||||
return (new BookingUpdateParser())->parse($resultNode);
|
||||
case ApiClient::TYPE_PRODUCT_DATA:
|
||||
$travelNode = $crawler->filterXPath('//reise/termin');
|
||||
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
|
||||
}
|
||||
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
|
||||
@@ -4,11 +4,12 @@ namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\Availability;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Room;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class AvailabilitiesParser extends AbstractParser
|
||||
{
|
||||
public function parse(Crawler $result): BaseData
|
||||
public function parseServices(Crawler $result): BaseData
|
||||
{
|
||||
$availabilities = [];
|
||||
|
||||
@@ -27,4 +28,27 @@ class AvailabilitiesParser extends AbstractParser
|
||||
|
||||
return new BaseData($availabilities);
|
||||
}
|
||||
|
||||
public function parseRooms(Crawler $result): BaseData
|
||||
{
|
||||
$availabilities = [];
|
||||
|
||||
$result
|
||||
->filterXPath('//unterbringungen/unterbringung')
|
||||
->each(function (Crawler $node) use (&$availabilities) {
|
||||
$room = new Room();
|
||||
$room->id = (int) $node->attr('id_zimmer');
|
||||
$room->label = (string) $node->attr('bezeichnung');
|
||||
$room->category = (string) $node->attr('kategorie');
|
||||
$room->board = (string) $node->attr('verpflegung');
|
||||
$room->available = (int) $node->attr('frei');
|
||||
$room->status = (string) $node->attr('status');
|
||||
$room->price = $this->stringToFloat($node->attr('preis'));
|
||||
|
||||
$availabilities[$room->id] = $room;
|
||||
})
|
||||
;
|
||||
|
||||
return new BaseData($availabilities);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\CrmSelection;
|
||||
use App\BusProNet\Model\CrmSelectionGroup;
|
||||
use App\BusProNet\Model\Guide;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DayTimeUtility;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* Parser for travel XML data from BusProNet API or XML exports.
|
||||
*
|
||||
* This parser handles the extraction and parsing of travel information from XML
|
||||
* including dates, services, rooms, pickups, and pricing. It can be used by both
|
||||
* file-based loaders and API response parsers.
|
||||
*/
|
||||
class TravelParser extends AbstractParser
|
||||
{
|
||||
/**
|
||||
* Parse XML node into a Travel object.
|
||||
*
|
||||
* Extracts all travel-related data from the XML node including dates,
|
||||
* pricing, services, rooms, pickups, and guide information.
|
||||
*
|
||||
* @param Crawler $node The XML node containing travel data
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
* @return Travel The parsed travel object
|
||||
*/
|
||||
public function parse(Crawler $node, ?int $hotelId = null): Travel
|
||||
{
|
||||
$hotelNode = $this->getHotelNode($node, $hotelId);
|
||||
|
||||
$dateFrom = $this->stringToDate($node->attr('termin'));
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->id = (int) $node->attr('idbuspro');
|
||||
$travel->hotelId = (int) $hotelNode->attr('idbuspro');
|
||||
$travel->label = $this->getStringOrNullValue($node->filterXPath('//text'));
|
||||
$travel->dateFrom = $dateFrom;
|
||||
$travel->dateTo = $this->stringToDate($node->attr('bis'));
|
||||
$travel->code = $node->attr('code');
|
||||
$travel->type = $node->attr('reiseart');
|
||||
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
|
||||
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
|
||||
$travel->additionalServices = $this
|
||||
->getAdditionalServices($node->filterXPath('//lei_sonstiges/leistung'));
|
||||
$travel->transportationServices = $this
|
||||
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
|
||||
$travel->rooms = $this->getRooms($hotelNode);
|
||||
$travel->pickupsTo = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom);
|
||||
$travel->pickupsFro = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'));
|
||||
$travel->guide = $this->getGuide($node);
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse selection groups from XML node.
|
||||
*
|
||||
* Extracts CRM selection groups and their associated selections from
|
||||
* the XML structure. Each group contains multiple selection options.
|
||||
*
|
||||
* @param Crawler $node The XML node containing selection group data
|
||||
* @return array<int, CrmSelectionGroup> Array of selection groups indexed by ID
|
||||
*/
|
||||
public function getSelectionGroups(Crawler $node): array
|
||||
{
|
||||
$selectionGroups = [];
|
||||
|
||||
$node->each(function (Crawler $groupNode) use (&$selectionGroups) {
|
||||
$groupId = (int) $groupNode->attr('idbuspro');
|
||||
$selectionGroup = new CrmSelectionGroup();
|
||||
$selectionGroup->id = $groupId;
|
||||
$selectionGroup->label = $groupNode->attr('bezeichnung');
|
||||
|
||||
$groupNode
|
||||
->filterXPath('//selektion')
|
||||
->each(function (Crawler $selectionNode) use (&$selectionGroups, &$selectionGroup, $groupId) {
|
||||
$selectionId = (int) $selectionNode->attr('idbuspro');
|
||||
$selection = new CrmSelection();
|
||||
$selection->id = $selectionId;
|
||||
$selection->label = $selectionNode->attr('bezeichnung');
|
||||
$selectionGroups[$groupId]['selections'][$selectionId] = $selectionNode->attr('bezeichnung');
|
||||
$selectionGroup->selections[] = $selection;
|
||||
})
|
||||
;
|
||||
|
||||
$selectionGroups[$groupId] = $selectionGroup;
|
||||
});
|
||||
|
||||
return $selectionGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse additional services from XML node.
|
||||
*
|
||||
* Extracts additional services like insurance, activities, or extras
|
||||
* from the XML structure with pricing and availability information.
|
||||
*
|
||||
* @param Crawler $node The XML node containing additional service data
|
||||
* @return array<int, Service> Array of additional services indexed by ID
|
||||
*/
|
||||
public function getAdditionalServices(Crawler $node): array
|
||||
{
|
||||
$additionalServices = [];
|
||||
|
||||
$node->each(function (Crawler $serviceNode) use (&$additionalServices) {
|
||||
$serviceId = (int) $serviceNode->attr('idbuspro');
|
||||
|
||||
$service = new Service();
|
||||
$service->source = Constants::SOURCE_TRAVEL;
|
||||
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||
$service->id = $serviceId;
|
||||
$service->subType = $serviceNode->attr('unterart');
|
||||
$service->mandatory = $this->stringToBool($serviceNode->attr('pflicht'));
|
||||
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
|
||||
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
|
||||
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
|
||||
$service->price = $this
|
||||
->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('//preis')));
|
||||
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
|
||||
|
||||
$additionalServices[$serviceId] = $service;
|
||||
});
|
||||
|
||||
return $additionalServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transportation services from XML node.
|
||||
*
|
||||
* Extracts transportation services like bus, train, or flight options
|
||||
* with scheduling, pricing, and direction information.
|
||||
*
|
||||
* @param Crawler $node The XML node containing transportation service data
|
||||
* @return array<int, Service> Array of transportation services indexed by ID
|
||||
*/
|
||||
public function getTransportationServices(Crawler $node): array
|
||||
{
|
||||
$transportationServices = [];
|
||||
|
||||
$node->each(function (Crawler $serviceNode) use (&$transportationServices) {
|
||||
$serviceId = (int) $serviceNode->attr('idbuspro');
|
||||
|
||||
$service = new Service();
|
||||
$service->source = Constants::SOURCE_TRAVEL;
|
||||
$service->category = Constants::CATEGORY_TRANSPORTATION;
|
||||
$service->id = $serviceId;
|
||||
$service->subType = $serviceNode->attr('unterart');
|
||||
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
|
||||
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
|
||||
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('//text'));
|
||||
$service->direction = $this->getStringOrNullValue($serviceNode->filterXPath('//richtung'));
|
||||
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('//status'));
|
||||
|
||||
if (null !== $timeFrom = $serviceNode->attr('uhrzeit_von')) {
|
||||
$service->timeFrom = $timeFrom;
|
||||
$service->dayTime = (new DayTimeUtility())->mapTime($timeFrom);
|
||||
}
|
||||
|
||||
$transportationServices[$serviceId] = $service;
|
||||
});
|
||||
|
||||
return $transportationServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract guide information from travel XML node.
|
||||
*
|
||||
* Searches for guide information in bus transportation services
|
||||
* and extracts name and phone contact details.
|
||||
*
|
||||
* @param Crawler $travelNode The XML node containing travel data
|
||||
* @return Guide|null The guide object or null if no guide found
|
||||
*/
|
||||
public function getGuide(Crawler $travelNode): ?Guide
|
||||
{
|
||||
$guideNodes = $travelNode
|
||||
->filterXPath('//lei_befoerderung/leistung[@unterart="BUS"]/zustiegsplanung/reiseleiter');
|
||||
|
||||
if (0 < $guideNodes->count()) {
|
||||
$guideNode = $guideNodes->first();
|
||||
|
||||
$guide = new Guide();
|
||||
$guide->name = $this->getStringOrNullValue($guideNode->filterXPath('//name'));
|
||||
$guide->phone = $this->getStringOrNullValue($guideNode->filterXPath('//telefon'));
|
||||
|
||||
return $guide;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pickup locations from XML node.
|
||||
*
|
||||
* Extracts pickup/drop-off locations with pricing and timing information.
|
||||
* Date and time parsing is only performed for outbound journeys when
|
||||
* a default date is provided.
|
||||
*
|
||||
* @param Crawler $node The XML node containing pickup data
|
||||
* @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only)
|
||||
* @return array<int, Pickup> Array of pickup locations indexed by ID
|
||||
*/
|
||||
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
|
||||
{
|
||||
$pickups = [];
|
||||
|
||||
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) {
|
||||
$pickupId = (int) $pickupNode->attr('idbuspro');
|
||||
|
||||
$pickup = new Pickup();
|
||||
$pickup->id = $pickupId;
|
||||
$pickup->price = $pickupNode->attr('preis') ?
|
||||
$this->stringToFloat($pickupNode->attr('preis')) : null;
|
||||
|
||||
// parse date and time only for direction 'to' indicated by provided default date
|
||||
if (null !== $defaultDate) {
|
||||
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate);
|
||||
}
|
||||
|
||||
$pickups[$pickupId] = $pickup;
|
||||
});
|
||||
|
||||
return $pickups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hotel node from the travel XML structure.
|
||||
*
|
||||
* Retrieves the hotel node either by specific hotel ID or returns
|
||||
* the first hotel node if no ID is specified.
|
||||
*
|
||||
* @param Crawler $node The XML node containing hotel data
|
||||
* @param int|null $hotelId Optional hotel ID to filter by
|
||||
* @return Crawler The hotel XML node
|
||||
*/
|
||||
public function getHotelNode(Crawler $node, ?int $hotelId): Crawler
|
||||
{
|
||||
// In case not hotel id is provided, take the first hotel node (which is most probably the only one)
|
||||
if (null === $hotelId) {
|
||||
return $node->filterXPath('//hotel')->first();
|
||||
}
|
||||
|
||||
$hotelNode = $node->filterXPath(sprintf('//hotel[@idbuspro="%d"]', $hotelId));
|
||||
|
||||
return $hotelNode->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse room information from hotel XML node.
|
||||
*
|
||||
* Extracts room details including pricing, capacity, availability,
|
||||
* and board options from the hotel XML structure.
|
||||
*
|
||||
* @param Crawler $node The XML node containing room data
|
||||
* @return array<int, Room> Array of rooms indexed by room ID
|
||||
*/
|
||||
public function getRooms(Crawler $node): array
|
||||
{
|
||||
$roomNodes = $node->filterXPath('//zimmer/preis');
|
||||
|
||||
if (0 === $roomNodes->count()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rooms = [];
|
||||
|
||||
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
|
||||
$roomId = (int) $roomNode->attr('idbuspro_zimmer');
|
||||
|
||||
$room = new Room();
|
||||
$room->id = $roomId;
|
||||
$room->code = $roomNode->attr('zimmercode');
|
||||
$room->category = $roomNode->attr('kat');
|
||||
$room->boardId = (int) $roomNode->attr('idbuspro_vp');
|
||||
$room->label = $roomNode->attr('zimmertext');
|
||||
$room->minPax = (int) $roomNode->attr('minpax');
|
||||
$room->maxPax = (int) $roomNode->attr('maxpax');
|
||||
$room->nights = (int) $roomNode->attr('naechte');
|
||||
$room->price = $roomNode->attr('preis') ?
|
||||
$this->stringToFloat($roomNode->attr('preis')) : null;
|
||||
$room->status = $roomNode->attr('status');
|
||||
$room->available = (int) $roomNode->attr('verfuegbar');
|
||||
|
||||
$rooms[$roomId] = $room;
|
||||
});
|
||||
|
||||
return $rooms;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\TravelCodeUtility;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
@@ -11,6 +13,7 @@ use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapDateTime;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -24,6 +27,7 @@ class TravelController extends AbstractController
|
||||
private readonly TravelLoader $travelXmlLoader,
|
||||
private readonly HotelLoader $hotelXmlLoader,
|
||||
private readonly PickupLoader $pickupXmlLoader,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
@@ -49,6 +53,38 @@ class TravelController extends AbstractController
|
||||
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
|
||||
}
|
||||
|
||||
#[Route(
|
||||
path: '/travels/{travelId}/remote',
|
||||
name: 'api_travel_single_id_remote',
|
||||
requirements: ['travelId' => '\d+'],
|
||||
)]
|
||||
public function singleByIdRemote(int $travelId): JsonResponse
|
||||
{
|
||||
$cacheKey = sprintf('bpn_travel_remote_%d', $travelId);
|
||||
|
||||
try {
|
||||
$result = $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId) {
|
||||
$item->expiresAfter(300); // 5 minutes cache for remote API calls
|
||||
|
||||
try {
|
||||
return $this->apiClient->getTravelData($travelId);
|
||||
} catch (ApiClientException $e) {
|
||||
// Return error info instead of throwing to avoid cache wrapping issues
|
||||
return ['error' => $e->getMessage(), 'type' => 'api_error'];
|
||||
}
|
||||
});
|
||||
|
||||
// Check if result is an error
|
||||
if (is_array($result) && isset($result['error'], $result['type'])) {
|
||||
return $this->json($result['error'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->json($result, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $this->json('Cache error occurred', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(
|
||||
path: '/travels/{travelCode}/{hotelCode}',
|
||||
name: 'api_travel_single_code',
|
||||
@@ -63,7 +99,7 @@ class TravelController extends AbstractController
|
||||
$hotelId = $hotelCode ? $this->hotelXmlLoader->mapCodeToId($travelCode) : null;
|
||||
|
||||
if (null === $travelId) {
|
||||
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$travel = $this->loadCached($travelId, $hotelId);
|
||||
@@ -71,29 +107,47 @@ class TravelController extends AbstractController
|
||||
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
|
||||
}
|
||||
|
||||
#[Route('/travels/{travelId}/{hotelId}/{dateTo}/availability', name: 'api_travel_hotel_availability')]
|
||||
public function hotelAvailability(
|
||||
int $travelId,
|
||||
int $hotelId,
|
||||
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo
|
||||
): JsonResponse {
|
||||
try {
|
||||
$result = $this->apiClient->getHotelAvailability($travelId, $hotelId, $dateTo);
|
||||
|
||||
return $this->json($result);
|
||||
} catch (ApiClientException $e) {
|
||||
return $this->json(['error' => $e->getMessage(), 'type' => 'api_error']);
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCached(int $travelId, ?int $hotelId = null): ?Travel
|
||||
{
|
||||
$cacheKey = sprintf('bpn_travel_%d_%d', $travelId, $hotelId ?? 0);
|
||||
|
||||
try {
|
||||
$travel = $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId, $hotelId) {
|
||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId, $hotelId) {
|
||||
$item->expiresAfter(60);
|
||||
|
||||
$travel = $this->travelXmlLoader->loadById($travelId, $hotelId);
|
||||
try {
|
||||
$travel = $this->travelXmlLoader->loadById($travelId, $hotelId);
|
||||
|
||||
if (null === $travel) {
|
||||
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
if (null === $travel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->pickupXmlLoader->patchPickupsDetails($travel);
|
||||
$this->hotelXmlLoader->patchHotelDetails($travel);
|
||||
|
||||
return $travel;
|
||||
} catch (\Exception) {
|
||||
// Return null for any loader exceptions to avoid cache wrapping issues
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->pickupXmlLoader->patchPickupsDetails($travel);
|
||||
$this->hotelXmlLoader->patchHotelDetails($travel);
|
||||
|
||||
return $travel;
|
||||
});
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$travel = null;
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $travel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,49 +2,117 @@
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\Service\BookingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use App\Form\BookingCreateStep1Type;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly TravelLoader $travelDataLoader)
|
||||
{}
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingCreateService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/bookings/create', name: 'app_booking_create', methods: ['POST'])]
|
||||
#[Route('/bookings/create', name: 'app_booking_create_step_1')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$travelId = $request->query->getInt('travel_id');
|
||||
$hotelId = $request->query->getInt('hotel_id');
|
||||
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
|
||||
|
||||
$roomsIdsAndQuantities = $this->getRoomsIdsAndQuantities($request);
|
||||
// Validate step access - allow step 1 or redirect to current step
|
||||
$this->validateStepAccess($bookingCreateDto, 1);
|
||||
|
||||
$travelData = $this->travelDataLoader->loadById($travelId, $hotelId);
|
||||
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto);
|
||||
|
||||
if (null === $travelData) {
|
||||
throw $this->createNotFoundException('Travel data not found');
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$bookingCreateDto->currentStep = 2;
|
||||
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
$participantsCount = 0;
|
||||
|
||||
$rooms = $travelData->getRoomsByIds(array_keys($roomsIdsAndQuantities));
|
||||
|
||||
foreach ($rooms as $room) {
|
||||
$roomCount = $roomsIdsAndQuantities[$room->id] ?? 0;
|
||||
$participantsCount += $room->minPax * $roomCount;
|
||||
}
|
||||
|
||||
return $this->render('booking/create.html.twig', [
|
||||
'travelData' => $travelData,
|
||||
return $this->render('booking/create_step_1.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getRoomsIdsAndQuantities(Request $request): array
|
||||
#[Route('/bookings/create/participants', name: 'app_booking_create_step_2')]
|
||||
public function participants(Request $request): Response
|
||||
{
|
||||
$rooms = $request->request->all('rooms');
|
||||
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
|
||||
|
||||
return array_map('intval', array_filter($rooms, 'strlen'));
|
||||
// Validate step access
|
||||
$this->validateStepAccess($bookingCreateDto, 2);
|
||||
|
||||
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$bookingCreateDto->currentStep = 3;
|
||||
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_3');
|
||||
}
|
||||
|
||||
return $this->render('booking/create_step_2.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')]
|
||||
public function confirm(Request $request): Response
|
||||
{
|
||||
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
|
||||
|
||||
// Validate step access
|
||||
$this->validateStepAccess($bookingCreateDto, 3);
|
||||
|
||||
return $this->render('booking/confirm.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates step access and redirects if necessary.
|
||||
*
|
||||
* @param \App\Form\Model\BookingCreateDto $bookingCreateDto
|
||||
* @param int $expectedStep
|
||||
*/
|
||||
private function validateStepAccess($bookingCreateDto, int $expectedStep): void
|
||||
{
|
||||
// Allow access to current step or any previous step
|
||||
if ($expectedStep > $bookingCreateDto->currentStep) {
|
||||
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
|
||||
|
||||
$this->redirectToCurrentStep($bookingCreateDto);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to the current step based on the DTO's currentStep.
|
||||
*/
|
||||
private function redirectToCurrentStep($bookingCreateDto): void
|
||||
{
|
||||
$routeParams = [
|
||||
'travel_id' => $bookingCreateDto->travelData->id,
|
||||
'hotel_id' => $bookingCreateDto->travelData->hotelId,
|
||||
];
|
||||
|
||||
$route = match ($bookingCreateDto->currentStep) {
|
||||
1 => 'app_booking_create_step_1',
|
||||
2 => 'app_booking_create_step_2',
|
||||
3 => 'app_booking_create_step_3',
|
||||
default => 'app_booking_create_step_1',
|
||||
};
|
||||
|
||||
$this->redirectToRoute($route, $routeParams);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\Controller\Traits\BookingDataTrait;
|
||||
use App\Entity\User;
|
||||
use App\Form\BookingType;
|
||||
use App\Form\BookingEditType;
|
||||
use App\Form\Model\BookingEditDto;
|
||||
use App\Security\Crypt;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
@@ -92,7 +92,7 @@ class EditController extends AbstractController
|
||||
// Create DTO for form
|
||||
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
|
||||
|
||||
$form = $this->createForm(BookingType::class, $formData, [
|
||||
$form = $this->createForm(BookingEditType::class, $formData, [
|
||||
'attr' => ['novalidate' => 'novalidate'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -16,8 +16,20 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Controller for managing customer personal data operations.
|
||||
*
|
||||
* Provides functionality for viewing and updating customer profile information
|
||||
* through integration with the BusProNet API system. Handles personal data
|
||||
* management and newsletter subscription preferences for authenticated users.
|
||||
*/
|
||||
class PersonalDataController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
||||
* @param Crypt $crypt Encryption service for password handling
|
||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly Crypt $crypt,
|
||||
@@ -25,6 +37,18 @@ class PersonalDataController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display and handle updates to customer personal data.
|
||||
*
|
||||
* Fetches current personal data from BusProNet API and displays an editable form.
|
||||
* Handles form submission to update personal information including address and
|
||||
* communication details. Uses the Post-Redirect-Get pattern for form processing.
|
||||
*
|
||||
* @param Request $request The HTTP request containing form data
|
||||
* @return Response The rendered personal data page or redirect response
|
||||
*
|
||||
* @throws ApiClientException When BusProNet API communication fails
|
||||
*/
|
||||
#[Route('/personal-data', name: 'app_personal_data')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function index(Request $request): Response
|
||||
@@ -79,6 +103,17 @@ class PersonalDataController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle newsletter subscription status for the authenticated user.
|
||||
*
|
||||
* Retrieves current personal data, toggles the newsletter subscription flag,
|
||||
* and updates the preference via BusProNet API. Designed for HTMX AJAX
|
||||
* requests to provide immediate feedback without full page reload.
|
||||
*
|
||||
* @return Response Redirect response to personal data page
|
||||
*
|
||||
* @throws ApiClientException When BusProNet API communication fails
|
||||
*/
|
||||
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function newsletter(): Response
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
class BookingCreateParticipantType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ParticipantDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use App\Form\RoomSelectType;
|
||||
|
||||
class BookingCreateStep1Type extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('roomSelections', CollectionType::class, [
|
||||
'entry_type' => RoomSelectType::class,
|
||||
'label' => false,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
'by_reference' => false,
|
||||
'error_bubbling' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
|
||||
class BookingCreateStep2Type extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('participants', CollectionType::class, [
|
||||
'entry_type' => BookingCreateParticipantType::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class BookingType extends AbstractType
|
||||
final class BookingEditType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
@@ -23,7 +23,7 @@ final class BookingType extends AbstractType
|
||||
|
||||
$travelData = $data->travel;
|
||||
$form->add('participants', CollectionType::class, [
|
||||
'entry_type' => ParticipantType::class,
|
||||
'entry_type' => BoolingEditParticipantType::class,
|
||||
'entry_options' => [
|
||||
'selectable_courses' => $this->mergeSelectableServices($data, Constants::TOKEN_COURSES),
|
||||
'selectable_ski_passes' => $this->mergeSelectableServices($data, Constants::TOKEN_SKI_PASS),
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ParticipantType extends AbstractType
|
||||
class BoolingEditParticipantType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
class BookingCreateDto
|
||||
{
|
||||
public int $currentStep = 1;
|
||||
|
||||
#[Assert\Valid]
|
||||
/**
|
||||
* @var array<int, RoomSelectionDto>
|
||||
*/
|
||||
public array $roomSelections = [];
|
||||
|
||||
/**
|
||||
* @var array<int, ParticipantDto>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
public array $participants = [];
|
||||
|
||||
public function __construct(public Travel $travelData, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function assertValidRoomSelections(ExecutionContextInterface $context): void
|
||||
{
|
||||
$participantsCount = $this->getParticipantsCount();
|
||||
$selectedContingent = 0;
|
||||
|
||||
foreach ($this->roomSelections as $roomSelection) {
|
||||
$selectedContingent += $roomSelection->quantity * $roomSelection->minPax;
|
||||
}
|
||||
|
||||
if (0 === $selectedContingent) {
|
||||
$context->buildViolation('Bitte mindestens ein Zimmer auswählen.')
|
||||
->atPath('roomSelections')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if ($selectedContingent !== $participantsCount) {
|
||||
$context->buildViolation('Die ausgewählten Zimmer passen nicht zur Teilnehmerzahl.')
|
||||
->atPath('roomSelections')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
public function getParticipantsCount(): int
|
||||
{
|
||||
$participantsCount = 0;
|
||||
$rooms = $this->travelData->getAvailableRooms();
|
||||
|
||||
foreach ($this->roomSelections as $roomSelection) {
|
||||
$room = $rooms[$roomSelection->roomId];
|
||||
$participantsCount += $room->minPax * $roomSelection->quantity;
|
||||
}
|
||||
|
||||
return $participantsCount;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ class BookingEditDto
|
||||
public ?Booking $booking = null;
|
||||
public ?Travel $travel = null;
|
||||
|
||||
/**
|
||||
* @var array<int, ParticipantDto>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
public array $participants = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class RoomSelectionDto
|
||||
{
|
||||
#[Assert\NotNull(message: 'Bitte eine Zimmerkategorie auswählen')]
|
||||
public ?int $roomId = null;
|
||||
|
||||
public ?string $roomLabel = null;
|
||||
|
||||
public ?int $quantity = null;
|
||||
|
||||
public int $maxQuantity = 100;
|
||||
|
||||
public int $minPax = 0;
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
if (1 === preg_match('/bett/i', $this->roomLabel)) {
|
||||
return 'by_pax';
|
||||
}
|
||||
|
||||
return 'by_room';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
|
||||
class RoomSelectType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('roomId', HiddenType::class)
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||
$data = $event->getData();
|
||||
$form = $event->getForm();
|
||||
|
||||
$form->add('quantity', ChoiceType::class, [
|
||||
'label' => 'Anzahl '.$data->roomLabel,
|
||||
'required' => false,
|
||||
'placeholder' => '-',
|
||||
'choices' => array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)),
|
||||
]);
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => RoomSelectionDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TravelLoader $travelDataLoader,
|
||||
private readonly HotelLoader $hotelDataLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getOrCreateBookingCreateDto(Request $request): BookingCreateDto
|
||||
{
|
||||
$bookingUuid = $request->query->get('uid');
|
||||
$bookingCreateDto = $request->getSession()->get('booking_create');
|
||||
|
||||
// No UID parameter - try to get existing DTO from session
|
||||
if (null === $bookingUuid) {
|
||||
return $bookingCreateDto ?? throw new NotFoundHttpException('No booking data found. Please start from the beginning.');
|
||||
}
|
||||
|
||||
// Create a new DTO - we need travel_id and hotel_id for this
|
||||
$travelId = $request->query->getInt('travel_id');
|
||||
$hotelId = $request->query->getInt('hotel_id');
|
||||
|
||||
if (0 === $travelId || 0 === $hotelId) {
|
||||
throw new NotFoundHttpException('Missing travel_id or hotel_id parameters');
|
||||
}
|
||||
|
||||
$travelData = $this->travelDataLoader->loadById($travelId, $hotelId);
|
||||
if (null === $travelData) {
|
||||
throw new NotFoundHttpException('Travel data not found');
|
||||
}
|
||||
|
||||
$hotelData = $this->hotelDataLoader->loadById($hotelId);
|
||||
if (null === $hotelData) {
|
||||
throw new NotFoundHttpException('Hotel data not found');
|
||||
}
|
||||
|
||||
$travelData->hotel = $hotelData;
|
||||
$roomsIdsAndQuantities = $this->processRoomQuantities($request);
|
||||
$availableRooms = $travelData->getAvailableRooms();
|
||||
|
||||
$roomSelections = array_map(
|
||||
fn (Room $room) => $this->createRoomSelection($room, $roomsIdsAndQuantities),
|
||||
$availableRooms
|
||||
);
|
||||
|
||||
$bookingCreateDto = new BookingCreateDto($travelData, $hotelId);
|
||||
$bookingCreateDto->roomSelections = $roomSelections;
|
||||
|
||||
$this->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
return $bookingCreateDto;
|
||||
}
|
||||
|
||||
public function saveBookingCreateDto(Request $request, BookingCreateDto $bookingCreateDto): void
|
||||
{
|
||||
$request->getSession()->set('booking_create', $bookingCreateDto);
|
||||
}
|
||||
|
||||
private function createRoomSelection(Room $room, array $roomsIdsAndQuantities): RoomSelectionDto
|
||||
{
|
||||
$selection = new RoomSelectionDto();
|
||||
$selection->roomId = $room->id;
|
||||
$selection->roomLabel = $room->label;
|
||||
$selection->maxQuantity = $room->available;
|
||||
$selection->minPax = $room->minPax;
|
||||
$selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0;
|
||||
|
||||
return $selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts room IDs and quantities from the request data.
|
||||
*
|
||||
* Processes the 'rooms' parameter from the request to extract room IDs
|
||||
* as keys and their corresponding quantities as integer values. Filters
|
||||
* out empty values and converts all quantities to integers.
|
||||
*
|
||||
* @param Request $request The HTTP request containing room data
|
||||
*
|
||||
* @return array<int, int> Array with room IDs as keys and quantities as values
|
||||
*/
|
||||
public function processRoomQuantities(Request $request): array
|
||||
{
|
||||
$rooms = $request->request->all('rooms');
|
||||
|
||||
return array_map('intval', array_filter($rooms, 'strlen'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
<h1>Neue Buchung</h1>
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<h2>Zimmerauswahl</h2>
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.roomSelections) }}
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p>Reise: {{ bookingCreateDto.travelData.label }}</p>
|
||||
<p>Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}</p>
|
||||
<p>Hotel: {{ bookingCreateDto.travelData.hotel.name }}</p>
|
||||
{% if bookingCreateDto.participantsCount > 0 %}
|
||||
<p>Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
<h1>Neue Buchung</h1>
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<h2>Teilnehmer</h2>
|
||||
{{ form_start(form) }}
|
||||
{% for participant in form.participants %}
|
||||
{{ form_row(participant) }}
|
||||
{% endfor %}
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p>Reise: {{ bookingCreateDto.travelData.label }}</p>
|
||||
<p>Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}</p>
|
||||
<p>Hotel: {{ bookingCreateDto.travelData.hotel.name }}</p>
|
||||
{% if bookingCreateDto.participantsCount > 0 %}
|
||||
<p>Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}</p>
|
||||
{% endif %}
|
||||
<p>Zimmer: </p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user