Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit 01458e3b4c
589 changed files with 65788 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Console\Commands;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CacheClearCommand extends AcmeShopCommand
{
protected static $defaultName = 'cache:clear';
protected static $defaultDescription = 'Очистка кеша модуля AcmeShop';
private CacheInterface $cache;
public function __construct(CacheInterface $cache)
{
parent::__construct();
$this->cache = $cache;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Очистка кеша модуля AcmeShop');
try {
$this->cache->clear();
$io->success('Кеш успешно очищен!');
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error('Ошибка при очистке кеша: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Console\Commands;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CustomerCountsCommand extends AcmeShopCommand
{
protected static $defaultName = 'customer:counts';
protected static $defaultDescription = 'Обновление счетчиков заказов для всех клиентов';
private ConnectionInterface $database;
public function __construct(ConnectionInterface $database)
{
parent::__construct();
$this->database = $database;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Обновление счетчиков заказов клиентов');
$io->writeln('Выполняется пересчёт счетчиков заказов...');
$sql = <<<SQL
update acmeshop_customers
set orders_count = (select count(*) from oc_order where oc_order.customer_id = acmeshop_customers.oc_customer_id)
where true;
SQL;
$this->database->statement($sql);
$io->success('Счетчики заказов успешно обновлены!');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace Console\Commands;
use Acme\ECommerceFramework\Container\Container;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ImagesCacheClearCommand extends AcmeShopCommand
{
protected static $defaultName = 'images:cache-clear';
protected static $defaultDescription = 'Очистка кеша изображений товаров';
private Container $container;
public function __construct(Container $container)
{
parent::__construct();
$this->container = $container;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Очистка кеша изображений товаров');
// Получаем пути из конфига
$imagesDir = $this->container->getConfigValue('paths.images');
$cachePath = $this->container->getConfigValue('paths.images_cache', 'cache/acmeshop');
$cachePath = ltrim($cachePath, '/');
$fullCachePath = rtrim($imagesDir, '/') . '/' . $cachePath;
if (!is_dir($fullCachePath)) {
$io->warning("Директория кеша не существует: {$fullCachePath}");
return Command::SUCCESS;
}
$io->section('Информация');
$io->listing([
"Директория изображений: {$imagesDir}",
"Путь кеша: {$cachePath}",
"Полный путь кеша: {$fullCachePath}",
]);
// Подсчитываем файлы перед удалением
$fileCount = 0;
$totalSize = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($fullCachePath, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$fileCount++;
$totalSize += $file->getSize();
}
}
if ($fileCount === 0) {
$io->info('Кеш пуст, нечего очищать.');
return Command::SUCCESS;
}
$io->section('Статистика перед очисткой');
$io->listing([
"Файлов: {$fileCount}",
"Размер: " . $this->formatBytes($totalSize),
]);
// Запрашиваем подтверждение
if (!$io->confirm('Вы уверены, что хотите удалить все файлы из кеша?', false)) {
$io->info('Очистка кеша отменена.');
return Command::SUCCESS;
}
// Удаляем файлы и директории
$deletedFiles = 0;
$deletedDirs = 0;
$errors = 0;
$progressBar = $io->createProgressBar($fileCount);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Удаление файлов...');
$progressBar->start();
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($fullCachePath, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
try {
if ($file->isFile()) {
if (@unlink($file->getPathname())) {
$deletedFiles++;
} else {
$errors++;
}
$progressBar->advance();
} elseif ($file->isDir()) {
if (@rmdir($file->getPathname())) {
$deletedDirs++;
}
}
} catch (\Exception $e) {
$errors++;
}
}
// Удаляем саму директорию кеша, если она пуста
if (is_dir($fullCachePath)) {
@rmdir($fullCachePath);
}
$progressBar->setMessage('Завершено');
$progressBar->finish();
$io->newLine(2);
// Выводим статистику
$io->section('Результаты');
$io->table(
['Метрика', 'Значение'],
[
['Удалено файлов', $deletedFiles],
['Удалено директорий', $deletedDirs],
['Ошибок', $errors],
]
);
if ($errors > 0) {
$io->warning("Обнаружено {$errors} ошибок при удалении файлов.");
} else {
$io->success('Кеш изображений успешно очищен!');
}
return Command::SUCCESS;
}
private function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace Console\Commands;
use App\Services\SettingsService;
use Exception;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Acme\ECommerceFramework\ImageTool\ImageUtils;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\Support\Arr;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ImagesWarmupCacheCommand extends AcmeShopCommand
{
protected static $defaultName = 'images:warmup';
protected static $defaultDescription = 'Прогрев кеша изображений товаров';
private Builder $queryBuilder;
private ImageFactory $image;
private SettingsService $settings;
private LoggerInterface $logger;
public function __construct(
Builder $queryBuilder,
ImageFactory $image,
SettingsService $settings,
LoggerInterface $logger
) {
parent::__construct();
$this->queryBuilder = $queryBuilder;
$this->image = $image;
$this->settings = $settings;
$this->logger = $logger;
}
protected function configure(): void
{
$this->addArgument(
'product_id',
InputArgument::OPTIONAL,
'ID товара для прогрева кеша (если не указан, прогреваются все товары)'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$productId = $input->getArgument('product_id');
$io->title('Прогрев кеша изображений товаров');
// Получаем настройки
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$io->section('Настройки');
$io->listing([
"Соотношение сторон: {$aspectRatio}",
"Алгоритм обрезки: {$cropAlgorithm}",
"Размер изображения: {$imageWidth}x{$imageHeight}",
"Размер миниатюры: 500x500",
"Размер большого изображения: 1000x1000",
]);
// Получаем список товаров
$products = $this->getProducts($productId, $languageId);
if (empty($products)) {
$io->warning('Товары не найдены');
return Command::SUCCESS;
}
$totalProducts = count($products);
$io->section("Найдено товаров: {$totalProducts}");
$stats = [
'products' => 0,
'main_images' => 0,
'additional_images' => 0,
'thumbnails' => 0,
'large_images' => 0,
'errors' => 0,
];
$progressBar = $io->createProgressBar($totalProducts);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Обработка товаров...');
$progressBar->start();
foreach ($products as $product) {
$productId = $product['product_id'];
$productName = $product['product_name'] ?? "ID: {$productId}";
$progressBar->setMessage("Товар: {$productName}");
try {
// Прогреваем основное изображение товара
if (!empty($product['product_image'])) {
try {
$this->image->make($product['product_image'])
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url();
$stats['main_images']++;
} catch (Exception $e) {
$this->logger->error("Ошибка при прогреве основного изображения товара {$productId}: " . $e->getMessage());
$stats['errors']++;
}
}
// Получаем дополнительные изображения товара
$additionalImages = $this->getProductAdditionalImages($productId);
$processedAdditional = 0;
foreach ($additionalImages as $imagePath) {
if ($processedAdditional >= 2) {
break; // Ограничиваем до 2 дополнительных изображений, как в ProductsService
}
try {
$this->image->make($imagePath)
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url();
$stats['additional_images']++;
$processedAdditional++;
} catch (Exception $e) {
$this->logger->error("Ошибка при прогреве дополнительного изображения товара {$productId}: " . $e->getMessage());
$stats['errors']++;
}
}
// Прогреваем изображения для детальной страницы (миниатюры и большие)
$allImages = [];
if (!empty($product['product_image'])) {
$allImages[] = $product['product_image'];
}
$allImages = array_merge($allImages, $additionalImages);
foreach ($allImages as $imagePath) {
try {
// Миниатюра
$this->image->make($imagePath)
->contain(500, 500)
->url();
$stats['thumbnails']++;
// Большое изображение
$this->image->make($imagePath)
->resize(1000, 1000)
->url();
$stats['large_images']++;
} catch (Exception $e) {
$this->logger->error("Ошибка при прогреве изображений для детальной страницы товара {$productId}: " . $e->getMessage());
$stats['errors']++;
}
}
$stats['products']++;
} catch (Exception $e) {
$this->logger->error("Ошибка при обработке товара {$productId}: " . $e->getMessage());
$stats['errors']++;
}
$progressBar->advance();
}
$progressBar->setMessage('Завершено');
$progressBar->finish();
$io->newLine(2);
// Выводим статистику
$io->section('Статистика');
$io->table(
['Метрика', 'Значение'],
[
['Обработано товаров', $stats['products']],
['Основных изображений', $stats['main_images']],
['Дополнительных изображений', $stats['additional_images']],
['Миниатюр (500x500)', $stats['thumbnails']],
['Больших изображений (1000x1000)', $stats['large_images']],
['Ошибок', $stats['errors']],
]
);
if ($stats['errors'] > 0) {
$io->warning("Обнаружено {$stats['errors']} ошибок. Проверьте логи для подробностей.");
} else {
$io->success('Кеш изображений успешно прогрет!');
}
return Command::SUCCESS;
}
/**
* Получает список товаров для прогрева кеша
*/
private function getProducts(?string $productId, int $languageId): array
{
$query = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.image' => 'product_image',
'product_description.name' => 'product_name',
])
->from(db_table('product'), 'products')
->join(
db_table('product_description') . ' AS product_description',
function (JoinClause $join) use ($languageId) {
$join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', $languageId);
}
)
->where('products.status', '=', 1)
->whereRaw('products.date_available < NOW()');
if ($productId !== null) {
$query->where('products.product_id', '=', (int) $productId);
}
return $query->orderBy('products.product_id', 'ASC')->get();
}
/**
* Получает дополнительные изображения товара
*/
private function getProductAdditionalImages(int $productId): array
{
$images = $this->queryBuilder->newQuery()
->select(['products_images.image' => 'image'])
->from(db_table('product_image'), 'products_images')
->where('products_images.product_id', '=', $productId)
->orderBy('products_images.sort_order')
->get();
return Arr::pluck($images, 'image');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Console\Commands;
use Bastion\ScheduledTasks\AcmeShopPulseSendEventsTask;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class PulseSendEventsCommand extends AcmeShopCommand
{
protected static $defaultName = 'pulse:send';
protected static $defaultDescription = 'Manually send pulse events ignoring schedule.';
private AcmeShopPulseSendEventsTask $megaPayPulseSendEventsTask;
public function __construct(AcmeShopPulseSendEventsTask $megaPayPulseSendEventsTask)
{
parent::__construct();
$this->megaPayPulseSendEventsTask = $megaPayPulseSendEventsTask;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('<info>Sending Pulse events.</info>');
$this->megaPayPulseSendEventsTask->execute();
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Console\Commands;
use Carbon\Carbon;
use Acme\ECommerceFramework\Config\Settings;
use Acme\ECommerceFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class ScheduleRunCommand extends AcmeShopCommand
{
private SchedulerService $scheduler;
private Settings $settings;
protected static $defaultName = 'schedule:run';
protected static $defaultDescription = 'Run scheduled commands';
public function __construct(SchedulerService $scheduler, Settings $settings)
{
parent::__construct();
$this->scheduler = $scheduler;
$this->settings = $settings;
}
protected function configure(): void
{
$this->addOption(
'ignore-global-lock',
null,
InputOption::VALUE_NONE,
'Ignore global scheduler lock (e.g. when running multiple cron instances)'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'system') {
$output->writeln('<comment>Scheduler not in CRON mode. Skipping CLI execution.</comment>');
return Command::SUCCESS;
}
$output->writeln(
sprintf(
'[%s] <info>AcmeShop Scheduler Running...</info>',
Carbon::now()->toJSON(),
)
);
$ignoreGlobalLock = (bool) $input->getOption('ignore-global-lock');
$result = $this->scheduler->run($ignoreGlobalLock);
// Print Executed
if (empty($result->executed)) {
$output->writeln('No tasks executed.');
} else {
foreach ($result->executed as $item) {
$output->writeln(sprintf('<info>Executed:</info> %s (%.4fs)', $item['name'], $item['duration']));
}
}
// Print Failed
foreach ($result->failed as $item) {
$output->writeln(sprintf('<error>Failed:</error> %s - %s', $item['name'], $item['error']));
}
// Print Skipped (verbose only)
if ($output->isVerbose()) {
foreach ($result->skipped as $item) {
$output->writeln(sprintf('<comment>Skipped:</comment> %s - %s', $item['name'], $item['reason']));
}
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Console\Commands;
use Symfony\Component\Console\Command\Command;
abstract class AcmeShopCommand extends Command
{
public function __construct()
{
parent::__construct();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Console\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class VersionCommand extends AcmeShopCommand
{
protected static $defaultName = 'version';
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('AcmeShop Version: ' . module_version());
return Command::SUCCESS;
}
}