update
This commit is contained in:
60
projects/priceservice/src/Command/RedisImportCommand.php
Normal file
60
projects/priceservice/src/Command/RedisImportCommand.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\Prices\RedisImportService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Stopwatch\Stopwatch;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'mto:prices:import',
|
||||
description: 'Importiert verschachtelte ORM-Datenstruktur in Redis.'
|
||||
)]
|
||||
final class RedisImportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RedisImportService $importService,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly Stopwatch $stopwatch
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$this->stopwatch->start('price_import');
|
||||
|
||||
$this->importService->writePrices($output);
|
||||
|
||||
$event = $this->stopwatch->stop('price_import');
|
||||
$duration = number_format(($event?->getDuration() ?? 0) / 1000, 2);
|
||||
|
||||
$io->success([
|
||||
'Preisdaten erfolgreich nach Redis importiert.',
|
||||
"Dauer: {$duration}s"
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Redis-Import fehlgeschlagen', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$io->error('Fehler beim Redis-Import: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
projects/priceservice/src/Command/RedisReadCommand.php
Normal file
52
projects/priceservice/src/Command/RedisReadCommand.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\Prices\RedisReadService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'mto:prices:read',
|
||||
description: 'Liest Preise aus RedisReadService basierend auf Shop, Step und SKUs'
|
||||
)]
|
||||
class RedisReadCommand extends Command
|
||||
{
|
||||
private RedisReadService $readService;
|
||||
|
||||
public function __construct(RedisReadService $readService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->readService = $readService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('shopId', InputArgument::REQUIRED, 'Shop ID')
|
||||
->addArgument('step', InputArgument::REQUIRED, 'Price step')
|
||||
->addArgument('skus', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Liste von SKUs (Leerzeichen-getrennt)')
|
||||
->addOption('akStep', null, InputOption::VALUE_OPTIONAL, 'Alternative Einkaufspreisstufe')
|
||||
->addOption('maxPriceAge', null, InputOption::VALUE_OPTIONAL, 'Maximales Alter in Sekunden')
|
||||
->addOption('noTax', null, InputOption::VALUE_NONE, 'Bruttopreise unterdrücken');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$shopId = (int) $input->getArgument('shopId');
|
||||
$step = (int) $input->getArgument('step');
|
||||
$skus = $input->getArgument('skus');
|
||||
$akStep = $input->getOption('akStep') !== null ? (int) $input->getOption('akStep') : null;
|
||||
$maxPriceAge = $input->getOption('maxPriceAge') !== null ? (int) $input->getOption('maxPriceAge') : null;
|
||||
$noTax = $input->getOption('noTax') ?? false;
|
||||
|
||||
$data = $this->readService->getPrices($skus, $shopId, $step, $akStep, $maxPriceAge, $noTax);
|
||||
|
||||
$output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
34
projects/priceservice/src/Command/TriggerTestCommand.php
Normal file
34
projects/priceservice/src/Command/TriggerTestCommand.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Message\TriggerPriceImport;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'mto:test:trigger-import',
|
||||
description: 'Dispatches a TriggerPriceImport message for testing'
|
||||
)]
|
||||
class TriggerTestCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $bus
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$timestamp = date('YmdHis');
|
||||
$output->writeln("Dispatching TriggerPriceImport with timestamp: $timestamp");
|
||||
|
||||
$this->bus->dispatch(new TriggerPriceImport($timestamp));
|
||||
|
||||
$output->writeln("Message dispatched. Check Redis stream 'messages'.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
0
projects/priceservice/src/Controller/.gitignore
vendored
Normal file
0
projects/priceservice/src/Controller/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Service\Prices\RedisReadService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Throwable;
|
||||
|
||||
final class PricesController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RedisReadService $readService
|
||||
) {}
|
||||
|
||||
#[Route('/api/prices/{type}', name: 'api_prices_get', methods: ['GET'], utf8: true)]
|
||||
public function indexGet(string $type = ''): JsonResponse
|
||||
{
|
||||
return new JsonResponse([
|
||||
'status' => Response::HTTP_METHOD_NOT_ALLOWED,
|
||||
'error' => 'This endpoint only accepts POST requests. Please use POST with a JSON payload.',
|
||||
], Response::HTTP_METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[Route('/api/prices/{type}', name: 'api_prices_post', methods: ['POST'], utf8: true)]
|
||||
public function indexPost(string $type = ''): JsonResponse
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
try {
|
||||
$params = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $e) {
|
||||
return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->handleRequest($type, $params);
|
||||
}
|
||||
|
||||
private function handleRequest(string $type, array $params): JsonResponse
|
||||
{
|
||||
$callType = $type ?: RedisReadService::RAW_DATA;
|
||||
|
||||
try {
|
||||
$result = $this->readService->getPricesForSkus($params, $callType);
|
||||
return new JsonResponse($result);
|
||||
} catch (Throwable $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Trigger;
|
||||
|
||||
use App\Message\TriggerPriceImport;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class PriceTriggerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $bus,
|
||||
|
||||
#[Autowire(service: 'limiter.price_import')]
|
||||
private readonly RateLimiterFactory $priceImportLimiter,
|
||||
|
||||
private readonly LoggerInterface $logger
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/trigger-import', name: 'trigger_import', methods: ['POST'])]
|
||||
public function trigger(Request $request): JsonResponse
|
||||
{
|
||||
$limiter = $this->priceImportLimiter->create('price_import_trigger_limiter');
|
||||
$limit = $limiter->consume();
|
||||
|
||||
if (!$limit->isAccepted()) {
|
||||
return new JsonResponse([
|
||||
'status' => 'rate-limited',
|
||||
'retry_after' => $limit->getRetryAfter()?->getTimestamp() - time(),
|
||||
], 429);
|
||||
}
|
||||
|
||||
$timestamp = time(); // Default fallback
|
||||
|
||||
if ($request->getContent()) {
|
||||
try {
|
||||
$payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (is_array($payload) && isset($payload['timestamp']) && is_numeric($payload['timestamp'])) {
|
||||
$timestamp = (int)$payload['timestamp'];
|
||||
}
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->warning('Invalid JSON payload in optional body', ['error' => $e->getMessage(), 'payload' => $request->getContent()]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->bus->dispatch(
|
||||
new TriggerPriceImport((string)$timestamp),
|
||||
[new TransportNamesStamp(['async'])]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Dispatch failed', ['exception' => $e]);
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => 'Dispatch failed',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'queued',
|
||||
'timestamp' => $timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
0
projects/priceservice/src/Entity/.gitignore
vendored
Normal file
0
projects/priceservice/src/Entity/.gitignore
vendored
Normal file
49
projects/priceservice/src/Kernel.php
Normal file
49
projects/priceservice/src/Kernel.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function prepareContainer(ContainerBuilder $container): void
|
||||
{
|
||||
$this->addMithoConfig($container);
|
||||
parent::prepareContainer($container);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function addMithoConfig($container): void
|
||||
{
|
||||
$loader = new YamlFileLoader($container, new FileLocator($this->getProjectDir()));
|
||||
$loader->load('config/mitho/settings.yaml');
|
||||
$this->flattenParameter('mitho', $container);
|
||||
}
|
||||
|
||||
private function flattenParameter(string $name, ContainerBuilder $container): void
|
||||
{
|
||||
$value = $container->getParameter($name);
|
||||
|
||||
if (!is_array($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $innerValue) {
|
||||
$innerName = $name . '.' . $key;
|
||||
$container->setParameter($innerName, $innerValue);
|
||||
$this->flattenParameter($innerName, $container);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
projects/priceservice/src/Message/SendNotification.php
Normal file
11
projects/priceservice/src/Message/SendNotification.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class SendNotification
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $message = null
|
||||
) {}
|
||||
}
|
||||
11
projects/priceservice/src/Message/TriggerPriceImport.php
Normal file
11
projects/priceservice/src/Message/TriggerPriceImport.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class TriggerPriceImport
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $timestamp = null
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\TriggerPriceImport;
|
||||
use App\Service\Prices\RedisImportService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Stopwatch\Stopwatch;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final readonly class TriggerPriceImportHandler
|
||||
{
|
||||
public function __construct(
|
||||
private RedisImportService $importService,
|
||||
private LoggerInterface $logger,
|
||||
private readonly Stopwatch $stopwatch
|
||||
) {}
|
||||
|
||||
public function __invoke(TriggerPriceImport $message): void
|
||||
{
|
||||
$ts = $message->timestamp ?? 'undefined';
|
||||
|
||||
$this->stopwatch->start('price_import_trigger');
|
||||
print('[Handler] Start Import with client timestamp: '.$ts."\n");
|
||||
|
||||
try {
|
||||
$this->importService->writePrices();
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('[Handler] Import error: ', [
|
||||
'timestamp' => $ts,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
print('[Handler] Import error: '.$exception->getMessage()."\n");
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$event = $this->stopwatch->stop('price_import_trigger');
|
||||
$duration = number_format(($event?->getDuration() ?? 0) / 1000, 2);
|
||||
print('[Handler] Import complete with client timestamp: '.$ts."\n");
|
||||
print('[Handler] Duration: '.$duration." s\n\n");
|
||||
}
|
||||
}
|
||||
0
projects/priceservice/src/Repository/.gitignore
vendored
Normal file
0
projects/priceservice/src/Repository/.gitignore
vendored
Normal file
210
projects/priceservice/src/Service/Adapter/Orm.php
Normal file
210
projects/priceservice/src/Service/Adapter/Orm.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Adapter;
|
||||
|
||||
use AllowDynamicProperties;
|
||||
use App\Kernel;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
#[AllowDynamicProperties] class Orm
|
||||
{
|
||||
const SUCCESS_HTTP_STATUS = 200;
|
||||
const MAX_AGE_PRICE_SECONDS = 240;
|
||||
const CLIENT_MAX_DURATION_SECONDS = 20;
|
||||
const CLIENT_TIMEOUT_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* @var ContainerInterface
|
||||
*/
|
||||
private ContainerInterface $container;
|
||||
|
||||
/**
|
||||
* The current URL read from the list of possible URLs. including fallback urls
|
||||
* @var string
|
||||
*/
|
||||
private string $currentUrl;
|
||||
|
||||
/**
|
||||
* List of possible URLs including fallback URLs
|
||||
* @var array
|
||||
*/
|
||||
private array $urlList;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private string $user;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private string $pwd;
|
||||
|
||||
/**
|
||||
* @var HttpClientInterface
|
||||
*/
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
/**
|
||||
* @var LoggerInterface
|
||||
*/
|
||||
private LoggerInterface $logger;
|
||||
|
||||
/**
|
||||
* @var array|string
|
||||
*/
|
||||
private array|string $shopIds;
|
||||
|
||||
/**
|
||||
* @param Kernel $kernel
|
||||
* @param HttpClientInterface $httpClient
|
||||
* @param LoggerInterface $logger
|
||||
*/
|
||||
public function __construct(Kernel $kernel, HttpClientInterface $httpClient, LoggerInterface $logger)
|
||||
{
|
||||
$this->container = $kernel->getContainer();
|
||||
$this->httpClient = $httpClient;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $shopIds
|
||||
* @return array|null
|
||||
*/
|
||||
public function call(array $shopIds): ?array
|
||||
{
|
||||
$result = null;
|
||||
$this->shopIds = implode(',', $shopIds);
|
||||
$this->setConfig();
|
||||
|
||||
foreach ($this->urlList as $url) {
|
||||
$this->currentUrl = $url;
|
||||
$this->setParams();
|
||||
|
||||
try {
|
||||
$result = $this->runCall();
|
||||
break;
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->error("Error in {$url}: " . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($result === null) {
|
||||
throw new RuntimeException("All API calls failed. See errors before this message");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private function setConfig(): void
|
||||
{
|
||||
$url = $this->container->getParameter('mitho.import.orm.url');
|
||||
$urlBckp1 = $this->container->getParameter('mitho.import.orm.urlBckp1');
|
||||
$urlBckp2 = $this->container->getParameter('mitho.import.orm.urlBckp2');
|
||||
$this->user = $this->container->getParameter('mitho.import.orm.user');
|
||||
$this->pwd = $this->container->getParameter('mitho.import.orm.pwd');
|
||||
$this->urlList = array_filter([$url, $urlBckp1, $urlBckp2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private function setParams(): void
|
||||
{
|
||||
$this->currentUrl = $this->currentUrl . '?shopids=' . $this->shopIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization: Basic ' . base64_encode($this->user . ':' . $this->pwd),
|
||||
'User-Agent' => 'MtoPriceService v2'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'headers' => $this->getHeaders(),
|
||||
'timeout' => self::CLIENT_TIMEOUT_SECONDS,
|
||||
'max_duration' => self::CLIENT_MAX_DURATION_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
private function runCall(): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
$this->currentUrl,
|
||||
$this->getOptions()
|
||||
);
|
||||
|
||||
$statusCode = $response->getStatusCode() ?: 500;
|
||||
|
||||
if ($statusCode !== self::SUCCESS_HTTP_STATUS) {
|
||||
throw new HttpException($statusCode, "Unexpected HTTP status: $statusCode");
|
||||
}
|
||||
|
||||
$responseArray = $response->toArray();
|
||||
$responseArray = !empty($responseArray) ? $responseArray : throw new RuntimeException('API response is empty, expected valid JSON data.');
|
||||
|
||||
//Check the age of data
|
||||
$this->validateMaxAgeOfData($responseArray);
|
||||
|
||||
return $responseArray;
|
||||
|
||||
} catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
|
||||
throw new HttpException($e->getCode(), 'HTTP Client Error: ' . $e->getMessage(), $e);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException('Unexpected API system error: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the age of the data and throw an exception if the data is older than the maximum time period
|
||||
* @param array $responseArray
|
||||
* @return void
|
||||
*/
|
||||
private function validateMaxAgeOfData(array $responseArray): void
|
||||
{
|
||||
$lastUpdate = $responseArray['data']['lastupdate'] ?? null;
|
||||
|
||||
try {
|
||||
$dateTime = new DateTimeImmutable($lastUpdate);
|
||||
} catch (Exception) {
|
||||
throw new RuntimeException('API response was not accepted at '.date('Y-m-d H:i:s').', because no valid timestamp was found.');
|
||||
}
|
||||
|
||||
$timeSpan = (time() - $dateTime->getTimestamp());
|
||||
if ($timeSpan > self::MAX_AGE_PRICE_SECONDS) {
|
||||
throw new RuntimeException('API response was not accepted at '.date('Y-m-d H:i:s').', because the date is out of range. Max age: ' . self::MAX_AGE_PRICE_SECONDS . ' | age of data: ' . $timeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Adapter;
|
||||
|
||||
use Redis;
|
||||
use RedisException;
|
||||
use Throwable;
|
||||
|
||||
class RedisClientService
|
||||
{
|
||||
private ?Redis $redis = null;
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function get(): Redis
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
$this->redis = $this->withRetry(function () {
|
||||
$host = $_ENV['REDIS_HOST'];
|
||||
$port = $_ENV['REDIS_PORT'];
|
||||
$timeout = 2.0;
|
||||
|
||||
$redis = new Redis();
|
||||
|
||||
try {
|
||||
$redis->connect($host, (int)$port, $timeout);
|
||||
|
||||
if (!empty($_ENV['REDIS_PASS'])) {
|
||||
$redis->auth($_ENV['REDIS_PASS']);
|
||||
}
|
||||
|
||||
return $redis;
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $this->redis;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generic retry wrapper with exponential backoff.
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function withRetry(callable $callback, int $maxAttempts = 3, int $baseDelayMs = 100)
|
||||
{
|
||||
$attempt = 0;
|
||||
do {
|
||||
try {
|
||||
return $callback();
|
||||
} catch (Throwable $e) {
|
||||
$attempt++;
|
||||
if ($attempt >= $maxAttempts) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$delay = $baseDelayMs * (2 ** ($attempt - 1));
|
||||
usleep($delay * 1000); // Convert ms to microseconds
|
||||
}
|
||||
} while (true);
|
||||
}
|
||||
}
|
||||
483
projects/priceservice/src/Service/Prices/RedisImportService.php
Normal file
483
projects/priceservice/src/Service/Prices/RedisImportService.php
Normal file
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Prices;
|
||||
|
||||
use App\Service\Adapter\Orm;
|
||||
use App\Service\Adapter\RedisClientService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Random\RandomException;
|
||||
use Redis;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Stopwatch\Stopwatch;
|
||||
use Throwable;
|
||||
|
||||
class RedisImportService
|
||||
{
|
||||
private ?Redis $redis = null;
|
||||
private const LOCK_TTL = 30;
|
||||
private const MAX_TTL_PRICE_KEY = 300;
|
||||
private const MAX_VERSIONS = 2;
|
||||
public const PRICE_DATA_FEED = 'price_data_feed.json';
|
||||
private string $priceDataImportDir;
|
||||
private string $randomFilePrefix;
|
||||
private ?OutputInterface $consoleOutput = null;
|
||||
|
||||
/**
|
||||
* RedisImportService constructor.
|
||||
*
|
||||
* @param RedisClientService $redisClientService The service to manage Redis client connections.
|
||||
* @param Orm $ormAdapter The ORM adapter to fetch price data.
|
||||
* @param LoggerInterface $logger The logger for logging messages and errors.
|
||||
* @param array $calledShopIds The shop IDs to call for fetching data.
|
||||
* @param int $cashPriceStep The step index for cash prices.
|
||||
* @param KernelInterface $kernel The kernel interface for accessing project directories.
|
||||
* @param Stopwatch $stopwatch The stopwatch for measuring execution time.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RedisClientService $redisClientService,
|
||||
private readonly Orm $ormAdapter,
|
||||
private readonly LoggerInterface $logger,
|
||||
#[Autowire('%mitho.import.orm.shopIds%')]
|
||||
private readonly array $calledShopIds,
|
||||
#[Autowire('%mitho.import.orm.cashPriceStep%')]
|
||||
private readonly int $cashPriceStep,
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly Stopwatch $stopwatch
|
||||
)
|
||||
{
|
||||
$this->priceDataImportDir = $this->getDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports prices from ORM and saves them to a JSON file.
|
||||
*
|
||||
* This method calls the ORM adapter to fetch price data and saves it to a JSON file
|
||||
* in the specified directory. It returns true if the import was successful, false otherwise.
|
||||
*
|
||||
* @return bool True if the import was successful, false otherwise.
|
||||
*/
|
||||
public function importPricesFromOrm(): bool
|
||||
{
|
||||
$fileName = $this->getPriceDataFeedPath();
|
||||
$ormResult = $this->ormAdapter->call($this->calledShopIds);
|
||||
if ($ormResult) {
|
||||
file_put_contents($fileName, json_encode($ormResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes prices to Redis from the imported data.
|
||||
*
|
||||
* This method reads the price data from the JSON file created by `importPricesFromOrm()`
|
||||
* and writes it to Redis, ensuring that the data is properly formatted and stored.
|
||||
*
|
||||
* @param OutputInterface|null $output Optional output interface for logging messages.
|
||||
* @throws RuntimeException|RandomException|Throwable If there is an error during the import process.
|
||||
*/
|
||||
public function writePrices(?OutputInterface $output = null): void
|
||||
{
|
||||
$updateStartTimestamp = time();
|
||||
|
||||
if ($output) {
|
||||
$this->consoleOutput = $output;
|
||||
}
|
||||
|
||||
//Set global lock to prevent concurrent imports
|
||||
if (!$this->acquireGlobalLock()) {
|
||||
throw new RuntimeException('An import is already in progress...');
|
||||
}
|
||||
|
||||
// Check if Redis is available before proceeding
|
||||
if (!$this->redis()->ping()) {
|
||||
throw new RuntimeException('Redis is not available');
|
||||
}
|
||||
|
||||
// Create a random file prefix to avoid conflicts
|
||||
$this->randomFilePrefix = bin2hex(random_bytes(8));
|
||||
|
||||
$this->__WCO('Call ORM', 'comment');
|
||||
|
||||
//**********************************************************
|
||||
//* Load data from ORM
|
||||
//**********************************************************
|
||||
try {
|
||||
$this->stopwatch->start('call_orm');
|
||||
$this->importPricesFromOrm();
|
||||
$time = $this->stopwatch->stop('call_orm')?->getDuration() / 1000;
|
||||
$this->__WCO("Time ORM call: {$time}s");
|
||||
} catch (Throwable $e) {
|
||||
$this->handleException('Import from ORM failed', $e);
|
||||
}
|
||||
|
||||
//**********************************************************
|
||||
//* Load data from saved feed with data from ORM
|
||||
//**********************************************************
|
||||
try {
|
||||
$this->__WCO('Start save prices', 'comment');
|
||||
|
||||
$this->stopwatch->start('get_price_data_feed');
|
||||
$payload = $this->getPriceDataFromFeed();
|
||||
$time = $this->stopwatch->stop('get_price_data_feed')?->getDuration() / 1000;
|
||||
$this->__WCO("Time load data: {$time}s");
|
||||
} catch (Throwable $e) {
|
||||
$this->handleException('Failed to read price data feed', $e);
|
||||
}
|
||||
|
||||
if (!isset($payload['data']['shops']) || !is_array($payload['data']['shops'])) {
|
||||
throw new RuntimeException('Invalid payload format: "shops" data is missing or not an array.');
|
||||
}
|
||||
|
||||
|
||||
$timestamp = $payload['data']['real_pricedate'] ?? date('Y-m-d H:i:s');
|
||||
$unixTimestamp = strtotime($timestamp);
|
||||
$version = $unixTimestamp;
|
||||
|
||||
//**********************************************************
|
||||
//* Write into redis from payload data from ORM
|
||||
//**********************************************************
|
||||
foreach ($payload['data']['shops'] as $shopId => $shopData) {
|
||||
$shopId = (int)$shopId;
|
||||
$currentVersion = $this->redis()->get("current_cache_version:$shopId");
|
||||
|
||||
//Set shop redis lock
|
||||
if (!$this->acquireLock($shopId)) {
|
||||
$this->__WCO("[INFO] Shop $shopId locked – skipping import.", 'error');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the current version matches the version to be imported
|
||||
if($currentVersion == $version){
|
||||
$this->logger->info("Version: $version for Shop $shopId already exists, skipping import.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$watcherName = "write_shop_data_$shopId";
|
||||
$this->stopwatch->start($watcherName);
|
||||
|
||||
try {
|
||||
$this->importShopData($shopId, $shopData, $version, $timestamp, $unixTimestamp, $updateStartTimestamp);
|
||||
$this->updateCurrentVersion($shopId, $version);
|
||||
$this->manageVersionHistory($shopId, $version);
|
||||
$this->__WCO("[SUCCESS] Imported Shop $shopId (Version $version).", 'fg=white');
|
||||
} catch (Throwable $e) {
|
||||
// Don't stop the import for other shops, only log the error
|
||||
$this->logger->error("Import failed for Shop $shopId: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
|
||||
$this->__WCO("[ERROR] Import failed for Shop $shopId: " . $e->getMessage(), 'error');
|
||||
} finally {
|
||||
|
||||
// Reset lock for the shop
|
||||
$this->releaseLock($shopId);
|
||||
|
||||
$steps = count($shopData['items'][0]['steps'] ?? []);
|
||||
$savedDataCount = count($shopData['items']) * $steps;
|
||||
$time = $this->stopwatch->stop($watcherName)?->getDuration() / 1000;
|
||||
$this->__WCO("Time write data ($savedDataCount keys): {$time}s");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the JSON file after import
|
||||
if (file_exists($this->getPriceDataFeedPath())) {
|
||||
unlink($this->getPriceDataFeedPath());
|
||||
}
|
||||
|
||||
$this->releaseGlobalLock();
|
||||
}
|
||||
|
||||
//**********************************************************
|
||||
//* HELPER METHODS
|
||||
//**********************************************************
|
||||
|
||||
/**
|
||||
* Logs an informational message to the output interface.
|
||||
*
|
||||
* @param string $message The message to log.
|
||||
* @param string|null $type The type of message (e.g., 'info', 'comment'). Defaults to 'info'.
|
||||
*/
|
||||
private function __WCO(string $message, ?string $type = 'info'): void
|
||||
{
|
||||
if ($this->consoleOutput instanceof OutputInterface) {
|
||||
$this->consoleOutput?->writeln("<{$type}>$message</{$type}>");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles exceptions by logging the error and throwing a RuntimeException.
|
||||
*
|
||||
* @param string $context The context in which the exception occurred.
|
||||
* @param Throwable $e The exception to handle.
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function handleException(string $context, Throwable $e): void
|
||||
{
|
||||
$this->logger->error("$context: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
|
||||
throw new RuntimeException("$context: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports shop data into Redis.
|
||||
*
|
||||
* This method processes the shop data and writes it to Redis with a specific key format.
|
||||
* It calculates gross prices based on the net prices and tax rates provided in the data.
|
||||
*
|
||||
* @param int $shopId The ID of the shop to import data for.
|
||||
* @param array $shopData The data for the shop, including items and their prices.
|
||||
* @param string $version The version of the data being imported.
|
||||
* @param string $timestamp The timestamp of the data import.
|
||||
* @param int $unixTimestamp The Unix timestamp of the data import.
|
||||
* @throws RuntimeException|Throwable If there is an error writing to Redis.
|
||||
*/
|
||||
private function importShopData(int $shopId, array $shopData, string $version, string $timestamp, int $unixTimestamp, int $updateStartTimestamp): void
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($shopData['items'] as $item) {
|
||||
$sku = $item['itemid'];
|
||||
$taxrate = isset($item['taxrate']) ? ($item['taxrate'] / 100) : 0.0;
|
||||
$cashPrice = $item['steps'][$this->cashPriceStep]['vk_price'] ?? 0.0;
|
||||
$updateEndTimestamp = time();
|
||||
|
||||
foreach ($item['steps'] as $step => $prices) {
|
||||
$key = "v{$version}:{$step}:{$shopId}:{$sku}";
|
||||
|
||||
$data = [
|
||||
'sku' => $sku,
|
||||
'vk_price' => $prices['vk_price'],
|
||||
'vk_price_gross' => $this->calcGross($prices['vk_price'], $taxrate),
|
||||
'vk_g_price' => $prices['vk_price_g_net'],
|
||||
'vk_g_price_gross' => $this->calcGross($prices['vk_price_g_net'], $taxrate),
|
||||
'ak_price' => $prices['ak_price'],
|
||||
'ak_price_gross' => $this->calcGross($prices['ak_price'], $taxrate),
|
||||
'ak_g_price' => $prices['ak_price_g_net'],
|
||||
'ak_g_price_gross' => $this->calcGross($prices['ak_price_g_net'], $taxrate),
|
||||
'cash_price' => $cashPrice,
|
||||
'cash_price_gross' => $this->calcGross($cashPrice, $taxrate),
|
||||
'taxrate' => $taxrate,
|
||||
'last_update' => $timestamp,
|
||||
'timestamp' => $unixTimestamp,
|
||||
'update_start_timestamp' => $updateStartTimestamp,
|
||||
'update_end_timestamp' => $updateEndTimestamp
|
||||
];
|
||||
|
||||
// Write to Redis with a key that includes the version, step, shop ID, and SKU
|
||||
try {
|
||||
$this->redis()->set($key, json_encode($data), ['NX', 'EX' => self:: MAX_TTL_PRICE_KEY]);
|
||||
} catch (\RedisException $e) {
|
||||
$errors [] = "Failed to write Redis key: {$key}. Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
$this->logger->error(implode("", $errors));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory for price imports.
|
||||
*
|
||||
* This method checks if the directory exists and creates it if it does not.
|
||||
*
|
||||
* @return string The path to the price imports directory.
|
||||
* @throws RuntimeException If the directory cannot be created.
|
||||
*/
|
||||
private function getDir(): string
|
||||
{
|
||||
$dir = $this->kernel->getProjectDir() . '/var/price_imports/';
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException(sprintf('Directory "%s" was not created', $dir));
|
||||
}
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the price data feed file.
|
||||
*
|
||||
* This method checks if the price data feed file exists and is readable.
|
||||
* If not, it attempts to create the file.
|
||||
*
|
||||
* @return string The path to the price data feed file.
|
||||
* @throws RuntimeException If the file cannot be created or is not readable.
|
||||
*/
|
||||
private function getPriceDataFeedPath(): string
|
||||
{
|
||||
$file = $this->priceDataImportDir . '/' . $this->randomFilePrefix . '_' . self::PRICE_DATA_FEED;
|
||||
if (!is_readable($file)) {
|
||||
if (!touch($file)) {
|
||||
$msg = 'Input file not readable';
|
||||
$this->logger->error($msg, ['path' => $file]);
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the price data feed from the JSON file.
|
||||
*
|
||||
* This method reads the JSON file containing price data and decodes it into an array.
|
||||
* It throws an exception if the file is not readable or if the JSON format is invalid.
|
||||
*
|
||||
* @return array The decoded price data.
|
||||
* @throws RuntimeException If the file is not readable or contains invalid JSON.
|
||||
*/
|
||||
private function getPriceDataFromFeed(): array
|
||||
{
|
||||
$file = $this->getPriceDataFeedPath();
|
||||
$json = file_get_contents($file);
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
$msg = 'Invalid JSON format in file';
|
||||
$this->logger->error($msg, ['path' => $file]);
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the gross price from the net price and tax rate.
|
||||
*
|
||||
* @param float $net The net price.
|
||||
* @param float $taxRate The tax rate as a decimal (e.g., 0.19 for 19%).
|
||||
* @return float The calculated gross price, rounded to two decimal places.
|
||||
*/
|
||||
private function calcGross(float $net, float $taxRate): float
|
||||
{
|
||||
return round($net * (1 + $taxRate), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a global lock to prevent concurrent imports.
|
||||
*
|
||||
* @return bool True if the lock was acquired, false if it was already held.
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function acquireGlobalLock(): bool
|
||||
{
|
||||
return $this->redis()->set('lock:import:global', time(), ['NX', 'EX' => self::LOCK_TTL]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the global lock after import is complete.
|
||||
*
|
||||
* @return void
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function releaseGlobalLock(): void
|
||||
{
|
||||
$this->redis()->del('lock:import:global');
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a lock for a specific shop to prevent concurrent imports.
|
||||
*
|
||||
* @param int $shopId The ID of the shop to lock.
|
||||
* @return bool True if the lock was acquired, false if it was already held.
|
||||
* @throws RuntimeException If there is an error acquiring the lock.
|
||||
*/
|
||||
private function acquireLock(int $shopId): bool
|
||||
{
|
||||
try {
|
||||
return $this->redis()->set("lock:$shopId", time(), ['NX', 'EX' => self::LOCK_TTL]);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException("Redis lock error for shop $shopId: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lock for a specific shop.
|
||||
*
|
||||
* @param int $shopId The ID of the shop to release the lock for.
|
||||
* @return void
|
||||
*/
|
||||
private function releaseLock(int $shopId): void
|
||||
{
|
||||
try {
|
||||
$this->redis()->del("lock:$shopId");
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning("[WARN] Failed to release lock for Shop $shopId: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current cache version for a specific shop.
|
||||
*
|
||||
* @param int $shopId The ID of the shop.
|
||||
* @param string $version The new version to set.
|
||||
* @throws RuntimeException|Throwable If there is an error updating the version.
|
||||
*/
|
||||
private function updateCurrentVersion(int $shopId, string $version): void
|
||||
{
|
||||
if (!$this->redis()->set("current_cache_version:$shopId", $version)) {
|
||||
//Don't stop the import if one shop key fails, just only log the error
|
||||
$this->logger->error("Failed to update current version for Shop {$shopId}: {$version}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the version history for a specific shop, keeping only the latest versions.
|
||||
*
|
||||
* @param int $shopId The ID of the shop.
|
||||
* @param string $newVersion The new version to add to the history.
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function manageVersionHistory(int $shopId, string $newVersion): void
|
||||
{
|
||||
$versionListKey = "price_versions:$shopId";
|
||||
|
||||
$this->redis()->lPush($versionListKey, $newVersion);
|
||||
$allVersions = $this->redis()->lRange($versionListKey, 0, -1);
|
||||
$this->redis()->lTrim($versionListKey, 0, self::MAX_VERSIONS - 1);
|
||||
$versionsToDelete = array_slice($allVersions, self::MAX_VERSIONS);
|
||||
|
||||
foreach ($versionsToDelete as $oldVersion) {
|
||||
$this->deleteVersionKeys($oldVersion, $shopId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all keys associated with a specific version for a shop.
|
||||
*
|
||||
* @param string $version The version to delete.
|
||||
* @param int $shopId The ID of the shop.
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function deleteVersionKeys(string $version, int $shopId): void
|
||||
{
|
||||
$pattern = "v{$version}:*:$shopId:*";
|
||||
$cursor = null;
|
||||
do {
|
||||
$batch = $this->redis()->scan($cursor, $pattern, 500);
|
||||
if ($batch) {
|
||||
$this->redis()->del(...$batch);
|
||||
}
|
||||
} while ($cursor !== 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Redis client instance.
|
||||
*
|
||||
* This method initializes the Redis client if it has not been created yet.
|
||||
*
|
||||
* @return Redis The Redis client instance.
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function redis(): Redis
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
$this->redis = $this->redisClientService->get();
|
||||
}
|
||||
return $this->redis;
|
||||
}
|
||||
}
|
||||
200
projects/priceservice/src/Service/Prices/RedisReadService.php
Normal file
200
projects/priceservice/src/Service/Prices/RedisReadService.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Prices;
|
||||
|
||||
use App\Service\Adapter\RedisClientService;
|
||||
use Redis;
|
||||
|
||||
class RedisReadService
|
||||
{
|
||||
private ?Redis $redis = null;
|
||||
public const RAW_DATA = 'RAW_DATA';
|
||||
public const RAW_DATA_DIFF_STEPS = 'RAW_DATA_DIFF_STEPS';
|
||||
public const RAW_DATA_WITH_ARTICLE = 'RAW_DATA_WITH_ARTICLE';
|
||||
|
||||
public function __construct(private readonly RedisClientService $redisClientService)
|
||||
{
|
||||
//$this->redis = $clientService->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves prices for the given SKUs based on the current parameters and call type.
|
||||
*
|
||||
* @param array $currentParams The current parameters including SKU, shop ID, step, AK step, max price age, and no tax flag.
|
||||
* @param string $currentCallType The type of call being made (e.g., RAW_DATA, RAW_DATA_DIFF_STEPS).
|
||||
* @return array An associative array containing the success status, message, data, total count, and validity of MTO prices.
|
||||
*/
|
||||
public function getPricesForSkus(array $currentParams, string $currentCallType): array
|
||||
{
|
||||
$skus = $currentParams['sku'];
|
||||
$shopId = $currentParams['shop'];
|
||||
$step = $currentParams['step'];
|
||||
$akStep = $currentParams['ak_step'];
|
||||
$maxPriceAge = $currentParams['maxPriceAge'];
|
||||
$noTax = $currentParams['noTax'] ?? false;
|
||||
|
||||
$this->validateCallType($currentCallType);
|
||||
|
||||
return $this->getPrices($skus, $shopId, $step, $akStep, $maxPriceAge, $noTax);
|
||||
}
|
||||
|
||||
private function validateCallType(string $callType): void
|
||||
{
|
||||
$validCallTypes = [
|
||||
self::RAW_DATA,
|
||||
self::RAW_DATA_DIFF_STEPS,
|
||||
self::RAW_DATA_WITH_ARTICLE
|
||||
];
|
||||
|
||||
if (!in_array($callType, $validCallTypes, true)) {
|
||||
throw new \InvalidArgumentException("Invalid call type: $callType");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves prices for the given SKUs from Redis.
|
||||
*
|
||||
* @param array $skus List of SKUs to retrieve prices for.
|
||||
* @param int $shopId The ID of the shop.
|
||||
* @param int $step The price step to retrieve.
|
||||
* @param int|null $akStep Optional AK step to retrieve.
|
||||
* @param int|null $maxPriceAge Optional maximum age of the price data in seconds.
|
||||
* @param bool $noTax Whether to exclude tax information from the result.
|
||||
* @return array An associative array containing the success status, message, data, total count, and validity of MTO prices.
|
||||
*/
|
||||
public function getPrices(array $skus, int $shopId, int $step, ?int $akStep = null, ?int $maxPriceAge = null, bool $noTax = false): array
|
||||
{
|
||||
$microNow = microtime(true);
|
||||
$version = $this->redis()->get("current_cache_version:$shopId");
|
||||
|
||||
if (!$version) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'No active version found for shop',
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'MTO-PRICES-VALID' => false,
|
||||
'showTax' => $noTax
|
||||
];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$mto_price_valid = true;
|
||||
$now = time();
|
||||
$chunkSize = 100;
|
||||
|
||||
// Preisdaten in Chunks laden
|
||||
$priceDataAll = [];
|
||||
foreach (array_chunk($skus, $chunkSize) as $chunk) {
|
||||
if (!is_array($chunk)) continue;
|
||||
$keys = array_map(fn($sku) => "v{$version}:{$step}:{$shopId}:{$sku}", $chunk);
|
||||
$priceDataAll = array_merge($priceDataAll, $this->redis()->mget($keys));
|
||||
}
|
||||
|
||||
// Optional: AK-Daten in Chunks laden
|
||||
$akDataAll = [];
|
||||
if ($akStep !== null) {
|
||||
foreach (array_chunk($skus, $chunkSize) as $chunk) {
|
||||
if (!is_array($chunk)) continue;
|
||||
$akKeys = array_map(fn($sku) => "v{$version}:{$akStep}:{$shopId}:{$sku}", $chunk);
|
||||
$akDataAll = array_merge($akDataAll, $this->redis()->mget($akKeys));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($skus as $index => $sku) {
|
||||
$entry = $priceDataAll[$index] ? json_decode($priceDataAll[$index], true) : null;
|
||||
if (!$entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ak = $akDataAll[$index] ?? null;
|
||||
if ($ak && is_string($ak)) {
|
||||
$ak = json_decode($ak, true);
|
||||
}
|
||||
|
||||
$unit = str_starts_with($sku, 'BR-') ? 'Karat' : 'Gramm';
|
||||
$is_disabled = false;
|
||||
|
||||
if ($maxPriceAge !== null && ($now - $entry['timestamp']) > $maxPriceAge) {
|
||||
$mto_price_valid = false;
|
||||
$is_disabled = true;
|
||||
}
|
||||
|
||||
if (!$entry['vk_price']) {
|
||||
$mto_price_valid = false;
|
||||
$is_disabled = true;
|
||||
}
|
||||
|
||||
$result[$sku] = [
|
||||
'subshopId' => $shopId,
|
||||
'sku' => $sku,
|
||||
'step' => $step,
|
||||
|
||||
'price_net' => $entry['vk_price'],
|
||||
'price_num' => $entry['vk_price_gross'] ?? null,
|
||||
'price_currency' => $this->formatCurrency($entry['vk_price_gross'] ?? 0.0),
|
||||
|
||||
'ref_price_net' => $entry['vk_g_price'],
|
||||
'ref_price_num' => $entry['vk_g_price_gross'],
|
||||
'ref_price_currency' => '(' . $this->formatCurrency($entry['vk_g_price_gross']) . ' / 1 ' . $unit . ')',
|
||||
'ref_price_currency_raw' => $this->formatCurrency($entry['vk_g_price_gross']),
|
||||
|
||||
'ak_step' => $akStep ?? $step,
|
||||
'ak_price_net' => $ak['ak_price'] ?? $entry['ak_price'],
|
||||
'ak_price_num' => $ak['ak_price'] ?? $entry['ak_price'],
|
||||
'ak_price_currency' => $this->formatCurrency($ak['ak_price'] ?? $entry['ak_price'] ?? 0.00),
|
||||
|
||||
'cash_price_net' => $entry['cash_price'],
|
||||
'cash_price_num' => $entry['cash_price_gross'] ?? 0.0,
|
||||
'cash_price_currency' => $this->formatCurrency($entry['cash_price_gross'] ?? 0.0),
|
||||
|
||||
'is_disabled' => $is_disabled,
|
||||
|
||||
'taxrate' => round($entry['taxrate'] * 100),
|
||||
'version' => $version,
|
||||
'last_update' => date('d.m.Y H:i:s', strtotime($entry['last_update'])),
|
||||
'timestamp' => $entry['timestamp'],
|
||||
'timestampAsDate' => date('d.m.Y H:i:s', $entry['timestamp']),
|
||||
'updateStartTimestamp' => date('d.m.Y H:i:s', $entry['update_start_timestamp']),
|
||||
'updateEndTimestamp' => date('d.m.Y H:i:s', $entry['update_end_timestamp']),
|
||||
];
|
||||
}
|
||||
|
||||
$timeGap = (microtime(true) - $microNow) * 1000;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'total' => count($result),
|
||||
'version' => $version,
|
||||
'MTO-PRICES-VALID' => $mto_price_valid,
|
||||
'timeGap' => round($timeGap, 2) . ' ms',
|
||||
'data' => $result,
|
||||
'showTax' => $noTax
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats a float value as a currency string.
|
||||
*
|
||||
* @param float $value The value to format.
|
||||
* @return string The formatted currency string.
|
||||
*/
|
||||
private function formatCurrency(float $value): string
|
||||
{
|
||||
return number_format($value, 2, ',', '.') . ' €';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Redis client instance.
|
||||
*
|
||||
* @return Redis
|
||||
*/
|
||||
private function redis(): Redis
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
$this->redis = $this->redisClientService->get();
|
||||
}
|
||||
return $this->redis;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user