<?php
/**
* Created by Elements.at New Media Solutions GmbH
*
*/
namespace App\Service\Shop;
use App\Service\TicketShopFrameworkBundle\TicketFilter;
use Carbon\Carbon;
use Elements\Bundle\TicketShopFrameworkBundle\Model\DataObject\SiteConfigInterface;
use Elements\Bundle\TicketShopFrameworkBundle\Model\DataObject\TicketCatalog;
use Elements\Bundle\TicketShopFrameworkBundle\Model\DataObject\TicketProduct;
use Elements\Bundle\TicketShopFrameworkBundle\Model\Shop\Ticketing\TicketCatalogAvailability;
use Elements\Bundle\TicketShopFrameworkBundle\Service\SiteConfigService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Pimcore;
use Pimcore\Bundle\EcommerceFrameworkBundle\Factory;
use Pimcore\Bundle\EcommerceFrameworkBundle\Model\Currency;
use Pimcore\Bundle\EcommerceFrameworkBundle\PriceSystem\Price;
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;
use Pimcore\Db;
use Psr\Log\LoggerInterface;
class SmartPricerService
{
const DEMAND_PRESSURE_TABLE_NAME = 'bundle_smartpricer_demand_pressure';
const TIME_PRESSURE_TABLE_NAME = 'bundle_smartpricer_time_pressure';
protected LoggerInterface $logger;
protected ?SiteConfigInterface $config;
private string $apiDomain = 'https://api.app.smart-pricer.com';
private string $customer = 'zermatt';
private string $demandPressureSeason = 'ad508dcd-d652-4c60-9275-f1ccde3ace4c';
private string $timePressureSeason = '1f17762e-290f-4b94-a0eb-6b8b57945b18';
private string $APIToken = 'Hj0eU6wNOfE4QdMEObDJzCOQ03iqwcDBy+DLl4aAZTl2F+XDxykYw3D67YxPE3OQOXYVuZ07pOkr/Bvp1YYviuqY4qpsjSUOF0MrXST8NtuBaKZ079DHvAiii0vJ5MRGr1Ef/Q==';
private string $demandPressureAPIToken = 'FHSHVyZCiyBwa5Aw4hkO6QvJu1ZlW5t3O6yzORgAD0uLtZjshqrez0J4J2gU1IMMOBD68a5f0WKEoBVGkL+Z2jWeyMclmSJmP0zODWc7Cqal8YXqgCcbmXK3gxTaeJ5a0jJV4w==';
/** @var array<string, array<string, Price>> $livePrices */
public array $livePrices = [];
/** @var array<string> $cat1Array */
public array $cat1Array = [];
/** @var array<string> $cat3Array */
public array $cat3Array = [];
/**
* SmartPricerService constructor.
*/
public function __construct(
LoggerInterface $smartpricerLogger,
SiteConfigService $siteConfigService,
private Client $client
) {
$this->logger = $smartpricerLogger;
$this->config = $siteConfigService->getSiteConfig();
}
protected function getConnection(): Db\ConnectionInterface
{
return Db::get();
}
public function getLivePrice(string $productExternalId, string $consumerExternalId, Carbon $validityDate): ?Price
{
try {
$debugID = $this->guidv4();
$response = $this->client->request(
'POST',
$this->apiDomain . '/steeringservice/v1/steering/' . $this->customer . '/steer',
[
'json' => [
'categories' => [
'cat01' => $productExternalId,
'cat03' => $consumerExternalId,
],
'season' => $this->timePressureSeason,
'validAt' => $validityDate->getTimestamp(),
],
'headers' => [
'Authorization' => 'Bearer ' . $this->customer . ':' . $this->APIToken,
'X-Correlation-ID' => $debugID,
],
'connect_timeout' => 3,
'timeout' => 3,
]
);
if ($response->getStatusCode() == 200) {
$data = json_decode($response->getBody()->getContents());
if (empty($data->error)) {
$this->logger->info('SmartPricer live-price request debug ID', ['ID' => $debugID]);
if (isset($data->result)) {
$currency = new Currency(Factory::getInstance()->getEnvironment()->getDefaultCurrency()->getShortName());
return new Price(Decimal::fromRawValue($data->result->price, 0)->withScale(4), $currency, false);
} else {
$this->logger->error('No Price found.', [
'error' => $data->error,
'productExternalId' => $productExternalId,
'customerExternalId' => $consumerExternalId,
'validityDate' => $validityDate->getTimestamp(),
'debugId' => $debugID,
]);
}
} else {
$this->logger->error('SmartPricer response-error.', [
'error' => $data->error,
'productExternalId' => $productExternalId,
'customerExternalId' => $consumerExternalId,
'validityDate' => $validityDate->getTimestamp(),
'debugId' => $debugID,
]);
}
}
} catch (GuzzleException $e) {
$this->logger->error('Guzzle-Error while requesting price.', [
'exception' => $e->getMessage(),
'productExternalId' => $productExternalId,
'customerExternalId' => $consumerExternalId,
'validityDate' => $validityDate->getTimestamp(),
]);
}
return null;
}
/**
* @param array<string> $productExternalIdMatrix
* @param array<string> $consumerExternalIdMatrix
* @param Carbon $startDate
* @param Carbon $endDate
*/
public function setLivePrices(array $productExternalIdMatrix, array $consumerExternalIdMatrix, Carbon $startDate, Carbon $endDate): void
{
try {
$debugID = $this->guidv4();
$response = $this->client->request(
'POST',
$this->apiDomain . '/steeringservice/v1/steering/' . $this->customer . '/steer-matrix',
[
'json' => [
'categories' => [
'cat01' => $productExternalIdMatrix,
'cat03' => $consumerExternalIdMatrix,
],
'season' => $this->timePressureSeason,
'startDate' => $startDate->getTimestamp(),
'endDate' => $endDate->getTimestamp(),
],
'headers' => [
'Authorization' => 'Bearer ' . $this->customer . ':' . $this->APIToken,
'X-Correlation-ID' => $debugID,
],
'connect_timeout' => 3,
'timeout' => 3,
]
);
if ($response->getStatusCode() == 200) {
$data = json_decode($response->getBody()->getContents());
if (!empty($data->ranges)) {
$this->logger->info('SmartPricer live-price request debug ID', ['ID' => $debugID]);
if (isset($data->ranges)) {
$currency = new Currency(Factory::getInstance()->getEnvironment()->getDefaultCurrency()->getShortName());
$prices = [];
foreach ($data->ranges as $price) {
if (!empty($price->categories) && !empty($price->steerings)) {
$prices[$price->categories->cat01][$price->categories->cat03] = new Price(Decimal::fromRawValue($price->steerings[0]->result->price, 0)->withScale(4), $currency, false);
}
}
$this->livePrices = $prices;
} else {
$this->logger->error('No Price found.', [
'productExternalId' => implode(', ', $productExternalIdMatrix),
'customerExternalId' => implode(', ', $consumerExternalIdMatrix),
'startDate' => $startDate->getTimestamp(),
'endDate' => $endDate->getTimestamp(),
'debugId' => $debugID,
]);
}
} else {
$this->logger->error('SmartPricer response-error.', [
'productExternalId' => implode(', ', $productExternalIdMatrix),
'customerExternalId' => implode(', ', $consumerExternalIdMatrix),
'startDate' => $startDate->getTimestamp(),
'endDate' => $endDate->getTimestamp(),
'debugId' => $debugID,
]);
}
}
} catch (GuzzleException $e) {
$this->logger->error('Guzzle-Error while requesting price.', [
'exception' => $e->getMessage(),
'productExternalId' => implode(', ', $productExternalIdMatrix),
'customerExternalId' => implode(', ', $consumerExternalIdMatrix),
'startDate' => $startDate->getTimestamp(),
'endDate' => $endDate->getTimestamp(),
]);
}
}
/**
* @param array<mixed> $insurances
* @param array<mixed> $originalConsumerGroups
* @param array<mixed> $upgradeIds
*/
public function setPriceMatrix(TicketCatalogAvailability $ticketCatalogAvailability, array $insurances, array $originalConsumerGroups, Carbon $startDate, Carbon $endDate, TicketProduct $ticket = null, array $upgradeIds = []): void
{
$catalog = $ticketCatalogAvailability->getTicketCatalog();
$isUpgrade = !empty($ticket);
foreach ($ticketCatalogAvailability->getTicketAvailability() as $ticketAvailability) {
$originalTicket = $ticketAvailability->getTicketProduct();
if (!$isUpgrade) {
$ticket = $originalTicket;
} else {
/** @var \App\Model\Shop\Ticket\TicketProduct $ticket */
$metaProduct = $ticket->getMetaProduct();
if ($metaProduct && !in_array($originalTicket, $metaProduct->getRelatedTo(), true)) {
continue;
}
}
foreach ($catalog->getUpgrades() as $upgrade) {
$upgradeCatalog = $upgrade->getObject();
if ($upgradeCatalog instanceof TicketCatalog) {
$upgradeFilter = new TicketFilter($startDate, $endDate);
$upgradeFilter->setExactDuration(true);
$upgradeFilter->setIgnorePrice(true);
$allowedConsumers = [];
foreach ($upgradeCatalog->getTicketConsumerCategories() as $allowedConsumer) {
$allowedConsumers[] = $allowedConsumer->getId();
}
if ($allowedConsumers) {
$upgradeFilter->setConsumers($allowedConsumers);
}
$catalogAvailability = $upgradeFilter->getTicketCatalogAvailability($upgradeCatalog, true);
if ($catalogAvailability->hasAvailability() && !$upgradeCatalog->getIsNotBookable()) {
$this->setPriceMatrix($catalogAvailability, $insurances, $originalConsumerGroups, $startDate, $endDate, $ticket, $upgradeIds);
}
}
}
foreach ($ticketAvailability->getSortedConsumerAvailabilities() as $consumerAvailability) {
$consumer = $consumerAvailability->getTicketConsumer();
$product = $consumerAvailability->getTicketProductAvailability()->getTicketProduct();
$this->cat1Array[] = $product->getSkidataProduct()?->getExternalId();
$this->cat3Array[] = $consumer->getSkidataConsumerForSkidataProduct($product->getSkidataProduct())?->getExternalId();
}
}
}
/**
* @param string $productExternalId
* @param string $customerExternalId
* @param Carbon $validityDate
*
* @return float[]|null
*
* @throws \Doctrine\DBAL\Driver\Exception
*/
public function getTimePressure(string $productExternalId, string $customerExternalId, Carbon $validityDate): ?array
{
$scores = [];
try {
//see if score is already in DB
$connection = $this->getConnection();
$qb = $connection->createQueryBuilder();
$timePressureData = $qb->select('score', 'timePressureTTL', 'textScore')
->from(self::TIME_PRESSURE_TABLE_NAME)
->where('
product_external_id = :productExternalId
AND consumer_external_id = :customerExternalId
AND validityDate = :validityDate
AND timePressureTTL > :now
')
->setParameters([
'productExternalId' => $productExternalId,
'customerExternalId' => $customerExternalId,
'validityDate' => $validityDate->getTimestamp(),
'now' => Carbon::now()->getTimestamp(),
])
->execute()
->fetchAssociative();
//if data is found, check if still valid
if ($timePressureData !== false) {
$ttl = Carbon::createFromTimestamp($timePressureData['timePressureTTL']);
if ($ttl->greaterThan(Carbon::now())) {
$scores = [
'score' => $timePressureData['score'],
'textScore' => $timePressureData['textScore'],
];
}
}
} catch (\Exception $e) {
$this->logger->error('Error while getting time-pressure from DB.', ['exception' => $e->getMessage()]);
}
//if no valid score is found, send a request to smartpricer and save the data
if (empty($scores)) {
try {
$debugID = $this->guidv4();
$response = $this->client->request(
'POST',
$this->apiDomain . '/steeringservice/v1/steering/' . $this->customer . '/steer',
[
'json' => [
'categories' => [
'cat01' => $productExternalId,
'cat03' => $customerExternalId,
],
'season' => $this->timePressureSeason,
'validAt' => $validityDate->getTimestamp(),
'temporary' => true,
'priority' => 2,
],
'headers' => [
'Authorization' => 'Bearer ' . $this->customer . ':' . $this->APIToken,
'X-Correlation-ID' => $debugID,
],
'connect_timeout' => 3,
'timeout' => 3,
]
);
if ($response->getStatusCode() == 200) {
try {
$data = json_decode($response->getBody()->getContents());
if (empty($data->error)) {
$metadata = $data->result->metadata ?? null;
$qb = $connection->createQueryBuilder();
$qb->insert(self::TIME_PRESSURE_TABLE_NAME)
->values([
'product_external_id' => $productExternalId,
'consumer_external_id' => $customerExternalId,
'lastUpdated' => Carbon::now()->getTimestamp(),
'validityDate' => $validityDate->getTimestamp(),
'score' => $metadata ? $metadata->timePressureScore : 0,
'textScore' => $metadata ? $metadata->textPressureScore : 0,
'timePressureTTL' => $metadata ? $metadata->timePressureTTL : Carbon::now()->addMinutes(5)->getTimestamp(),
])
->execute();
$this->logger->info('SmartPricer time-pressure request debug ID', ['ID' => $debugID]);
$scores = [
'score' => $metadata ? $metadata->timePressureScore : 0,
'textScore' => $metadata ? $metadata->textPressureScore : 0,
];
} else {
$this->logger->error('SmartPricer response-error.', ['error' => $data->error]);
}
} catch (\Exception $e) {
$this->logger->error('SmartPricer database error.', ['exception' => $e->getMessage()]);
}
}
} catch (GuzzleException $e) {
$this->logger->error('Guzzle-Error while requesting time-pressure.', ['exception' => $e->getMessage()]);
}
}
return $scores;
}
public function getDemandPressureBulkQuery(): void
{
try {
/** @var Pimcore\Model\DataObject\SiteConfig $config */
$config = $this->config;
if ($startDate = $config->getDemandPressureSeasonStart()) {
$startDate = $startDate->lessThan(Carbon::today()) ? Carbon::today() : $startDate;
} else {
$startDate = Carbon::today();
}
$response = $this->client->request(
'POST',
$this->apiDomain . '/steeringservice/v1/steering/' . $this->customer . '/steer-demand-pressure',
[
'headers' => [
'Authorization' => 'Bearer ' . $this->customer . ':' . $this->demandPressureAPIToken,
],
'json' => [
'season' => $this->demandPressureSeason,
'startDate' => $startDate->startOfDay()->getTimestamp(),
'endDate' => $config->getDemandPressureSeasonEnd()?->startOfDay()->getTimestamp(),
'intervalMinutes' => 1440,
'ignoreErrors' => true,
'priority' => 0,
],
'connect_timeout' => 3,
'timeout' => 3,
]
);
if ($response->getStatusCode() == 200) {
try {
$data = json_decode($response->getBody()->getContents());
$connection = $this->getConnection();
foreach ($data->ranges as $item) {
/** @phpstan-ignore-next-line */
$connection->query('INSERT INTO bundle_smartpricer_demand_pressure(pointInTime, score) VALUES(?, ?) ON DUPLICATE KEY UPDATE score = ?',
[
$item->pointInTime,
$item->result->score,
$item->result->score,
])
->execute();
}
} catch (\Exception $e) {
$this->logger->error('SmartPricer database error.', ['exception' => $e->getMessage()]);
}
}
} catch (GuzzleException $e) {
$this->logger->error('Guzzle-Error while requesting demand-pressure.', ['exception' => $e->getMessage()]);
}
}
public function getPressureText(float $pressure): string
{
/** @var Pimcore\Model\DataObject\SiteConfig $config */
$config = $this->config;
$lowTill = $config->getLowTill() ?? 0.143;
$ratherLowTill = $config->getRatherLowTill() ?? 0.285;
$normalTill = $config->getNormalTill() ?? 0.43;
$ratherHighTill = $config->getRatherHighTill() ?? 0.715;
$pressureText = 'low';
switch ($pressure) {
case $pressure >= $lowTill && $pressure < $ratherLowTill:
$pressureText = 'rather-low';
break;
case $pressure >= $ratherLowTill && $pressure < $normalTill:
$pressureText = 'normal';
break;
case $pressure >= $normalTill && $pressure < $ratherHighTill:
$pressureText = 'rather-high';
break;
case $pressure >= $ratherHighTill:
$pressureText = 'high';
break;
}
return $pressureText;
}
public function getPriceAvailabilityText(float $pressure): string
{
/** @var Pimcore\Model\DataObject\SiteConfig $config */
$config = $this->config;
$fewTicketsTill = $config->getFewTicketsTill() ?? 0.4;
$someTicketsTill = $config->getSomeTicketsTill() ?? 0.715;
$pressureText = 'many-tickets-available';
switch ($pressure) {
case $pressure < $fewTicketsTill:
$pressureText = 'few-tickets-available';
break;
case $pressure >= $fewTicketsTill && $pressure < $someTicketsTill:
$pressureText = 'some-tickets-available';
break;
}
return $pressureText;
}
/**
* @param Carbon $startDate
* @param Carbon $endDate
*
* @return array<mixed>
*/
public function getDemandPressureEntries(Carbon $startDate, Carbon $endDate): array
{
$db = $this->getConnection();
$queryBuilder = $db->createQueryBuilder();
$pressureEntries = $queryBuilder
->select('score', 'pointInTime')
->from(self::DEMAND_PRESSURE_TABLE_NAME)
->where('pointInTime BETWEEN :startTimestamp AND :endTimestamp')
->setParameters([
'startTimestamp' => $startDate->getTimestamp(),
'endTimestamp' => $endDate->getTimestamp(),
])
->execute()
->fetchAllAssociative();
return $pressureEntries;
}
/**
* For debugging purposes
*
* @param string|null $data
*
* @return string
*
* @throws \Exception
*/
private function guidv4(string $data = null): string
{
// Generate 16 bytes (128 bits) of random data or use the data passed into the function.
$data = $data ?? random_bytes(16);
assert(strlen($data) == 16);
// Set version to 0100
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
// Set bits 6-7 to 10
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
// Output the 36 character UUID.
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}