API - Search Prices - Improvements

- Use config variables for API responses.
This commit is contained in:
2025-10-16 19:44:51 +02:00
parent 7673521708
commit 77f8e9ec31
5 changed files with 266 additions and 47 deletions
+47 -17
View File
@@ -3,6 +3,7 @@
namespace App\Controller; namespace App\Controller;
use App\ApiErrorException; use App\ApiErrorException;
use App\Consts\ConfigVariableConsts;
use App\DTO\ApiErrorMessage; use App\DTO\ApiErrorMessage;
use App\Repository\RoomAvailabilityRepository; use App\Repository\RoomAvailabilityRepository;
use App\Repository\RoomCategoryRepository; use App\Repository\RoomCategoryRepository;
@@ -11,16 +12,28 @@ use App\Response\SearchAvailabilityResponse;
use App\Request\SearchPricesForCategoryRequest; use App\Request\SearchPricesForCategoryRequest;
use App\Response\SearchPricesResponse; use App\Response\SearchPricesResponse;
use App\Response\SearchPricesResponsePriceItem; use App\Response\SearchPricesResponsePriceItem;
use App\Service\ConfigVariableService;
use App\Service\PricesService; use App\Service\PricesService;
use DateTimeInterface;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
class SearchRoomsController extends AbstractController class SearchRoomsController extends AbstractController
{ {
private string $configMaxAllowedNightAmount;
public function __construct(
private ConfigVariableService $configVarService,
private LoggerInterface $logger
) {
$this->configMaxAllowedNightAmount = $configVarService->getVariableFromConst(ConfigVariableConsts::CONFIG_API_NAXNIGHTS)->getValue();
}
/** /**
* Get availability for all rooms on period. * Get availability for all rooms on period.
* For each room, the minimal availability will be returned. * For each room, the minimal availability will be returned.
@@ -52,12 +65,11 @@ class SearchRoomsController extends AbstractController
#[MapRequestPayload()] SearchPeriodRequest $searchRequest #[MapRequestPayload()] SearchPeriodRequest $searchRequest
): JsonResponse { ): JsonResponse {
if ($searchRequest->startDate >= $searchRequest->endDate) { $datesValid = $this->areRequestDatesValid($searchRequest->startDate, $searchRequest->endDate);
throw new ApiErrorException( if ($datesValid != null) {
new ApiErrorMessage(422, ApiErrorMessage::TYPE_INVALID_DATE), throw $datesValid;
'startDate cant be before endDate',
);
} }
$rs = $repoAvailability->getAvailabilityForPeriod($searchRequest->startDate, $searchRequest->endDate); $rs = $repoAvailability->getAvailabilityForPeriod($searchRequest->startDate, $searchRequest->endDate);
return $this->json(["results" => $rs]); return $this->json(["results" => $rs]);
@@ -93,11 +105,10 @@ class SearchRoomsController extends AbstractController
PricesService $priceService, PricesService $priceService,
#[MapRequestPayload()] SearchPeriodRequest $searchRequest #[MapRequestPayload()] SearchPeriodRequest $searchRequest
): JsonResponse { ): JsonResponse {
if ($searchRequest->startDate >= $searchRequest->endDate) {
throw new ApiErrorException( $datesValid = $this->areRequestDatesValid($searchRequest->startDate, $searchRequest->endDate);
new ApiErrorMessage(422, ApiErrorMessage::TYPE_INVALID_DATE), if ($datesValid != null) {
'startDate cant be before endDate', throw $datesValid;
);
} }
$rs = $priceService->getRoomSearch($searchRequest->startDate, $searchRequest->endDate); $rs = $priceService->getRoomSearch($searchRequest->startDate, $searchRequest->endDate);
@@ -138,6 +149,11 @@ class SearchRoomsController extends AbstractController
#[MapRequestPayload()] SearchPricesForCategoryRequest $searchRequest #[MapRequestPayload()] SearchPricesForCategoryRequest $searchRequest
): JsonResponse { ): JsonResponse {
$datesValid = $this->areRequestDatesValid($searchRequest->startDate, $searchRequest->endDate);
if ($datesValid != null) {
throw $datesValid;
}
$roomCategory = $repoCat->find($searchRequest->roomCategory); $roomCategory = $repoCat->find($searchRequest->roomCategory);
if ($roomCategory === null) { if ($roomCategory === null) {
throw new ApiErrorException( throw new ApiErrorException(
@@ -146,13 +162,6 @@ class SearchRoomsController extends AbstractController
); );
} }
if ($searchRequest->startDate >= $searchRequest->endDate) {
throw new ApiErrorException(
new ApiErrorMessage(422, ApiErrorMessage::TYPE_INVALID_DATE),
'startDate cant be before endDate',
);
}
$fareList = $priceService->getFinalPricesForSearch($roomCategory, $searchRequest->startDate, $searchRequest->endDate); $fareList = $priceService->getFinalPricesForSearch($roomCategory, $searchRequest->startDate, $searchRequest->endDate);
$rs = []; $rs = [];
foreach ($fareList as $key => $value) { foreach ($fareList as $key => $value) {
@@ -161,4 +170,25 @@ class SearchRoomsController extends AbstractController
return $this->json(["results" => $rs]); return $this->json(["results" => $rs]);
} }
private function areRequestDatesValid(DateTimeInterface $dateStart, DateTimeInterface $dateEnd): ApiErrorException|null
{
if ($dateStart >= $dateEnd) {
return new ApiErrorException(
new ApiErrorMessage(422, ApiErrorMessage::TYPE_INVALID_DATE),
'startDate cant be before endDate',
);
}
$daysDiff = $dateStart->diff($dateEnd)->days;
if ($daysDiff > $this->configMaxAllowedNightAmount) {
return new ApiErrorException(
new ApiErrorMessage(400, ApiErrorMessage::TYPE_API_LIMIT),
'Query exceeded max allowed night amount',
);
}
return null;
}
} }
+2
View File
@@ -11,6 +11,7 @@ class ApiErrorMessage
const TYPE_LOGICAL_ERROR = 'logical_error'; const TYPE_LOGICAL_ERROR = 'logical_error';
const TYPE_SERVER_ERROR = 'server_error'; const TYPE_SERVER_ERROR = 'server_error';
const TYPE_INVALID_DATE = 'invalid_date'; const TYPE_INVALID_DATE = 'invalid_date';
const TYPE_API_LIMIT = 'api_limit';
private static $titles = array( private static $titles = array(
self::TYPE_VALIDATION_ERROR => 'There was a validation error', self::TYPE_VALIDATION_ERROR => 'There was a validation error',
@@ -18,6 +19,7 @@ class ApiErrorMessage
self::TYPE_LOGICAL_ERROR => 'Invalid request due to a logic error', self::TYPE_LOGICAL_ERROR => 'Invalid request due to a logic error',
self::TYPE_SERVER_ERROR => 'Error on server side', self::TYPE_SERVER_ERROR => 'Error on server side',
self::TYPE_INVALID_DATE => 'Invalid date on request', self::TYPE_INVALID_DATE => 'Invalid date on request',
self::TYPE_API_LIMIT => 'Reached API Limit'
); );
private int $statusCode; private int $statusCode;
+10
View File
@@ -28,6 +28,16 @@ class ConfigVariableService
return $this->repoVariables->findVariableByKey($key); return $this->repoVariables->findVariableByKey($key);
} }
public function getVariableFromConst(array $varConst): ConfigVariable|null
{
$key = ConfigVariableConsts::getKeyFromConstant($varConst);
if (is_null($key)) {
return null;
}
return $this->getVariable($key);
}
/** /**
* Create and init all variables. * Create and init all variables.
* To be used during Setup process. * To be used during Setup process.
+93 -30
View File
@@ -2,13 +2,16 @@
namespace App\Service; namespace App\Service;
use App\Consts\ConfigVariableConsts;
use App\Entity\FareTable; use App\Entity\FareTable;
use App\Entity\RoomCategory; use App\Entity\RoomCategory;
use App\Entity\RoomFare;
use App\Repository\FareTableRepository; use App\Repository\FareTableRepository;
use App\Repository\RoomCategoryRepository; use App\Repository\RoomCategoryRepository;
use App\Repository\RoomFareRepository; use App\Repository\RoomFareRepository;
use App\Response\SearchPricesResponse; use App\Response\SearchPricesResponse;
use App\Response\SearchPricesResponsePriceItem; use App\Response\SearchPricesResponsePriceItem;
use App\Util\PriceSelectionMethods;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Ds\Set; use Ds\Set;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -19,13 +22,19 @@ use Psr\Log\LoggerInterface;
*/ */
class PricesService class PricesService
{ {
private string $configFareMethod;
private string $configPriceMethod;
public function __construct( public function __construct(
private RoomCategoryRepository $repoRoomCategory, private RoomCategoryRepository $repoRoomCategory,
private FareTableRepository $repoFareTable, private FareTableRepository $repoFareTable,
private RoomFareRepository $repoRoomFares, private RoomFareRepository $repoRoomFares,
private ConfigVariableService $configVarService,
private LoggerInterface $logger private LoggerInterface $logger
) {} ) {
$this->configFareMethod = $configVarService->getVariableFromConst(ConfigVariableConsts::CONFIG_PRICES_FAREMETHOD)->getValue();
$this->configPriceMethod = $configVarService->getVariableFromConst(ConfigVariableConsts::CONFIG_PRICES_PRICEMETHOD)->getValue();
}
/** /**
* Calcula los precios de la estancia para la categoría y periodo indicados. * Calcula los precios de la estancia para la categoría y periodo indicados.
@@ -79,8 +88,6 @@ class PricesService
* Calculate stay total price on each roomCategory for the given time period * Calculate stay total price on each roomCategory for the given time period
* Returns a list with all roomCategories, containing the total price by guestnumber. * Returns a list with all roomCategories, containing the total price by guestnumber.
* *
* e. g. --> [ 2 => 120€, 3 => 180€ ]
*
* @return SearchPricesResponse[] Total prices for each guestnumber of each roomCategory. * @return SearchPricesResponse[] Total prices for each guestnumber of each roomCategory.
*/ */
public function getRoomSearch(\DateTimeInterface $startDate, \DateTimeInterface $endDate): array public function getRoomSearch(\DateTimeInterface $startDate, \DateTimeInterface $endDate): array
@@ -125,6 +132,11 @@ class PricesService
$roomPrices = []; $roomPrices = [];
foreach ($roomPricesByGuestNum as $guestNum => $price) { foreach ($roomPricesByGuestNum as $guestNum => $price) {
if ($price == null) {
$roomPrices = [];
break;
}
$item = new SearchPricesResponsePriceItem(); $item = new SearchPricesResponsePriceItem();
$item->guestNumber = $guestNum; $item->guestNumber = $guestNum;
$item->price = $price; $item->price = $price;
@@ -159,7 +171,8 @@ class PricesService
for ($i = 0; $i < $daysDiff; $i++) { for ($i = 0; $i < $daysDiff; $i++) {
$strCurrentDate = $currentDate->format("Y-m-d"); $strCurrentDate = $currentDate->format("Y-m-d");
//Obtenemos el tarifario que aplica --> TODOME: Buscar si se puede cachear esto, para no recarlcularlo para cada GuestNumber. //Get Faretable for current day.
//TODO: Possible optimization - This data doesn't need to be recalculated so often.
$currentFareTableList = \array_filter($fareTableList, function (FareTable $element) use ($currentDate) { $currentFareTableList = \array_filter($fareTableList, function (FareTable $element) use ($currentDate) {
return $element->isActiveOnDate($currentDate); return $element->isActiveOnDate($currentDate);
}); });
@@ -195,12 +208,12 @@ class PricesService
return []; return [];
} }
//Si sólo hay 1 tarifa, aplico esa, sino calculo la apropiada según condiciones. //Calculate RoomFare.
$fareAmount = null; $fareAmount = $this->calculateFareApplyingConditions($roomFares, $currentDate);
if (count($roomFares) == 1) {
$fareAmount = $roomFares->first()->getFareAmount(); //If any price can't be calculated, NO price should be returned.
} else { if ($fareAmount == null) {
$fareAmount = $this->calculateFareApplyingConditions($roomFares, $currentDate); return [];
} }
$dailyPrices[$strCurrentDate] = $fareAmount; $dailyPrices[$strCurrentDate] = $fareAmount;
@@ -218,32 +231,76 @@ class PricesService
* @param DateTimeInterface $fareCondDate Fecha utilizada para las condiciones. * @param DateTimeInterface $fareCondDate Fecha utilizada para las condiciones.
* *
*/ */
private function calculateFareApplyingConditions(Collection $roomFareList, \DateTimeInterface $fareCondDate): int private function calculateFareApplyingConditions(Collection $roomFareList, \DateTimeInterface $fareCondDate): int|null
{ {
$currentDayOfWeek = $fareCondDate->format("w");
if (count($roomFareList) == 1) { if (count($roomFareList) == 1) {
return $roomFareList->first()->getFareAmount(); return $roomFareList->first()->getFareAmount();
} }
//Comprobamos si a alguna de las tarifas se la aplica la condición de "DayType" y coincide con el actual. $currentDayOfWeek = $fareCondDate->format("w");
// Si es así, devolvemos su valor. //Filter applicable fares.
foreach ($roomFareList as $roomFare) { //TODO: Possible optimization - This data doesn't need to be recalculated so often; just once for each FareTable.
$dayType = $roomFare->getDayType(); /** @var RoomFare[] */
$applFaresByDay = [];
/** @var RoomFare[] */
$applFaresNonCondition = [];
foreach ($roomFareList as $fare) {
$dayType = $fare->getDayType();
if ($dayType == null || $dayType == "") { if ($dayType == null || $dayType == "") {
$applFaresNonCondition[] = $fare;
continue; continue;
} }
if ($dayType == $currentDayOfWeek) { if ($dayType == $currentDayOfWeek) {
return $roomFare->getFareAmount(); $applFaresByDay[] = $fare;
} }
} }
//TODOME: Si existen múltiples tarifas posibles, debería haber una estrategia para elegir una u otra. Variable de configuración para esto. //Return final fare value.
$faresWithoutConditions = $roomFareList->filter(function ($element) { //Use fares for current weekday.
return $element->getDayType() == null; $finalFare = $this->getPriceFromRoomFareList($applFaresByDay);
}); if ($finalFare != null) {
return $finalFare;
}
return $faresWithoutConditions->first()->getFareAmount(); //Use non-conditional fares.
$finalFare = $this->getPriceFromRoomFareList($applFaresNonCondition);
if ($finalFare != null) {
return $finalFare;
}
//No valid fare found!
$this->logger->error(
"PricesService.calculateFareApplyingConditions: No RoomFare found. Price can't be calculated.",
["RoomFareList" => $roomFareList, "Date" => $fareCondDate]
);
return null;
}
/**
* Get the fare with bigger priority from a list of fares.
* @param RoomFare[] $roomFareList List of RoomFares.
*/
private function getPriceFromRoomFareList(array $roomFareList): int|null
{
$countFares = count($roomFareList);
if ($roomFareList == null || $countFares == 0) {
return null;
}
if ($countFares == 1) {
return $roomFareList[0]->getFareAmount();
}
switch ($this->configFareMethod) {
case 'HIGHER':
return PriceSelectionMethods::selectFare_Highest($roomFareList);
case 'LOWER':
return PriceSelectionMethods::selectFare_Lowest($roomFareList);
case 'AVERAGE':
return PriceSelectionMethods::selectFare_Average($roomFareList);
default:
return null;
}
} }
/** /**
@@ -273,13 +330,19 @@ class PricesService
* @param array $dailyPrices Array containing the price for each day. * @param array $dailyPrices Array containing the price for each day.
* @return int Total stay price. * @return int Total stay price.
*/ */
private function getTotalPriceForStay(array $dailyPrices): int private function getTotalPriceForStay(array $dailyPrices): int|null
{ {
// TODOME: Crear y configurar diversas estrategias de precios -> Por el momento utilizamos "Suma del precio de cada día". switch ($this->configPriceMethod) {
case 'SUM':
$finalAmount = \array_reduce($dailyPrices, function ($acc, $element) { return PriceSelectionMethods::priceMethod_Sum($dailyPrices);
return $acc + $element; case 'AVERAGE':
}); return PriceSelectionMethods::priceMethod_Average($dailyPrices);
return $finalAmount; case 'HIGHER':
return PriceSelectionMethods::priceMethod_Highest($dailyPrices);
case 'LOWER':
return PriceSelectionMethods::priceMethod_Lowest($dailyPrices);
default:
return null;
}
} }
} }
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Util;
use App\Entity\RoomFare;
//Utils - Functions to select prices and fares.
class PriceSelectionMethods
{
#region Fare Selection
/**
* Fare Selection - Get fare with highest price.
* @param RoomFare[] $fareList List of fares.
*/
public static function selectFare_Highest(array $fareList): int
{
$pricesArr = array_map(function (RoomFare $f) {
return $f->getFareAmount();
}, $fareList);
return max($pricesArr);
}
/**
* Fare Selection - Get fare with lowest price.
* @param RoomFare[] $fareList List of fares.
*/
public static function selectFare_Lowest(array $fareList): int
{
$pricesArr = array_map(function (RoomFare $f) {
return $f->getFareAmount();
}, $fareList);
return min($pricesArr);
}
/**
* Fare Selection - Get average price of given fares.
* @param RoomFare[] $fareList List of fares.
*/
public static function selectFare_Average(array $fareList): int|null
{
$totalAmount = 0;
$count = 0;
foreach ($fareList as $fare) {
$fareAmount = $fare->getFareAmount();
if ($fareAmount != null) {
$totalAmount += $fareAmount;
$count++;
}
}
return $count > 0 ? ($totalAmount / $count) : null;
}
#endregion
#region Price Selection
/**
* Price Method - Total price is the sum of daily prices.
* @param array $prices
* @return int Total price.
*/
public static function priceMethod_Sum(array $prices): int
{
$values = array_values($prices);
return array_sum($values);
}
/**
* Price Method - Apply the hightest daily price to all days.
* @param array $prices
* @return int Total price.
*/
public static function priceMethod_Highest(array $prices): int
{
$highestPrice = max($prices);
$numDays = count($prices);
return $numDays * $highestPrice;
}
/**
* Price Method - Apply the lowest daily price to all days.
* @param array $prices
* @return int Total price.
*/
public static function priceMethod_Lowest(array $prices): int
{
$highestPrice = min($prices);
$numDays = count($prices);
return $numDays * $highestPrice;
}
/**
* Price Method - Calculate average daily price and apply it to all days.
* @param array $prices
* @return int Total price.
*/
public static function priceMethod_Average(array $prices): int|null
{
$sum = array_sum($prices);
$numDays = count($prices);
$averagePrice = $numDays > 0 ? $sum / $numDays : null;
return $averagePrice != null ? $averagePrice * $numDays : null;
}
#endregion
}