This commit is contained in:
team3
2026-06-03 22:05:20 +02:00
parent d7e8df6876
commit 40de56c27b
5119 changed files with 552560 additions and 24 deletions

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View File

View 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);
}
}
}

View File

@@ -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,
]);
}
}

View File

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Message;
final class SendNotification
{
public function __construct(
public ?string $message = null
) {}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Message;
final class TriggerPriceImport
{
public function __construct(
public ?string $timestamp = null
) {}
}

View File

@@ -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");
}
}

View File

View 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);
}
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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;
}
}