feat: api endpoints and xml sync for contingents data

This commit is contained in:
Björn Fromme
2026-04-22 11:23:52 +02:00
parent a1f68ec11d
commit 9b84893d88
50 changed files with 2567 additions and 221 deletions
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\Utility\DateCodeUtility;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Service\ContingentDataService;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* API endpoints for contingent availability data.
*
* References are accepted as either numeric IDs or business codes:
* - `hotelRef`: hotel ID (`idbuspro`) or hotel code
* - `dateRef`: date ID or date code (sanitized before mapping)
*/
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class ContingentController extends AbstractController
{
public function __construct(
private readonly ContingentDataService $contingentDataService,
private readonly TravelDataProvider $travelDataProvider,
) {
}
#[Route(
'/contingents/calendar',
name: 'api_contingents_calendar',
methods: ['GET'],
)]
public function calendar(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleCalendar($request, $hotelReference);
}
#[Route(
'/contingents',
name: 'api_contingents_single',
methods: ['GET'],
)]
public function byDate(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
$dateReference = $request->query->get('dateRef');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateReference || '' === trim($dateReference)) {
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleByDate($hotelReference, $dateReference);
}
#[Route(
'/contingents/rooms',
name: 'api_contingents_rooms',
methods: ['GET'],
)]
public function rooms(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
$dateReference = $request->query->get('dateRef');
$dateFrom = $request->query->get('dateFrom');
$dateTo = $request->query->get('dateTo');
$myEpUrl = $request->query->get('my_ep_url');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateReference || '' === trim($dateReference)) {
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateFrom || null === $dateTo) {
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleRooms(
$hotelReference,
$dateReference,
$dateFrom,
$dateTo,
is_string($myEpUrl) ? $myEpUrl : null,
);
}
/**
* Shared execution path for calendar data after hotel reference validation.
*/
private function handleCalendar(Request $request, string $hotelReference): JsonResponse
{
$hotelId = $this->resolveHotelId($hotelReference);
if (null === $hotelId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$dateFrom = $request->query->get('dateFrom');
$dateTo = $request->query->get('dateTo');
if (null === $dateFrom || null === $dateTo) {
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
}
try {
$events = $this->contingentDataService->getCalendarEvents($hotelId, $dateFrom, $dateTo);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
return $this->json($events, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Shared execution path for daily contingent summary after reference validation.
*/
private function handleByDate(string $hotelReference, string $dateReference): JsonResponse
{
$hotelId = $this->resolveHotelId($hotelReference);
$dateId = $this->resolveDateId($dateReference);
if (null === $hotelId || null === $dateId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
try {
$contingents = $this->contingentDataService->getAvailableContingents($hotelId, $dateId);
} catch (TravelNotFoundException|HotelNotInTravelException) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
return $this->json($contingents, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Shared execution path for room-level availability after reference validation.
*/
private function handleRooms(
string $hotelReference,
string $dateReference,
string $dateFrom,
string $dateTo,
?string $myEpUrl = null,
): JsonResponse {
$hotelId = $this->resolveHotelId($hotelReference);
$dateId = $this->resolveDateId($dateReference);
if (null === $hotelId || null === $dateId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
try {
$rooms = $this->contingentDataService->getAvailableRooms($dateFrom, $dateTo, $hotelId, $dateId, $myEpUrl);
} catch (TravelNotFoundException|HotelNotInTravelException) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
return $this->json($rooms, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Resolves a hotel reference to hotel ID.
*
* Numeric values are treated as IDs, otherwise mapped as hotel code.
*/
private function resolveHotelId(string $hotelReference): ?int
{
return $this->resolveReferenceId(
$hotelReference,
fn (string $reference): ?int => $this->travelDataProvider->mapHotelCodeToId($reference),
);
}
/**
* Resolves a date reference to date ID.
*
* Numeric values are treated as IDs, otherwise mapped as date code.
* Date codes are sanitized first (uppercased and separators removed).
*/
private function resolveDateId(string $dateReference): ?int
{
return $this->resolveReferenceId(
$dateReference,
fn (string $reference): ?int => $this->travelDataProvider->mapDateCodeToId($reference),
fn (string $reference): string => (new DateCodeUtility())->sanitize($reference),
);
}
/**
* Generic resolver for mixed ID/code references.
*
* Flow:
* 1. Trim and reject empty values.
* 2. If numeric, return as integer ID.
* 3. Optionally normalize the value.
* 4. Map normalized code to ID.
*
* @param callable $mapper maps code input to ID
* @param callable|null $normalizer optional code normalizer before mapping
*/
private function resolveReferenceId(
string $reference,
callable $mapper,
?callable $normalizer = null,
): ?int {
$trimmedReference = trim($reference);
if ('' === $trimmedReference) {
return null;
}
if (true === ctype_digit($trimmedReference)) {
return (int) $trimmedReference;
}
if (null !== $normalizer) {
$trimmedReference = $normalizer($trimmedReference);
}
return $mapper($trimmedReference);
}
}