diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..99b6271 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(php -l:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/api.http b/api.http index feafa86..afe1a35 100644 --- a/api.http +++ b/api.http @@ -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")}} + diff --git a/assets/controllers/booking_controller.js b/assets/controllers/booking_controller.js index 5ce0c6f..0698ef5 100644 --- a/assets/controllers/booking_controller.js +++ b/assets/controllers/booking_controller.js @@ -1,4 +1,4 @@ -import { Controller} from '@hotwired/stimulus' +import { Controller } from '@hotwired/stimulus' export default class extends Controller { static targets = ['field'] diff --git a/assets/controllers/form_collection_controller.js b/assets/controllers/form_collection_controller.js new file mode 100644 index 0000000..e9e5530 --- /dev/null +++ b/assets/controllers/form_collection_controller.js @@ -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) + } + +} diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 2dc973e..0404286 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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); } diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 15e7f16..09b02a1 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -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 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; + }); + } } diff --git a/src/BusProNet/XmlLoader/TravelLoader.php b/src/BusProNet/XmlLoader/TravelLoader.php index d68406f..17971f4 100644 --- a/src/BusProNet/XmlLoader/TravelLoader.php +++ b/src/BusProNet/XmlLoader/TravelLoader.php @@ -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 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(); diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index b65385a..5216d7e 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -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'); diff --git a/src/BusProNet/XmlParser/AvailabilitiesParser.php b/src/BusProNet/XmlParser/AvailabilitiesParser.php index 165369e..9fcd892 100644 --- a/src/BusProNet/XmlParser/AvailabilitiesParser.php +++ b/src/BusProNet/XmlParser/AvailabilitiesParser.php @@ -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); + } } diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php new file mode 100644 index 0000000..2a76e68 --- /dev/null +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -0,0 +1,297 @@ +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 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 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 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 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 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; + } +} diff --git a/src/Controller/Api/TravelController.php b/src/Controller/Api/TravelController.php index b6f475b..3b756b9 100644 --- a/src/Controller/Api/TravelController.php +++ b/src/Controller/Api/TravelController.php @@ -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; } } diff --git a/src/Controller/Booking/CreateController.php b/src/Controller/Booking/CreateController.php index b574879..a3c705e 100644 --- a/src/Controller/Booking/CreateController.php +++ b/src/Controller/Booking/CreateController.php @@ -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); } } \ No newline at end of file diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php index 309dd17..284cc4b 100644 --- a/src/Controller/Booking/EditController.php +++ b/src/Controller/Booking/EditController.php @@ -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'], ]); diff --git a/src/Controller/PersonalDataController.php b/src/Controller/PersonalDataController.php index 609cd6b..9ff9e37 100644 --- a/src/Controller/PersonalDataController.php +++ b/src/Controller/PersonalDataController.php @@ -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 diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php new file mode 100644 index 0000000..02a3ce6 --- /dev/null +++ b/src/Form/BookingCreateParticipantType.php @@ -0,0 +1,22 @@ +setDefaults([ + 'data_class' => ParticipantDto::class, + ]); + } +} \ No newline at end of file diff --git a/src/Form/BookingCreateStep1Type.php b/src/Form/BookingCreateStep1Type.php new file mode 100644 index 0000000..1174ec8 --- /dev/null +++ b/src/Form/BookingCreateStep1Type.php @@ -0,0 +1,37 @@ +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, + ]); + } +} \ No newline at end of file diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php new file mode 100644 index 0000000..d50e630 --- /dev/null +++ b/src/Form/BookingCreateStep2Type.php @@ -0,0 +1,27 @@ +add('participants', CollectionType::class, [ + 'entry_type' => BookingCreateParticipantType::class, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BookingCreateDto::class, + ]); + } +} \ No newline at end of file diff --git a/src/Form/BookingType.php b/src/Form/BookingEditType.php similarity index 96% rename from src/Form/BookingType.php rename to src/Form/BookingEditType.php index 3847d3a..0788764 100644 --- a/src/Form/BookingType.php +++ b/src/Form/BookingEditType.php @@ -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), diff --git a/src/Form/ParticipantType.php b/src/Form/BoolingEditParticipantType.php similarity index 99% rename from src/Form/ParticipantType.php rename to src/Form/BoolingEditParticipantType.php index f8d7c1c..560dbf0 100644 --- a/src/Form/ParticipantType.php +++ b/src/Form/BoolingEditParticipantType.php @@ -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 { diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php new file mode 100644 index 0000000..622239f --- /dev/null +++ b/src/Form/Model/BookingCreateDto.php @@ -0,0 +1,64 @@ + + */ + public array $roomSelections = []; + + /** + * @var array + */ + #[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; + } +} \ No newline at end of file diff --git a/src/Form/Model/BookingEditDto.php b/src/Form/Model/BookingEditDto.php index 48a3217..565e325 100644 --- a/src/Form/Model/BookingEditDto.php +++ b/src/Form/Model/BookingEditDto.php @@ -13,6 +13,9 @@ class BookingEditDto public ?Booking $booking = null; public ?Travel $travel = null; + /** + * @var array + */ #[Assert\Valid] public array $participants = []; diff --git a/src/Form/Model/RoomSelectionDto.php b/src/Form/Model/RoomSelectionDto.php new file mode 100644 index 0000000..0645b0c --- /dev/null +++ b/src/Form/Model/RoomSelectionDto.php @@ -0,0 +1,28 @@ +roomLabel)) { + return 'by_pax'; + } + + return 'by_room'; + } +} diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php new file mode 100644 index 0000000..a7cdf59 --- /dev/null +++ b/src/Form/RoomSelectType.php @@ -0,0 +1,40 @@ +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, + ]); + } +} \ No newline at end of file diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php new file mode 100644 index 0000000..2e5125b --- /dev/null +++ b/src/Service/BookingService.php @@ -0,0 +1,100 @@ +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 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')); + } +} diff --git a/templates/booking/create_step_1.html.twig b/templates/booking/create_step_1.html.twig new file mode 100644 index 0000000..2e4cc4c --- /dev/null +++ b/templates/booking/create_step_1.html.twig @@ -0,0 +1,25 @@ +{% extends 'layout.html.twig' %} + +{% block content %} + {% include '_partials/_flashes.html.twig' %} +

Neue Buchung

+
+
+

Zimmerauswahl

+ {{ form_start(form) }} + {{ form_row(form.roomSelections) }} + + {{ form_rest(form) }} + {{ form_end(form) }} +
+
+

Zusammenfassung

+

Reise: {{ bookingCreateDto.travelData.label }}

+

Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}

+

Hotel: {{ bookingCreateDto.travelData.hotel.name }}

+ {% if bookingCreateDto.participantsCount > 0 %} +

Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}

+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig new file mode 100644 index 0000000..732d428 --- /dev/null +++ b/templates/booking/create_step_2.html.twig @@ -0,0 +1,31 @@ +{% extends 'layout.html.twig' %} + +{% block content %} + {% include '_partials/_flashes.html.twig' %} +

Neue Buchung

+
+
+

Teilnehmer

+ {{ form_start(form) }} + {% for participant in form.participants %} + {{ form_row(participant) }} + {% endfor %} +
+ Zurück + +
+ {{ form_rest(form) }} + {{ form_end(form) }} +
+
+

Zusammenfassung

+

Reise: {{ bookingCreateDto.travelData.label }}

+

Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}

+

Hotel: {{ bookingCreateDto.travelData.hotel.name }}

+ {% if bookingCreateDto.participantsCount > 0 %} +

Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}

+ {% endif %} +

Zimmer:

+
+
+{% endblock %} \ No newline at end of file