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 f329bfa9d9
585 changed files with 65605 additions and 0 deletions

12
backend/src/.env.example Executable file
View File

@@ -0,0 +1,12 @@
APP_DEBUG=true
PULSE_API_HOST=https://pulse.example.com/api/
PULSE_HEARTBEAT_SECRET=example-heartbeat-secret
MEGAPAY_CACHE_DRIVER=redis
#MEGAPAY_REDIS_HOST=redis
#MEGAPAY_REDIS_PORT=6379
#MEGAPAY_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Adapters;
class OcCartAdapter
{
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Adapters;
use ModelCatalogProduct;
use Proxy;
class OcModelCatalogProductAdapter
{
/** @var Proxy|ModelCatalogProduct */
private Proxy $model;
public function __construct($model)
{
$this->model = $model;
}
public function getProductOptions(int $productId): array
{
return $this->model->getProductOptions($productId);
}
/**
* @param int $productId
* @return array|false
*/
public function getProduct(int $productId)
{
return $this->model->getProduct($productId);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Acme\ECommerceFramework\Application;
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
use Acme\ECommerceFramework\Router\RouteServiceProvider;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
use Acme\ECommerceFramework\Scheduler\SchedulerServiceProvider;
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
use Acme\ECommerceFramework\Telegram\TelegramValidateInitDataMiddleware;
use Acme\ECommerceFramework\Validator\ValidatorServiceProvider;
class ApplicationFactory
{
public static function create(array $config): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$routes = require __DIR__ . '/routes.php';
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
CacheServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class,
TelegramServiceProvider::class,
SchedulerServiceProvider::class,
ValidatorServiceProvider::class,
AcmeShopPulseServiceProvider::class,
ImageToolServiceProvider::class,
])
->withMiddlewares([
TelegramValidateInitDataMiddleware::class,
]);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\DTO\Settings;
final class AppDTO
{
private bool $appEnabled;
private string $appName;
private ?string $appIcon;
private string $themeLight;
private string $themeDark;
private bool $appDebug;
private int $languageId;
private string $shopBaseUrl;
private bool $hapticEnabled;
public function __construct(
bool $appEnabled,
string $appName,
?string $appIcon,
string $themeLight,
string $themeDark,
bool $appDebug,
int $languageId,
string $shopBaseUrl,
bool $hapticEnabled = true
) {
$this->appEnabled = $appEnabled;
$this->appName = $appName;
$this->appIcon = $appIcon;
$this->themeLight = $themeLight;
$this->themeDark = $themeDark;
$this->appDebug = $appDebug;
$this->languageId = $languageId;
$this->shopBaseUrl = $shopBaseUrl;
$this->hapticEnabled = $hapticEnabled;
}
public function isAppEnabled(): bool
{
return $this->appEnabled;
}
public function getAppName(): string
{
return $this->appName;
}
public function getAppIcon(): ?string
{
return $this->appIcon;
}
public function getThemeLight(): string
{
return $this->themeLight;
}
public function getThemeDark(): string
{
return $this->themeDark;
}
public function isAppDebug(): bool
{
return $this->appDebug;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function getShopBaseUrl(): string
{
return $this->shopBaseUrl;
}
public function isHapticEnabled(): bool
{
return $this->hapticEnabled;
}
public function toArray(): array
{
return [
'app_enabled' => $this->isAppEnabled(),
'app_name' => $this->getAppName(),
'app_icon' => $this->getAppIcon(),
'theme_light' => $this->getThemeLight(),
'theme_dark' => $this->getThemeDark(),
'app_debug' => $this->isAppDebug(),
'language_id' => $this->getLanguageId(),
'shop_base_url' => $this->getShopBaseUrl(),
'haptic_enabled' => $this->isHapticEnabled(),
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\DTO\Settings;
final class ConfigDTO
{
private AppDTO $app;
private TelegramDTO $telegram;
private MetricsDTO $metrics;
private StoreDTO $store;
private OrdersDTO $orders;
private TextsDTO $texts;
private DatabaseDTO $database;
private LogsDTO $logs;
public function __construct(
AppDTO $app,
TelegramDTO $telegram,
MetricsDTO $metrics,
StoreDTO $store,
OrdersDTO $orders,
TextsDTO $texts,
DatabaseDTO $database,
LogsDTO $logs
) {
$this->app = $app;
$this->telegram = $telegram;
$this->metrics = $metrics;
$this->store = $store;
$this->orders = $orders;
$this->texts = $texts;
$this->database = $database;
$this->logs = $logs;
}
public function getApp(): AppDTO
{
return $this->app;
}
public function getTelegram(): TelegramDTO
{
return $this->telegram;
}
public function getMetrics(): MetricsDTO
{
return $this->metrics;
}
public function getStore(): StoreDTO
{
return $this->store;
}
public function getOrders(): OrdersDTO
{
return $this->orders;
}
public function getTexts(): TextsDTO
{
return $this->texts;
}
public function getDatabase(): DatabaseDTO
{
return $this->database;
}
public function getLogs(): LogsDTO
{
return $this->logs;
}
public function toArray(): array
{
return [
'app' => $this->app->toArray(),
'database' => $this->database->toArray(),
'logs' => $this->logs->toArray(),
'metrics' => $this->metrics->toArray(),
'orders' => $this->orders->toArray(),
'store' => $this->store->toArray(),
'telegram' => $this->telegram->toArray(),
'texts' => $this->texts->toArray(),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\DTO\Settings;
final class DatabaseDTO
{
private string $host;
private string $database;
private string $username;
private string $password;
private string $prefix;
private int $port;
public function __construct(
string $host,
string $database,
string $username,
string $password,
string $prefix,
int $port
) {
$this->host = $host;
$this->database = $database;
$this->username = $username;
$this->password = $password;
$this->prefix = $prefix;
$this->port = $port;
}
public function getHost(): string
{
return $this->host;
}
public function getDatabase(): string
{
return $this->database;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
public function getPrefix(): string
{
return $this->prefix;
}
public function getPort(): int
{
return $this->port;
}
public function toArray(): array
{
return [
'host' => $this->host,
'database' => $this->database,
'username' => $this->username,
'password' => $this->password,
'prefix' => $this->prefix,
'port' => $this->port,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\DTO\Settings;
final class LogsDTO
{
private string $path;
public function __construct(string $path)
{
$this->path = $path;
}
public function getPath(): string
{
return $this->path;
}
public function toArray(): array
{
return [
'path' => $this->path,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\DTO\Settings;
final class MetricsDTO
{
private bool $yandexMetrikaEnabled;
private string $yandexMetrikaCounter;
public function __construct(
bool $yandexMetrikaEnabled,
string $yandexMetrikaCounter
) {
$this->yandexMetrikaEnabled = $yandexMetrikaEnabled;
$this->yandexMetrikaCounter = $yandexMetrikaCounter;
}
public function isYandexMetrikaEnabled(): bool
{
return $this->yandexMetrikaEnabled;
}
public function getYandexMetrikaCounter(): string
{
return $this->yandexMetrikaCounter;
}
public function toArray(): array
{
return [
'yandex_metrika_enabled' => $this->yandexMetrikaEnabled,
'yandex_metrika_counter' => $this->yandexMetrikaCounter,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\DTO\Settings;
final class OrdersDTO
{
private int $orderDefaultStatusId;
private int $ocCustomerGroupId;
public function __construct(int $orderDefaultStatusId, int $ocCustomerGroupId)
{
$this->orderDefaultStatusId = $orderDefaultStatusId;
$this->ocCustomerGroupId = $ocCustomerGroupId;
}
public function getOrderDefaultStatusId(): int
{
return $this->orderDefaultStatusId;
}
public function getOcCustomerGroupId(): int
{
return $this->ocCustomerGroupId;
}
public function toArray(): array
{
return [
'order_default_status_id' => $this->orderDefaultStatusId,
'oc_customer_group_id' => $this->ocCustomerGroupId,
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\DTO\Settings;
final class StoreDTO
{
private bool $featureCoupons;
private bool $featureVouchers;
private bool $showCategoryProductsButton;
private string $productInteractionMode;
private ?string $managerUsername;
private string $ocDefaultCurrency;
private bool $ocConfigTax;
private int $ocStoreId;
public function __construct(
bool $featureCoupons,
bool $featureVouchers,
bool $showCategoryProductsButton,
string $productInteractionMode,
?string $managerUsername,
string $ocDefaultCurrency,
bool $ocConfigTax,
int $ocStoreId
) {
$this->featureCoupons = $featureCoupons;
$this->featureVouchers = $featureVouchers;
$this->showCategoryProductsButton = $showCategoryProductsButton;
$this->productInteractionMode = $productInteractionMode;
$this->managerUsername = $managerUsername;
$this->ocDefaultCurrency = $ocDefaultCurrency;
$this->ocConfigTax = $ocConfigTax;
$this->ocStoreId = $ocStoreId;
}
public function isFeatureCoupons(): bool
{
return $this->featureCoupons;
}
public function isFeatureVouchers(): bool
{
return $this->featureVouchers;
}
public function isShowCategoryProductsButton(): bool
{
return $this->showCategoryProductsButton;
}
public function getProductInteractionMode(): string
{
return $this->productInteractionMode;
}
public function getManagerUsername(): ?string
{
return $this->managerUsername;
}
public function getOcDefaultCurrency(): string
{
return $this->ocDefaultCurrency;
}
public function isOcConfigTax(): bool
{
return $this->ocConfigTax;
}
public function getOcStoreId(): int
{
return $this->ocStoreId;
}
public function toArray(): array
{
return [
// enable_store больше не сериализуется, так как заменен на product_interaction_mode
'feature_coupons' => $this->featureCoupons,
'feature_vouchers' => $this->featureVouchers,
'show_category_products_button' => $this->showCategoryProductsButton,
'product_interaction_mode' => $this->productInteractionMode,
'manager_username' => $this->managerUsername,
'oc_default_currency' => $this->ocDefaultCurrency,
'oc_config_tax' => $this->ocConfigTax,
'oc_store_id' => $this->ocStoreId,
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\DTO\Settings;
final class TelegramDTO
{
private string $botToken;
private ?int $chatId;
private string $ownerNotificationTemplate;
private string $customerNotificationTemplate;
private string $miniAppUrl;
public function __construct(
string $botToken,
?int $chatId,
string $ownerNotificationTemplate,
string $customerNotificationTemplate,
string $miniAppUrl
) {
$this->botToken = $botToken;
$this->chatId = $chatId;
$this->ownerNotificationTemplate = $ownerNotificationTemplate;
$this->customerNotificationTemplate = $customerNotificationTemplate;
$this->miniAppUrl = $miniAppUrl;
}
public function getBotToken(): string
{
return $this->botToken;
}
public function getChatId(): ?int
{
return $this->chatId;
}
public function getOwnerNotificationTemplate(): string
{
return $this->ownerNotificationTemplate;
}
public function getCustomerNotificationTemplate(): string
{
return $this->customerNotificationTemplate;
}
public function getMiniAppUrl(): string
{
return $this->miniAppUrl;
}
public function toArray(): array
{
return [
'bot_token' => $this->botToken,
'chat_id' => $this->chatId,
'owner_notification_template' => $this->ownerNotificationTemplate,
'customer_notification_template' => $this->customerNotificationTemplate,
'mini_app_url' => $this->miniAppUrl,
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\DTO\Settings;
final class TextsDTO
{
private string $textNoMoreProducts;
private string $textEmptyCart;
private string $textOrderCreatedSuccess;
private string $textManagerButton;
public function __construct(
string $textNoMoreProducts,
string $textEmptyCart,
string $textOrderCreatedSuccess,
string $textManagerButton
) {
$this->textNoMoreProducts = $textNoMoreProducts;
$this->textEmptyCart = $textEmptyCart;
$this->textOrderCreatedSuccess = $textOrderCreatedSuccess;
$this->textManagerButton = $textManagerButton;
}
public function getTextNoMoreProducts(): string
{
return $this->textNoMoreProducts;
}
public function getTextEmptyCart(): string
{
return $this->textEmptyCart;
}
public function getTextOrderCreatedSuccess(): string
{
return $this->textOrderCreatedSuccess;
}
public function getTextManagerButton(): string
{
return $this->textManagerButton;
}
public function toArray(): array
{
return [
'text_no_more_products' => $this->textNoMoreProducts,
'text_empty_cart' => $this->textEmptyCart,
'text_order_created_success' => $this->textOrderCreatedSuccess,
'text_manager_button' => $this->textManagerButton,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Exceptions;
use Acme\ECommerceFramework\Contracts\ExceptionHandlerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
use Psr\Log\LoggerInterface;
use Throwable;
class CustomExceptionHandler implements ExceptionHandlerInterface
{
public function respond(Throwable $exception): ?JsonResponse
{
if ($exception instanceof TelegramInvalidSignatureException) {
return new JsonResponse([
'error' => 'Invalid Signature',
'code' => 'NO_INIT_DATA',
], Response::HTTP_BAD_REQUEST);
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Exceptions;
use Acme\ECommerceFramework\Validator\ErrorBag;
use RuntimeException;
use Throwable;
class OrderValidationFailedException extends RuntimeException
{
private ErrorBag $errorBag;
public function __construct(
ErrorBag $errorBag,
string $message = 'Validation failed',
int $code = 422,
Throwable $previous = null
) {
$this->errorBag = $errorBag;
parent::__construct($message, $code, $previous);
}
public function getErrorBag(): ErrorBag
{
return $this->errorBag;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
/**
* Исключение, выбрасываемое когда Telegram-кастомер не найден
*
* @package App\Exceptions
*/
class TelegramCustomerNotFoundException extends RuntimeException
{
public function __construct(int $customerId, ?\Throwable $previous = null)
{
parent::__construct(
"Telegram customer with record ID {$customerId} not found",
404,
$previous
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
/**
* Исключение, выбрасываемое когда пользователь не разрешил писать ему в PM
*
* @package App\Exceptions
*/
class TelegramCustomerWriteNotAllowedException extends RuntimeException
{
public function __construct(int $telegramUserId, ?\Throwable $previous = null)
{
parent::__construct(
"User {$telegramUserId} has not allowed writing to PM",
400,
$previous
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Filters;
use InvalidArgumentException;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
class ProductAttribute extends BaseRule
{
public const NAME = 'RULE_PRODUCT_ATTRIBUTE';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_attribute' => new Criterion(static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE, [
'attribute_id' => null,
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'keyword' => '',
'language_id' => config('language_id'),
]),
]);
}
public function apply(Builder $builder, $operand): void
{
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE) {
$facetHash = md5(serialize($criterion));
$joinAlias = 'product_attributes_facet_' . $facetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = static::$stringCompareOperators[$criterion->params['operator']];
$languageId = $criterion->params['language_id'] ?? null;
if (! $languageId) {
throw new InvalidArgumentException('language_id is required for the product attribute filter');
}
$builder->leftJoin(
db_table('product_attribute') . " AS $joinAlias",
function (JoinClause $join) use ($criterion, $joinAlias, $operator, $languageId) {
$join->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.attribute_id", '=', $criterion->params['attribute_id'])
->where("$joinAlias.language_id", '=', $languageId);
if ($operator !== 'is_empty' && $operator !== 'is_not_empty') {
$this->criterionStringCondition(
$join,
$criterion,
"$joinAlias.text",
'and'
);
}
}
);
if ($operator === 'is_empty') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNotNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filters;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
class ProductCategories extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORIES';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_category_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORIES) {
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = $criterion->params['operator'];
$categoryIds = $criterion->params['value'];
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryIds) {
$join->on('products.product_id', '=', "$joinAlias.product_id");
if ($categoryIds) {
$join->whereIn("$joinAlias.category_id", $categoryIds);
}
},
'left'
);
if ($operator === 'contains' && ! $categoryIds) {
$builder->whereNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains' && ! $categoryIds) {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filters;
use InvalidArgumentException;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
class ProductCategory extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORY';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_category_id' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => null,
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORY) {
$operator = $criterion->params['operator'];
$categoryId = $criterion->params['value'];
if (! $categoryId) {
return;
}
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryId) {
$join
->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.category_id", '=', $categoryId);
},
'left'
);
if ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
throw new InvalidArgumentException('Invalid operator: ' . $operator);
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
class ProductManufacturer extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MANUFACTURER';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_manufacturer_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MANUFACTURER, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MANUFACTURER) {
$operator = $criterion->params['operator'];
$ids = $criterion->params['value'];
if ($ids) {
$builder->whereIn(
'products.manufacturer_id',
$ids,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->where(
'products.manufacturer_id',
'=',
0,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
class ProductModel extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MODEL';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_model' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MODEL) {
$operator = $criterion->params['operator'];
$models = $criterion->params['value'] ?? [];
if ($models) {
$builder->whereIn(
'products.model',
$models,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->whereRaw('TRUE = FALSE');
}
}
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use InvalidArgumentException;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
use Acme\ECommerceFramework\QueryBuilder\Table;
use Acme\ECommerceFramework\Support\Arr;
use RuntimeException;
class ProductPrice extends BaseRule
{
public const NAME = 'RULE_PRODUCT_PRICE';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_price' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [
'from' => 0,
'to' => null,
],
]),
'include_discounts' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
'include_specials' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
]);
}
/**
* @return void
*/
public function apply(Builder $builder, $operand)
{
$includeSpecials = $this->criteria['include_specials']->params['value'] ?? true;
/** @var Criterion|null $productPriceCriterion */
$productPriceCriterion = $this->criteria['product_price'] ?? null;
if (! $productPriceCriterion) {
throw new RuntimeException('Invalid product price rule format. Criterion is not found. Check filter JSON.');
}
if (! isset(static::$numberCompareOperators[$productPriceCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productPriceCriterion->params['operator']);
}
$column = 'products.price';
if ($includeSpecials) {
$specialsFacetHash = md5(serialize($productPriceCriterion) . 'specials');
$joinAlias = 'product_specials_' . $specialsFacetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$customerGroupId = config('oc_customer_group_id', 1);
$sub2 = $builder->newQuery()
->select([
'product_id',
new RawExpression("MIN(CONCAT(LPAD(priority, 5, '0'), LPAD(price, 10, '0'))) AS sort_key"),
])
->from(db_table('product_special'), 'ps')
->where("ps.customer_group_id", '=', $customerGroupId)
->whereRaw(
"
(ps.date_start = '0000-00-00' OR ps.date_start < NOW())
AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())
"
)
->groupBy(['product_id']);
$sub = $builder->newQuery()
->select([
'ps1.product_id',
'ps1.price',
])
->from(db_table('product_special'), 'ps1')
->join(new Table($sub2, 'ps2'), function (JoinClause $join) {
$join->on('ps1.product_id', '=', 'ps2.product_id')
->whereRaw("CONCAT(LPAD(ps1.priority, 5, '0'), LPAD(ps1.price, 10, '0')) = ps2.sort_key");
});
$builder->join(new Table($sub, $joinAlias), function (JoinClause $join) use ($joinAlias) {
$join->on('products.product_id', '=', "$joinAlias.product_id");
}, Builder::JOIN_TYPE_LEFT);
$column = new RawExpression("COALESCE($joinAlias.price, products.price)");
}
$numberOperator = static::$numberCompareOperators[$productPriceCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productPriceCriterion->params['value']
);
if ($numberOperator === 'BETWEEN') {
[$min, $max] = $value; // $min = левая, $max = правая граница
// если обе границы не указаны — фильтр игнорируем
if ($min === null && $max === null) {
return;
}
// если только правая граница — "меньше или равно"
if ($min === null && $max !== null) {
$builder->where($column, '<=', $max, $operand);
return;
}
// если только левая граница — "больше или равно"
if ($min !== null && $max === null) {
$builder->where($column, '>=', $min, $operand);
return;
}
// левая и правая граница равны
if ($min !== null && $max !== null && $min === $max) {
$builder->where($column, '=', $min, $operand);
return;
}
// если обе границы есть — классический between (min ≤ x ≤ max)
if ($min !== null && $max !== null) {
$builder->whereBetween($column, [$min, $max], $operand);
}
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
$from = null;
$to = null;
if (is_numeric($value['from'])) {
$from = (int) $value['from'];
}
if (is_numeric($value['to'])) {
$to = (int) $value['to'];
}
return [$from, $to];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use InvalidArgumentException;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Exceptions\CriteriaBuilderException;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use RuntimeException;
class ProductQuantity extends BaseRule
{
public const NAME = 'RULE_PRODUCT_QUANTITY';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_quantity' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [0, null],
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion|null $productQuantityCriterion */
$productQuantityCriterion = $this->criteria['product_quantity'] ?? null;
if (! $productQuantityCriterion) {
throw new RuntimeException('Product Quantity rule criterion is not found.');
}
$column = 'products.quantity';
if (! isset(static::$numberCompareOperators[$productQuantityCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productQuantityCriterion->params['operator']);
}
$numberOperator = static::$numberCompareOperators[$productQuantityCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productQuantityCriterion->params['value'],
);
if ($numberOperator === 'BETWEEN') {
$builder->whereBetween($column, $value, $operand);
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
if (
(isset($value[0]) && ! is_numeric($value[0]))
|| ($numberOperator === 'BETWEEN' && ! $value[1])
) {
throw new CriteriaBuilderException('Value is required.');
}
return array_map('intval', $value);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
use Acme\ECommerceFramework\QueryBuilder\Builder;
class ProductStatus extends BaseRule
{
public const NAME = 'RULE_PRODUCT_STATUS';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_status' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'operator' => static::CRITERIA_OPERATOR_EQUALS,
'value' => true,
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_BOOLEAN) {
$value = $criterion->params['value'];
$builder->where('products.status', '=', $value, $operand);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Handlers;
use App\Services\BlocksService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
class BlocksHandler
{
private BlocksService $blocksService;
public function __construct(BlocksService $blocksService)
{
$this->blocksService = $blocksService;
}
public function processBlock(Request $request): JsonResponse
{
$block = $request->json();
$data = $this->blocksService->process($block);
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Handlers;
use App\Services\CartService;
use Cart\Cart;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
class CartHandler
{
private Cart $cart;
private CartService $cartService;
public function __construct(Cart $cart, CartService $cartService)
{
$this->cart = $cart;
$this->cartService = $cartService;
}
public function index(): JsonResponse
{
$items = $this->cartService->getCart();
return new JsonResponse([
'data' => $items,
]);
}
public function checkout(Request $request): JsonResponse
{
$items = $request->json();
foreach ($items as $item) {
$options = [];
foreach ($item['options'] as $option) {
if (! empty($option['value']) && ! empty($option['value']['product_option_value_id'])) {
$options[$option['product_option_id']] = $option['value']['product_option_value_id'];
}
}
$this->cart->add(
$item['productId'],
$item['quantity'],
$options,
);
}
return new JsonResponse([
'data' => $items,
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\SettingsService;
use App\Support\Utils;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\QueryBuilder\Table;
use Acme\ECommerceFramework\Support\Str;
use Symfony\Component\HttpFoundation\JsonResponse;
class CategoriesHandler
{
private const THUMB_SIZE = 150;
private Builder $queryBuilder;
private ImageFactory $image;
private SettingsService $settings;
private CacheInterface $cache;
public function __construct(
Builder $queryBuilder,
ImageFactory $ocImageTool,
SettingsService $settings,
CacheInterface $cache
) {
$this->queryBuilder = $queryBuilder;
$this->image = $ocImageTool;
$this->settings = $settings;
$this->cache = $cache;
}
public function index(Request $request): JsonResponse
{
$cacheKey = 'categories.index';
$categories = $this->cache->get($cacheKey);
if ($categories === null) {
$languageId = $this->settings->config()->getApp()->getLanguageId();
$storeId = $this->settings->get('store.oc_store_id', 0);
$perPage = $request->get('perPage', 100);
$categoriesFlat = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'categories.image' => 'image',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->join(
new Table(db_table('category_to_store'), 'category_to_store'),
function (JoinClause $join) use ($storeId) {
$join->on('category_to_store.category_id', '=', 'categories.category_id')
->where('category_to_store.store_id', '=', $storeId);
}
)
->where('categories.status', '=', 1)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
$categories = $this->buildCategoryTree($categoriesFlat);
$categories = array_slice($categories, 0, $perPage);
$this->cache->set($cacheKey, $categories, 60 * 60 * 24);
}
return new JsonResponse([
'data' => array_map(static function ($category) {
return [
'id' => (int) $category['id'],
'image' => $category['image'] ?? '',
'name' => Str::htmlEntityEncode($category['name']),
'description' => $category['description'],
'children' => $category['children'],
];
}, $categories),
]);
}
public function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = [];
foreach ($flat as $category) {
if ((int) $category['parent_id'] === (int) $parentId) {
$children = $this->buildCategoryTree($flat, $category['id']);
if ($children) {
$category['children'] = $children;
}
$image = $this->image
->make($category['image'])
->resize(self::THUMB_SIZE, self::THUMB_SIZE)
->url();
$branch[] = [
'id' => (int) $category['id'],
'image' => $image,
'name' => Utils::htmlEntityEncode($category['name']),
'description' => $category['description'],
'children' => $category['children'] ?? [],
];
}
}
return $branch;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Handlers;
use Acme\ECommerceFramework\Config\Settings;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\Scheduler\SchedulerService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class CronHandler
{
private Settings $settings;
private SchedulerService $schedulerService;
public function __construct(Settings $settings, SchedulerService $schedulerService)
{
$this->settings = $settings;
$this->schedulerService = $schedulerService;
}
/**
* Запуск планировщика по HTTP (для cron-job.org и аналогов).
* Требует api_key в query, совпадающий с настройкой cron.api_key.
*/
public function runSchedule(Request $request): JsonResponse
{
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'cron_job_org') {
return new JsonResponse(['error' => 'Scheduler is not in cron-job.org mode'], Response::HTTP_FORBIDDEN);
}
$apiKey = $request->get('api_key', '');
$expectedKey = $this->settings->get('cron.api_key', '');
if ($expectedKey === '' || $apiKey === '' || !hash_equals($expectedKey, $apiKey)) {
return new JsonResponse(['error' => 'Invalid or missing API key'], Response::HTTP_UNAUTHORIZED);
}
// Увеличиваем лимит времени выполнения при запуске по HTTP, чтобы снизить риск timeout
$limit = 300; // 5 минут
if (function_exists('set_time_limit')) {
@set_time_limit($limit);
}
if (function_exists('ini_set')) {
@ini_set('max_execution_time', (string) $limit);
}
$result = $this->schedulerService->run(true);
$data = [
'success' => true,
'executed' => count($result->executed),
'failed' => count($result->failed),
'skipped' => count($result->skipped),
];
return new JsonResponse($data);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Handlers;
use Carbon\Carbon;
use Acme\ECommerceFramework\Config\Settings;
use Acme\ECommerceFramework\Exceptions\InvalidApiTokenException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
use Acme\ECommerceFramework\Support\DateUtils;
use Psr\Log\LoggerInterface;
class ETLHandler
{
private Builder $builder;
private Settings $settings;
private LoggerInterface $logger;
public function __construct(Builder $builder, Settings $settings, LoggerInterface $logger)
{
$this->builder = $builder;
$this->settings = $settings;
$this->logger = $logger;
}
private function getLastUpdatedAtSql(): string
{
return '
GREATEST(
COALESCE((
SELECT MAX(date_modified)
FROM oc_order as o
where o.customer_id = acmeshop_customers.oc_customer_id
), 0),
acmeshop_customers.updated_at
)
';
}
private function getCustomerQuery(?Carbon $updatedAt = null): Builder
{
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
return $this->builder->newQuery()
->from('acmeshop_customers')
->where('allows_write_to_pm', '=', 1)
->when($updatedAt !== null, function (Builder $builder) use ($lastUpdatedAtSql, $updatedAt) {
$builder->where(new RawExpression($lastUpdatedAtSql), '>=', $updatedAt);
});
}
/**
* @throws InvalidApiTokenException
*/
public function getCustomersMeta(Request $request): JsonResponse
{
$this->validateApiKey($request);
$updatedAt = $request->get('updated_at');
if ($updatedAt) {
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
}
$query = $this->getCustomerQuery($updatedAt);
$total = $query->count();
return new JsonResponse([
'data' => [
'total' => $total,
],
]);
}
/**
* @throws InvalidApiTokenException
*/
public function customers(Request $request): JsonResponse
{
$this->validateApiKey($request);
$this->logger->debug('Get customers for ETL');
$page = (int)$request->get('page', 1);
$perPage = (int)$request->get('perPage', 10000);
$successOrderStatusIds = '5,3';
$updatedAt = $request->get('updated_at');
if ($updatedAt) {
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
}
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
$query = $this->getCustomerQuery($updatedAt);
$query->orderBy('telegram_user_id');
$query->forPage($page, $perPage);
$query
->select([
'tracking_id',
'username',
'photo_url',
'telegram_user_id' => 'tg_user_id',
'acmeshop_customers.oc_customer_id',
'is_premium',
'last_seen_at',
'orders_count' => 'orders_count_total',
'created_at' => 'registered_at',
new RawExpression(
'(
SELECT MIN(date_added)
FROM oc_order
WHERE oc_order.customer_id = acmeshop_customers.oc_customer_id
) AS first_order_date'
),
new RawExpression(
'(
SELECT MAX(date_added)
FROM oc_order
WHERE oc_order.customer_id = acmeshop_customers.oc_customer_id
) AS last_order_date'
),
new RawExpression(
"COALESCE((
SELECT
SUM(total)
FROM
oc_order
WHERE
oc_order.customer_id = acmeshop_customers.oc_customer_id
AND oc_order.order_status_id IN ($successOrderStatusIds)
), 0) AS total_spent"
),
new RawExpression(
"COALESCE((
SELECT
COUNT(*)
FROM
oc_order
WHERE
oc_order.customer_id = acmeshop_customers.oc_customer_id
AND oc_order.order_status_id IN ($successOrderStatusIds)
), 0) AS orders_count_success"
),
new RawExpression("$lastUpdatedAtSql AS updated_at"),
]);
$items = $query->get();
return new JsonResponse([
'data' => array_map(static function ($item) {
return [
'tracking_id' => $item['tracking_id'],
'username' => $item['username'],
'photo_url' => $item['photo_url'],
'tg_user_id' => filter_var($item['tg_user_id'], FILTER_VALIDATE_INT),
'oc_customer_id' => filter_var($item['oc_customer_id'], FILTER_VALIDATE_INT),
'is_premium' => filter_var($item['is_premium'], FILTER_VALIDATE_BOOLEAN),
'last_seen_at' => DateUtils::toUTC($item['last_seen_at']),
'orders_count_total' => filter_var($item['orders_count_total'], FILTER_VALIDATE_INT),
'registered_at' => DateUtils::toUTC($item['registered_at']),
'first_order_date' => DateUtils::toUTC($item['first_order_date']),
'last_order_date' => DateUtils::toUTC($item['last_order_date']),
'total_spent' => (float)$item['total_spent'],
'orders_count_success' => filter_var($item['orders_count_success'], FILTER_VALIDATE_INT),
'updated_at' => DateUtils::toUTC($item['updated_at']),
];
}, $items),
]);
}
/**
* @throws InvalidApiTokenException
*/
private function validateApiKey(Request $request): void
{
$token = $request->getApiKey();
if (empty($token)) {
throw new InvalidApiTokenException('Invalid API Key.');
}
if (strcmp($token, $this->settings->get('pulse.api_key')) !== 0) {
throw new InvalidApiTokenException('Invalid API Key');
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Handlers;
use App\Filters\ProductCategory;
use App\Filters\ProductPrice;
use Symfony\Component\HttpFoundation\JsonResponse;
class FiltersHandler
{
public function getFiltersForMainPage(): JsonResponse
{
$filters = [
'operand' => 'AND',
'rules' => [
ProductPrice::NAME => [
'criteria' => [
'product_price' => [
'type' => 'number',
'params' => [
'operator' => 'between',
'value' => [
'from' => 0,
'to' => null,
],
],
],
],
],
ProductCategory::NAME => [
'criteria' => [
'product_category_id' => [
'type' => 'product_category',
'params' => [
'operator' => 'contains',
'value' => null,
],
],
]
],
],
];
return new JsonResponse([
'data' => $filters,
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Handlers;
use JsonException;
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getForm(Request $request): JsonResponse
{
$alias = $request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('acmeshop_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'schema' => $schema,
],
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class HealthCheckHandler
{
public function handle(): JsonResponse
{
return new JsonResponse([
'status' => 'ok',
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Handlers;
use App\Exceptions\OrderValidationFailedException;
use App\Services\OrderCreateService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class OrderHandler
{
private OrderCreateService $orderCreateService;
public function __construct(OrderCreateService $orderCreateService)
{
$this->orderCreateService = $orderCreateService;
}
public function store(Request $request): JsonResponse
{
try {
$order = $this->orderCreateService->create($request->json(), [
'ip' => $request->getClientIp(),
'user_agent' => $request->getUserAgent(),
]);
return new JsonResponse([
'data' => $order,
], Response::HTTP_CREATED);
} catch (OrderValidationFailedException $exception) {
return new JsonResponse([
'data' => $exception->getErrorBag()->firstOfAll(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Handlers;
use App\Models\TelegramCustomer;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\Enums\TelegramHeader;
use Acme\ECommerceFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
class PrivacyPolicyHandler
{
private TelegramService $telegramService;
private TelegramCustomer $telegramCustomer;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
TelegramCustomer $telegramCustomer,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->telegramCustomer = $telegramCustomer;
$this->logger = $logger;
}
public function checkIsUserPrivacyConsented(Request $request): JsonResponse
{
$isPrivacyConsented = false;
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
if (! $telegramUserId) {
return new JsonResponse([
'data' => [
'is_privacy_consented' => false,
],
]);
}
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if ($customer) {
$isPrivacyConsented = Arr::get($customer, 'privacy_consented_at') !== null;
}
return new JsonResponse([
'data' => [
'is_privacy_consented' => $isPrivacyConsented,
],
]);
}
public function userPrivacyConsent(Request $request): JsonResponse
{
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
if ($telegramUserId) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
'privacy_consented_at' => Carbon::now()->toDateTimeString(),
]);
} else {
$this->logger->warning(
'Could not find customer with telegram user_id: ' . $telegramUserId . ' to give privacy consent.'
);
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\ProductsService;
use App\Services\SettingsService;
use Exception;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
use Acme\ECommerceFramework\Http\Request;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class ProductsHandler
{
private SettingsService $settings;
private ProductsService $productsService;
private LoggerInterface $logger;
private CacheInterface $cache;
public function __construct(
SettingsService $settings,
ProductsService $productsService,
LoggerInterface $logger,
CacheInterface $cache
) {
$this->settings = $settings;
$this->productsService = $productsService;
$this->logger = $logger;
$this->cache = $cache;
}
public function index(Request $request): JsonResponse
{
$page = (int) $request->json('page', 1);
$perPage = min((int) $request->json('perPage', 20), 20);
$maxPages = (int) $request->json('maxPages', 10);
$search = trim($request->json('search', ''));
$filters = $request->json('filters');
$storeId = $this->settings->get('store.oc_store_id', 0);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$response = $this->productsService->getProductsResponse(
compact('page', 'perPage', 'search', 'filters', 'maxPages'),
$languageId,
$storeId,
);
return new JsonResponse($response);
}
public function show(Request $request): JsonResponse
{
$productId = (int) $request->get('id');
try {
$product = $this->productsService->getProductById($productId);
} catch (EntityNotFoundException $exception) {
return new JsonResponse([
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new RuntimeException('Error get product with id ' . $productId, 500);
}
return new JsonResponse([
'data' => $product,
]);
}
public function getProductImages(Request $request): JsonResponse
{
$productId = (int) $request->get('id');
try {
$images = $this->productsService->getProductImages($productId);
} catch (EntityNotFoundException $exception) {
return new JsonResponse([
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->error('Could not load images for product ' . $productId, ['exception' => $exception]);
$images = [];
}
return new JsonResponse([
'data' => $images,
]);
}
public function getSearchPlaceholder(Request $request): JsonResponse
{
$storeId = $this->settings->get('store.oc_store_id', 0);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$cacheKey = "products.search_placeholder.{$storeId}.{$languageId}";
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return new JsonResponse($cached);
}
$response = $this->productsService->getProductsResponse(
[
'page' => 1,
'perPage' => 3,
'search' => '',
'filters' => [],
'maxPages' => 1,
],
$languageId,
$storeId,
);
// Кешируем на 24 часа
$this->cache->set($cacheKey, $response, 60 * 60 * 24);
return new JsonResponse($response);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Acme\ECommerceFramework\Telegram\TelegramService;
use Symfony\Component\HttpFoundation\JsonResponse;
class SettingsHandler
{
private SettingsService $settings;
private ImageFactory $image;
private TelegramService $telegramService;
public function __construct(
SettingsService $settings,
ImageFactory $image,
TelegramService $telegramService
) {
$this->settings = $settings;
$this->image = $image;
$this->telegramService = $telegramService;
}
public function index(): JsonResponse
{
$appConfig = $this->settings->config()->getApp();
$appIcon = $appConfig->getAppIcon();
$hash = $this->settings->getHash();
if ($appIcon) {
$appIcon = $this->image->make($appIcon)->resize(null, 64)->url('png') . '?_v=' . $hash;
}
return new JsonResponse([
'app_name' => $appConfig->getAppName(),
'app_debug' => $appConfig->isAppDebug(),
'app_icon' => $appIcon,
'theme_light' => $appConfig->getThemeLight(),
'theme_dark' => $appConfig->getThemeDark(),
'ya_metrika_enabled' => $this->settings->config()->getMetrics()->isYandexMetrikaEnabled(),
'app_enabled' => $appConfig->isAppEnabled(),
'product_interaction_mode' => $this->settings->config()->getStore()->getProductInteractionMode(),
'manager_username' => $this->settings->config()->getStore()->getManagerUsername(),
'feature_coupons' => $this->settings->config()->getStore()->isFeatureCoupons(),
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
'show_category_products_button' => $this->settings->config()->getStore()->isShowCategoryProductsButton(),
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
'texts' => $this->settings->config()->getTexts()->toArray(),
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
'privacy_policy_link' => $this->settings->get('app.privacy_policy_link'),
'image_aspect_ratio' => $this->settings->get('app.image_aspect_ratio', '1:1'),
'haptic_enabled' => $appConfig->isHapticEnabled(),
]);
}
public function testTgMessage(Request $request): JsonResponse
{
$template = $request->json('template', 'Нет шаблона');
$token = $request->json('token');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@ecommerce.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
'{address}' => 'г. Москва',
'{total}' => 100000,
'{ip}' => '127.0.0.1',
'{created_at}' => date('Y-m-d H:i:s'),
];
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);
} catch (Exception $e) {
return new JsonResponse([
'message' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\MegapayCustomerService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\AcmeShopPulse\TrackingIdGenerator;
use Acme\ECommerceFramework\Telegram\Enums\TelegramHeader;
use Acme\ECommerceFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
use Acme\ECommerceFramework\Telegram\TelegramInitDataDecoder;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
class TelegramCustomerHandler
{
private MegapayCustomerService $telegramCustomerService;
private LoggerInterface $logger;
private TelegramInitDataDecoder $initDataDecoder;
public function __construct(
MegapayCustomerService $telegramCustomerService,
LoggerInterface $logger,
TelegramInitDataDecoder $initDataDecoder
) {
$this->telegramCustomerService = $telegramCustomerService;
$this->logger = $logger;
$this->initDataDecoder = $initDataDecoder;
}
/**
* Сохранить или обновить Telegram-пользователя
*
* @param Request $request HTTP запрос с данными пользователя
* @return JsonResponse JSON ответ с результатом операции
*/
public function saveOrUpdate(Request $request): JsonResponse
{
try {
$customer = $this->telegramCustomerService->saveOrUpdate(
$this->extractTelegramUserData($request)
);
return new JsonResponse([
'data' => [
'tracking_id' => Arr::get($customer, 'tracking_id'),
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
$this->logger->error('Could not save telegram customer data', ['exception' => $e]);
return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Получить данные текущего пользователя
*
* @param Request $request HTTP запрос
* @return JsonResponse JSON ответ с данными пользователя
*/
public function getCurrent(Request $request): JsonResponse
{
try {
$telegramUserData = $this->extractUserDataFromInitData($request);
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
$customer = $this->telegramCustomerService->getByTelegramUserId($telegramUserId);
if (!$customer) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
return new JsonResponse([
'data' => [
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
$this->logger->error('Could not get current telegram customer data', ['exception' => $e]);
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
}
/**
* Извлечь данные Telegram пользователя из запроса
*
* @param Request $request HTTP запрос
* @return array Данные пользователя
* @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request
*/
private function extractTelegramUserData(Request $request): array
{
$telegramUserData = $request->json('user');
if (! $telegramUserData) {
$telegramUserData = $this->extractUserDataFromInitData($request);
}
return $telegramUserData;
}
/**
* @throws DecodeTelegramInitDataException
*/
private function extractUserDataFromInitData(Request $request): array
{
$raw = $request->header(TelegramHeader::INIT_DATA);
if (! $raw) {
throw new RuntimeException('No init data found in http request header');
}
$initData = $this->initDataDecoder->decode($raw);
return Arr::get($initData, 'user');
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Handlers;
use GuzzleHttp\Exception\GuzzleException;
use Mockery\Exception;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Container\Container;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Psr\Log\LoggerInterface;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\Contracts\TelegramCommandInterface;
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramCommandNotFoundException;
use Acme\ECommerceFramework\Telegram\TelegramBotStateManager;
use Acme\ECommerceFramework\Telegram\TelegramCommandsRegistry;
use Acme\ECommerceFramework\Telegram\TelegramService;
class TelegramHandler
{
private CacheInterface $cache;
private TelegramCommandsRegistry $telegramCommandsRegistry;
private Container $container;
private TelegramBotStateManager $telegramBotStateManager;
private LoggerInterface $logger;
private TelegramService $telegramService;
public function __construct(
CacheInterface $cache,
TelegramCommandsRegistry $telegramCommandsRegistry,
Container $container,
TelegramBotStateManager $telegramBotStateManager,
LoggerInterface $logger,
TelegramService $telegramService
) {
$this->cache = $cache;
$this->telegramCommandsRegistry = $telegramCommandsRegistry;
$this->container = $container;
$this->telegramBotStateManager = $telegramBotStateManager;
$this->logger = $logger;
$this->telegramService = $telegramService;
}
/**
* @throws GuzzleException
* @throws \JsonException
*/
public function webhook(Request $request): JsonResponse
{
$this->logger->debug('Webhook received');
$update = $request->json();
$message = $update['message'] ?? null;
if (! $message) {
return new JsonResponse([]);
}
$userId = $update['message']['from']['id'];
$chatId = $update['message']['chat']['id'];
try {
$message = Arr::get($update, 'message', []);
$this->cache->set('tg_latest_msg', $message, 60);
$text = Arr::get($message, 'text', '');
// command starts from "/"
if (strpos($text, '/') === 0) {
$this->telegramBotStateManager->clearState($userId, $chatId);
$command = substr($text, 1);
$handler = $this->telegramCommandsRegistry->resolve($command);
/** @var TelegramCommandInterface $concrete */
$concrete = $this->container->get($handler);
$concrete->handle($update);
return new JsonResponse([]);
}
// Continue state
$hasState = $this->telegramBotStateManager->hasState($userId, $chatId);
if ($hasState) {
$handler = $this->telegramBotStateManager->getCurrentStateCommandHandler($userId, $chatId);
/** @var TelegramCommandInterface $concrete */
$concrete = $this->container->get($handler);
$concrete->handle($update);
return new JsonResponse([]);
}
} catch (TelegramCommandNotFoundException $exception) {
$this->telegramService->sendMessage($chatId, 'Неверная команда');
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return new JsonResponse([]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\AcmeShopPulse\PulseIngestException;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseService;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class TelemetryHandler
{
private AcmeShopPulseService $megaPayPulseService;
private LoggerInterface $logger;
public function __construct(
AcmeShopPulseService $megaPayPulseService,
LoggerInterface $logger
) {
$this->megaPayPulseService = $megaPayPulseService;
$this->logger = $logger;
}
/**
* @throws PulseIngestException
*/
public function ingest(Request $request): JsonResponse
{
$this->megaPayPulseService->handleIngest($request->json());
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
public function heartbeat(): JsonResponse
{
try {
$this->megaPayPulseService->handleHeartbeat();
} catch (Throwable $e) {
$this->logger->warning('AcmeShop Pulse Heartbeat failed: ' . $e->getMessage(), ['exception' => $e]);
}
return new JsonResponse(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Acme\ECommerceFramework\AcmeShopPulse\TrackingIdGenerator;
use RuntimeException;
class TelegramCustomer
{
private const TABLE_NAME = 'acmeshop_customers';
private ConnectionInterface $database;
private Builder $builder;
public function __construct(ConnectionInterface $database, Builder $builder)
{
$this->database = $database;
$this->builder = $builder;
}
/**
* Найти запись по ID
*
* @param int $id ID записи
* @return array|null Данные пользователя или null если не найдено
*/
public function findById(int $id): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('id', '=', $id)
->firstOrNull();
}
/**
* Найти запись по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @return array|null Данные пользователя или null если не найдено
*/
public function findByTelegramUserId(int $telegramUserId): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('telegram_user_id', '=', $telegramUserId)
->firstOrNull();
}
/**
* Найти запись по oc_customer_id
*
* @param int $customerId ID покупателя в ECommerce
* @return array|null Данные пользователя или null если не найдено
*/
public function findByCustomerId(int $customerId): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('oc_customer_id', '=', $customerId)
->firstOrNull();
}
/**
* Создать новую запись
*
* @param array $data Данные для создания записи
* @return int ID созданной записи
* @throws RuntimeException Если не удалось создать запись
*/
public function create(array $data): int
{
$data['created_at'] = Carbon::now()->toDateTimeString();
$data['updated_at'] = Carbon::now()->toDateTimeString();
$data['tracking_id'] = TrackingIdGenerator::generate();
$success = $this->database->insert(self::TABLE_NAME, $data);
if (! $success) {
$error = $this->database->getLastError();
$errorMessage = $error ? $error[1] : 'Unknown error';
throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}");
}
return $this->database->lastInsertId();
}
/**
* Обновить запись по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @param array $data Данные для обновления
* @return bool true если обновление успешно
*/
public function updateByTelegramUserId(int $telegramUserId, array $data): bool
{
$data['updated_at'] = Carbon::now()->toDateTimeString();
return $this->builder->newQuery()
->where('telegram_user_id', '=', $telegramUserId)
->update(self::TABLE_NAME, $data);
}
/**
* Обновить last_seen_at для пользователя
*
* @param int $telegramUserId Telegram user ID
* @return bool true если обновление успешно
*/
public function updateLastSeen(int $telegramUserId): bool
{
return $this->updateByTelegramUserId($telegramUserId, [
'last_seen_at' => Carbon::now()->toDateTimeString(),
]);
}
public function increase(int $id, string $field): bool
{
$now = Carbon::now()->toDateTimeString();
$table = self::TABLE_NAME;
$sql = "UPDATE `$table` SET `$field` = `$field` + 1, updated_at = '$now' WHERE id = ?";
return $this->database->statement($sql, [$id]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler;
use App\Filters\ProductAttribute;
use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductManufacturer;
use App\Filters\ProductModel;
use App\Filters\ProductPrice;
use App\Filters\ProductQuantity;
use App\Filters\ProductStatus;
use App\Telegram\LinkCommand;
use Acme\ECommerceFramework\Container\ServiceProvider;
use Acme\ECommerceFramework\Contracts\ExceptionHandlerInterface;
use Acme\ECommerceFramework\CriteriaBuilder\RulesRegistry;
use Acme\ECommerceFramework\Telegram\Commands\ChatIdCommand;
use Acme\ECommerceFramework\Telegram\Commands\StartCommand;
use Acme\ECommerceFramework\Telegram\TelegramCommandsRegistry;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(ExceptionHandlerInterface::class, function () {
return new CustomExceptionHandler();
});
$this->registerTelegramCommands();
$this->registerFacetFilters();
}
private function registerTelegramCommands(): void
{
$this->container->singleton(TelegramCommandsRegistry::class, function () {
return new TelegramCommandsRegistry();
});
$registry = $this->container->get(TelegramCommandsRegistry::class);
$registry->addCommand('id', ChatIdCommand::class, 'Возвращает ChatID текущего чата.');
$registry->addCommand('link', LinkCommand::class, 'Генератор Telegram сообщений с кнопкой');
$registry->addCommand(
'start',
StartCommand::class,
'Базовая команда Telegram бота. Присылает ссылку на открытие Megapay магазина.'
);
}
private function registerFacetFilters(): void
{
$this->container->singleton(RulesRegistry::class, function () {
return new RulesRegistry();
});
$registry = $this->container->get(RulesRegistry::class);
$registry->register([
ProductAttribute::NAME => ProductAttribute::class,
ProductCategories::NAME => ProductCategories::class,
ProductManufacturer::NAME => ProductManufacturer::class,
ProductModel::NAME => ProductModel::class,
ProductPrice::NAME => ProductPrice::class,
ProductQuantity::NAME => ProductQuantity::class,
ProductStatus::NAME => ProductStatus::class,
ProductCategory::NAME => ProductCategory::class,
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\ServiceProviders;
use App\Services\SettingsSerializerService;
use App\Services\SettingsService;
use Acme\ECommerceFramework\Container\Container;
use Acme\ECommerceFramework\Container\ServiceProvider;
class SettingsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(SettingsService::class, function (Container $container) {
return new SettingsService(
$container->getConfigValue(),
$container->get(SettingsSerializerService::class)
);
});
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use RuntimeException;
class BlocksService
{
private static array $processors = [
'slider' => [self::class, 'processSlider'],
'categories_top' => [self::class, 'processCategoriesTop'],
'products_feed' => [self::class, 'processProductsFeed'],
'products_carousel' => [self::class, 'processProductsCarousel'],
];
private ImageFactory $image;
private CacheInterface $cache;
private SettingsService $settings;
private Builder $queryBuilder;
private ProductsService $productsService;
public function __construct(
ImageFactory $image,
CacheInterface $cache,
SettingsService $settings,
Builder $queryBuilder,
ProductsService $productsService
) {
$this->image = $image;
$this->cache = $cache;
$this->settings = $settings;
$this->queryBuilder = $queryBuilder;
$this->productsService = $productsService;
}
public function process(array $block): array
{
$blockType = $block['type'];
$cacheKey = "block_{$blockType}_" . md5(serialize($block['data']));
$cacheTtlSeconds = 3600;
$data = $this->cache->get($cacheKey);
if (! $data) {
$method = self::$processors[$block['type']] ?? null;
if (! $method) {
throw new RuntimeException('Processor for block type ' . $block['type'] . ' does not exist');
}
$data = call_user_func_array($method, [$block]);
$this->cache->set($cacheKey, $data, $cacheTtlSeconds);
}
return $data;
}
private function processSlider(array $block): array
{
$slides = $block['data']['slides'];
foreach ($slides as $slideIndex => $slide) {
if (is_file(DIR_IMAGE . $slide['image'])) {
$image = $this->image->make($slide['image']);
$block['data']['slides'][$slideIndex]['image'] = $image->cover(1110, 600)->url();
}
}
return $block;
}
private function processCategoriesTop(array $block): array
{
$count = $block['data']['count'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categories = [];
if ($count > 0) {
$categories = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'descriptions.name' => 'name',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->where('categories.parent_id', '=', 0)
->orderBy('sort_order')
->orderBy('descriptions.name')
->limit($count)
->get();
$categories = array_map(static function ($category) {
$category['id'] = (int) $category['id'];
return $category;
}, $categories);
}
$block['data']['categories'] = $categories;
return $block;
}
private function processProductsFeed(array $block): array
{
return $block;
}
private function processProductsCarousel(array $block): array
{
$categoryId = $block['data']['category_id'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$params = [
'page' => 1,
'perPage' => 10,
'filters' => [
"operand" => "AND",
"rules" => [
"RULE_PRODUCT_CATEGORIES" => [
"criteria" => [
"product_category_ids" => [
"type" => "product_categories",
"params" => [
"operator" => "contains",
"value" => [$categoryId],
],
],
],
],
],
],
];
$storeId = $this->settings->get('store.store_id', 0);
$response = $this->productsService->getProductsResponse($params, $languageId, $storeId);
$block['data']['products'] = $response;
return $block;
}
}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Services;
use Cart\Cart;
use Cart\Currency;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
class CartService
{
private OcRegistryDecorator $oc;
private Cart $cart;
private Currency $currency;
public function __construct(OcRegistryDecorator $registry, Cart $cart, Currency $currency)
{
$this->oc = $registry;
$this->cart = $cart;
$this->currency = $currency;
}
public function getCart(): array
{
$this->oc->load->language('checkout/cart');
if ($this->oc->cart->hasProducts()) {
if (
! $this->oc->cart->hasStock()
&& (
! $this->oc->config->get('config_stock_checkout')
|| $this->oc->config->get('config_stock_warning')
)
) {
$data['error_warning'] = $this->oc->language->get('error_stock');
} elseif (isset($this->oc->session->data['error'])) {
$data['error_warning'] = $this->oc->session->data['error'];
unset($this->oc->session->data['error']);
} else {
$data['error_warning'] = '';
}
if ($this->oc->config->get('config_customer_price') && ! $this->oc->customer->isLogged()) {
$data['attention'] = sprintf(
$this->oc->language->get('text_login'),
$this->oc->url->link('account/login'),
$this->oc->url->link('account/register')
);
} else {
$data['attention'] = '';
}
if (isset($this->oc->session->data['success'])) {
$data['success'] = $this->oc->session->data['success'];
unset($this->oc->session->data['success']);
} else {
$data['success'] = '';
}
if ($this->oc->config->get('config_cart_weight')) {
$data['weight'] = $this->oc->weight->format(
$this->oc->cart->getWeight(),
$this->oc->config->get('config_weight_class_id'),
$this->oc->language->get('decimal_point'),
$this->oc->language->get('thousand_point')
);
} else {
$data['weight'] = '';
}
$this->oc->load->model('tool/image');
$this->oc->load->model('tool/upload');
$data['products'] = array();
$products = $this->oc->cart->getProducts();
foreach ($products as $product) {
$product_total = 0;
foreach ($products as $product_2) {
if ($product_2['product_id'] == $product['product_id']) {
$product_total += $product_2['quantity'];
}
}
if ($product['minimum'] > $product_total) {
$data['error_warning'] = sprintf(
$this->oc->language->get('error_minimum'),
$product['name'],
$product['minimum']
);
}
if ($product['image']) {
$image = $this->oc->model_tool_image->resize(
$product['image'],
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_width'),
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_height')
);
} else {
$image = '';
}
$option_data = array();
foreach ($product['option'] as $option) {
if ($option['type'] != 'file') {
$value = $option['value'];
} else {
$upload_info = $this->oc->model_tool_upload->getUploadByCode($option['value']);
if ($upload_info) {
$value = $upload_info['name'];
} else {
$value = '';
}
}
$option_data[] = [
'product_option_id' => (int) $option['product_option_id'],
'product_option_value_id' => (int) $option['product_option_value_id'],
'name' => $option['name'],
'value' => (strlen($value) > 20 ? substr($value, 0, 20) . '..' : $value),
'type' => $option['type'],
];
}
$priceNumeric = 0;
$totalNumeric = 0;
// Display prices
if ($this->oc->customer->isLogged() || ! $this->oc->config->get('config_customer_price')) {
$unit_price = $this->oc->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
);
$priceNumeric = $unit_price;
$totalNumeric = $unit_price * $product['quantity'];
$price = $this->currency->format($unit_price, $this->oc->session->data['currency']);
$total = $this->currency->format($totalNumeric, $this->oc->session->data['currency']);
} else {
$price = false;
$total = false;
}
$recurring = '';
if ($product['recurring']) {
$frequencies = array(
'day' => $this->oc->language->get('text_day'),
'week' => $this->oc->language->get('text_week'),
'semi_month' => $this->oc->language->get('text_semi_month'),
'month' => $this->oc->language->get('text_month'),
'year' => $this->oc->language->get('text_year')
);
if ($product['recurring']['trial']) {
$recurring = sprintf(
$this->oc->language->get('text_trial_description'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['trial_price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['trial_cycle'],
$frequencies[$product['recurring']['trial_frequency']],
$product['recurring']['trial_duration']
) . ' ';
}
if ($product['recurring']['duration']) {
$recurring .= sprintf(
$this->oc->language->get('text_payment_description'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['cycle'],
$frequencies[$product['recurring']['frequency']],
$product['recurring']['duration']
);
} else {
$recurring .= sprintf(
$this->oc->language->get('text_payment_cancel'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['cycle'],
$frequencies[$product['recurring']['frequency']],
$product['recurring']['duration']
);
}
}
$data['products'][] = array(
'product_id' => (int) $product['product_id'],
'cart_id' => (int) $product['cart_id'],
'thumb' => $image,
'name' => $product['name'],
'model' => $product['model'],
'option' => $option_data,
'recurring' => $recurring,
'quantity' => (int) $product['quantity'],
'stock' => $product['stock'] ? true : ! (! $this->oc->config->get(
'config_stock_checkout'
) || $this->oc->config->get('config_stock_warning')),
'reward' => ($product['reward'] ? sprintf(
$this->oc->language->get('text_points'),
$product['reward']
) : ''),
'price' => $price,
'total' => $total,
'href' => $this->oc->url->link('product/product', 'product_id=' . $product['product_id']),
'price_numeric' => $priceNumeric,
'total_numeric' => $totalNumeric,
'reward_numeric' => $product['reward'] ?? 0,
);
}
// Totals
$this->oc->load->model('setting/extension');
$totals = array();
$taxes = $this->oc->cart->getTaxes();
$total = 0;
// Because __call can not keep var references so we put them into an array.
$total_data = array(
'totals' => &$totals,
'taxes' => &$taxes,
'total' => &$total
);
$sort_order = array();
$results = $this->oc->model_setting_extension->getExtensions('total');
foreach ($results as $key => $value) {
$sort_order[$key] = $this->oc->config->get('total_' . $value['code'] . '_sort_order');
}
array_multisort($sort_order, SORT_ASC, $results);
foreach ($results as $result) {
if ($this->oc->config->get('total_' . $result['code'] . '_status')) {
$this->oc->load->model('extension/total/' . $result['code']);
// We have to put the totals in an array so that they pass by reference.
$this->oc->{'model_extension_total_' . $result['code']}->getTotal($total_data);
}
}
$sort_order = array();
foreach ($totals as $key => $value) {
$sort_order[$key] = $value['sort_order'];
}
array_multisort($sort_order, SORT_ASC, $totals);
$data['totals'] = array();
foreach ($totals as $total) {
$data['totals'][] = [
'code' => $total['code'],
'title' => $total['title'],
'value' => $total['value'],
'sort_order' => $total['sort_order'],
'text' => $this->currency->format($total['value'], $this->oc->session->data['currency']),
];
}
$lastTotal = $totals[count($totals) - 1] ?? false;
$data['total'] = $lastTotal ? $lastTotal['value'] : 0;
$data['total_text'] = $lastTotal
? $this->currency->format($lastTotal['value'], $this->oc->session->data['currency'])
: 0;
$data['total_products_count'] = $this->oc->cart->countProducts();
} else {
$data['text_error'] = $this->oc->language->get('text_empty');
$data['totals'] = [];
$data['total'] = 0;
$data['total_text'] = '';
$data['products'] = [];
$data['total_products_count'] = 0;
unset($this->oc->session->data['success']);
}
return $data;
}
public function flush(): void
{
$this->cart->clear();
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\TelegramCustomer;
use Carbon\Carbon;
use Acme\ECommerceFramework\Config\Settings;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Support\Utils;
use RuntimeException;
class MegapayCustomerService
{
private TelegramCustomer $telegramCustomer;
private Settings $settings;
public function __construct(TelegramCustomer $telegramCustomer, Settings $settings)
{
$this->telegramCustomer = $telegramCustomer;
$this->settings = $settings;
}
/**
* Сохранить или обновить Telegram-пользователя
*
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
* @return array
* @throws RuntimeException Если данные невалидны или не удалось сохранить
*/
public function saveOrUpdate(array $telegramUserData): array
{
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
$existingRecord = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if ($existingRecord) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
} else {
$this->telegramCustomer->create($telegramCustomerData);
}
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
}
/**
* Извлечь Telegram user ID из данных
*
* @param array $telegramUserData Данные пользователя
* @return int Telegram user ID
* @throws RuntimeException Если ID отсутствует или невалиден
*/
private function extractTelegramUserId(array $telegramUserData): int
{
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) {
throw new RuntimeException('Telegram user ID is required and must be positive');
}
return $telegramUserId;
}
/**
* Подготовить данные для сохранения в БД
*
* @param array $telegramUserData Исходные данные пользователя
* @param int $telegramUserId Telegram user ID
* @return array Подготовленные данные
*/
private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array
{
return [
'telegram_user_id' => $telegramUserId,
'username' => Arr::get($telegramUserData, 'username', $telegramUserId),
'first_name' => Arr::get($telegramUserData, 'first_name'),
'last_name' => Arr::get($telegramUserData, 'last_name'),
'language_code' => Arr::get($telegramUserData, 'language_code'),
'is_premium' => Utils::boolToInt(Arr::get($telegramUserData, 'is_premium', false)),
'allows_write_to_pm' => Utils::boolToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
'last_seen_at' => date('Y-m-d H:i:s'),
'store_id' => $this->settings->get('store.oc_store_id', 0),
];
}
/**
* Assign ECommerce Customer to Telegram User ID and return Megapay Customer ID if it exists.
*
* @param $telegramUserId
* @param int $ocCustomerId
* @return int|null
*/
public function assignOcCustomer($telegramUserId, int $ocCustomerId): ?int
{
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if (! $customer) {
return null;
}
if ($customer['oc_customer_id'] === null) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
'oc_customer_id' => $ocCustomerId,
'updated_at' => Carbon::now()->toDateTimeString(),
]);
}
return (int)$customer['id'];
}
public function increaseOrdersCount(int $acmeshopCustomerId): void
{
$this->telegramCustomer->increase($acmeshopCustomerId, 'orders_count');
}
/**
* Получить данные пользователя по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @return array|null Данные пользователя или null если не найдено
*/
public function getByTelegramUserId(int $telegramUserId): ?array
{
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
class OcCustomerService
{
private Builder $builder;
private ConnectionInterface $database;
public function __construct(Builder $builder, ConnectionInterface $database)
{
$this->builder = $builder;
$this->database = $database;
}
public function create(array $orderData, ?int $acmeshopCustomerId): ?int
{
$customerData = [
'customer_group_id' => $orderData['customer_group_id'],
'store_id' => $orderData['store_id'],
'language_id' => $orderData['language_id'],
'firstname' => $orderData['firstname'] ?? '',
'lastname' => $orderData['lastname'] ?? '',
'email' => $orderData['email'] ?? '',
'telephone' => $orderData['telephone'] ?? '',
'fax' => $orderData['fax'] ?? '',
'password' => bin2hex(random_bytes(16)),
'salt' => bin2hex(random_bytes(9)),
'ip' => $orderData['ip'] ?? '',
'status' => 1,
'safe' => 0,
'token' => bin2hex(random_bytes(32)),
'code' => '',
'date_added' => $orderData['date_added'],
];
$this->database->insert(db_table('customer'), $customerData);
$lastInsertId = $this->database->lastInsertId();
if ($acmeshopCustomerId) {
$this->builder
->where('id', '=', $acmeshopCustomerId)
->update('acmeshop_customers', [
'oc_customer_id' => $lastInsertId,
]);
}
return $lastInsertId;
}
public function findByMegapayCustomerId(int $telegramCustomerId): ?array
{
return $this->builder->newQuery()
->select(['oc_customers.*'])
->from(db_table('customer'), 'oc_customers')
->join('acmeshop_customers', function (JoinClause $join) {
$join->on('acmeshop_customers.oc_customer_id', '=', 'oc_customers.customer_id');
})
->where('acmeshop_customers.id', '=', $telegramCustomerId)
->firstOrNull();
}
public function findById(int $ocCustomerId): ?array
{
return $this->builder->newQuery()
->select(['oc_customers.*'])
->from(db_table('customer'), 'oc_customers')
->where('oc_customers.customer_id', '=', $ocCustomerId)
->firstOrNull();
}
public function findOrCreateByMegapayCustomerId(int $acmeshopCustomerId, array $orderData): ?array
{
$ocCustomer = $this->findByMegapayCustomerId($acmeshopCustomerId);
if (! $ocCustomer) {
$ocCustomerId = $this->create($orderData, $acmeshopCustomerId);
return $this->findById($ocCustomerId);
}
return $ocCustomer;
}
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\Carbon;
use Exception;
use JsonException;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
class OrderCreateService
{
private ConnectionInterface $database;
private CartService $cartService;
private OcRegistryDecorator $oc;
private SettingsService $settings;
private TelegramService $telegramService;
private LoggerInterface $logger;
private MegapayCustomerService $acmeshopCustomerService;
private OcCustomerService $ocCustomerService;
private OrderMetaService $orderMetaService;
public function __construct(
ConnectionInterface $database,
CartService $cartService,
OcRegistryDecorator $registry,
SettingsService $settings,
TelegramService $telegramService,
LoggerInterface $logger,
MegapayCustomerService $telegramCustomerService,
OcCustomerService $ocCustomerService,
OrderMetaService $orderMetaService
) {
$this->database = $database;
$this->cartService = $cartService;
$this->oc = $registry;
$this->settings = $settings;
$this->telegramService = $telegramService;
$this->logger = $logger;
$this->acmeshopCustomerService = $telegramCustomerService;
$this->ocCustomerService = $ocCustomerService;
$this->orderMetaService = $orderMetaService;
}
/**
* @throws Throwable
* @throws JsonException
*/
public function create(array $data, array $meta = []): array
{
$now = Carbon::now();
$storeId = $this->settings->get('store.oc_store_id');
$storeName = $this->settings->config()->getApp()->getAppName();
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$languageId = $this->settings->config()->getApp()->getLanguageId();
$currencyId = $this->oc->currency->getId($this->oc->session->data['currency']);
$currencyCode = $this->oc->session->data['currency'];
$currencyValue = $this->oc->currency->getValue($this->oc->session->data['currency']);
$cart = $this->cartService->getCart();
$total = $cart['total'] ?? 0;
$products = $cart['products'] ?? [];
$totals = $cart['totals'] ?? [];
// Получаем telegram_user_id из tgData
$telegramUserId = Arr::get($data['tgData'] ?? [], 'user.id');
$telegramUserdata = Arr::get($data['tgData'] ?? [], 'user');
if (! $telegramUserId) {
throw new RuntimeException('Telegram user id is required.');
}
$customOrderFields = $this->customOrderFields($data);
$orderData = [
'store_id' => $storeId,
'store_name' => $storeName,
'firstname' => $data['firstname'] ?? '',
'lastname' => $data['lastname'] ?? '',
'email' => $data['email'] ?? '',
'telephone' => $data['telephone'] ?? '',
'comment' => $data['comment'] ?? '',
'payment_method' => $data['payment_method'] ?? '',
'shipping_address_1' => $data['shipping_address_1'] ?? '',
'shipping_city' => $data['shipping_city'] ?? '',
'shipping_zone' => $data['shipping_zone'] ?? '',
'shipping_postcode' => $data['shipping_postcode'] ?? '',
'total' => $total,
'order_status_id' => $orderStatusId,
'ip' => $meta['ip'] ?? '',
'forwarded_ip' => $meta['ip'] ?? '',
'user_agent' => $meta['user_agent'] ?? '',
'date_added' => $now,
'date_modified' => $now,
'language_id' => $languageId,
'currency_id' => $currencyId,
'currency_code' => $currencyCode,
'currency_value' => $currencyValue,
'customer_group_id' => $customerGroupId,
];
try {
$this->database->beginTransaction();
$acmeshopCustomer = $this->acmeshopCustomerService->saveOrUpdate($telegramUserdata);
$acmeshopCustomerId = (int) $acmeshopCustomer['id'];
$ocCustomer = $this->ocCustomerService->findOrCreateByMegapayCustomerId($acmeshopCustomerId, $orderData);
$ocCustomerId = (int) $ocCustomer['customer_id'];
$orderData['customer_id'] = $ocCustomerId;
$this->database->insert(db_table('order'), $orderData);
$orderId = $this->database->lastInsertId();
// Insert products
$this->insertProducts($products, $orderId);
// Insert totals
$this->insertTotals($totals, $orderId);
// Insert history
$this->insertHistory($orderId, $orderStatusId, $customOrderFields, $now);
// Insert order meta data
if ($customOrderFields) {
$this->orderMetaService->insert($orderId, $storeId, $customOrderFields, $acmeshopCustomerId);
}
$this->acmeshopCustomerService->increaseOrdersCount($acmeshopCustomerId);
$this->database->commitTransaction();
} catch (Throwable $exception) {
$this->database->rollBackTransaction();
throw $exception;
}
$this->cartService->flush();
$orderData['order_id'] = $orderId;
$orderData['total_numeric'] = $orderData['total'] ?? 0;
$orderData['total'] = $cart['total_text'] ?? '';
$this->sendNotifications($orderData, $data['tgData']);
$dateTimeFormatted = '';
try {
$dateTimeFormatted = $now->format('d.m.Y H:i');
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return [
'id' => $orderData['order_id'],
'created_at' => $dateTimeFormatted,
'total' => $orderData['total'],
'final_total_numeric' => $orderData['total_numeric'],
'currency' => $currencyCode,
'products' => $products,
];
}
private function sendNotifications(array $orderData, array $tgInitData): void
{
$variables = [
'{store_name}' => $orderData['store_name'],
'{order_id}' => $orderData['order_id'],
'{customer}' => $orderData['firstname'] . ' ' . $orderData['lastname'],
'{email}' => $orderData['email'],
'{phone}' => $orderData['telephone'],
'{comment}' => $orderData['comment'],
'{address}' => $orderData['shipping_address_1'],
'{total}' => $orderData['total'],
'{ip}' => $orderData['ip'],
'{created_at}' => $orderData['date_added'],
];
$chatId = $this->settings->config()->getTelegram()->getChatId();
$template = $this->settings->config()->getTelegram()->getOwnerNotificationTemplate();
if ($chatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($chatId, $message);
} catch (Throwable $exception) {
$this->logger->error(
'Telegram sendMessage to owner error.',
[
'exception' => $exception,
'chat_id' => $chatId,
'message' => $message,
],
);
}
}
$allowsWriteToPm = Arr::get($tgInitData, 'user.allows_write_to_pm', false);
$customerChatId = Arr::get($tgInitData, 'user.id');
$template = $this->settings->config()->getTelegram()->getCustomerNotificationTemplate();
if ($allowsWriteToPm && $customerChatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Throwable $exception) {
$this->logger->error(
"Telegram sendMessage to customer error.",
[
'exception' => $exception,
'chat_id' => $chatId,
'message' => $message,
],
);
}
}
}
private function formatHistoryComment(array $customFields): string
{
$additionalString = '';
if ($customFields) {
$additionalString = "\n\nДополнительная информация по заказу:\n";
foreach ($customFields as $field => $value) {
$additionalString .= $field . ': ' . $value . "\n";
}
}
return "Заказ оформлен через Telegram Mini App.$additionalString";
}
private function customOrderFields(array $data): array
{
return Arr::except($data, [
'firstname',
'lastname',
'email',
'telephone',
'comment',
'shipping_address_1',
'shipping_city',
'shipping_zone',
'shipping_postcode',
'payment_method',
'tgData',
]);
}
public function insertTotals(array $totals, int $orderId): void
{
foreach ($totals as $total) {
$this->database->insert(db_table('order_total'), [
'order_id' => $orderId,
'code' => $total['code'],
'title' => $total['title'],
'value' => $total['value'],
'sort_order' => $total['sort_order'],
]);
}
}
/**
* @param int $orderId
* @param int $orderStatusId
* @param array $customOrderFields
* @param Carbon $now
* @return void
*/
public function insertHistory(int $orderId, int $orderStatusId, array $customOrderFields, Carbon $now): void
{
$history = [
'order_id' => $orderId,
'order_status_id' => $orderStatusId,
'notify' => 0,
'comment' => $this->formatHistoryComment($customOrderFields),
'date_added' => $now,
];
$this->database->insert(db_table('order_history'), $history);
}
private function insertProducts($products, int $orderId): void
{
foreach ($products as $product) {
$this->database->insert(db_table('order_product'), [
'order_id' => $orderId,
'product_id' => $product['product_id'],
'name' => $product['name'],
'model' => $product['model'],
'quantity' => $product['quantity'],
'price' => $product['price_numeric'],
'total' => $product['total_numeric'],
'reward' => $product['reward_numeric'],
]);
$orderProductId = $this->database->lastInsertId();
foreach ($product['option'] as $option) {
$this->database->insert(db_table('order_option'), [
'order_id' => $orderId,
'order_product_id' => $orderProductId,
'product_option_id' => $option['product_option_id'],
'product_option_value_id' => $option['product_option_value_id'],
'name' => $option['name'],
'value' => $option['value'],
'type' => $option['type'],
]);
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
class OrderMetaService
{
private ConnectionInterface $connection;
public function __construct(ConnectionInterface $connection)
{
$this->connection = $connection;
}
public function insert(int $orderId, int $storeId, array $fields, ?int $acmeshopCustomerId = null): void
{
$orderMeta = [
'oc_order_id' => $orderId,
'oc_store_id' => $storeId,
'acmeshop_customer_id' => $acmeshopCustomerId,
'meta_data' => json_encode($fields, JSON_THROW_ON_ERROR),
];
$this->connection->insert('acmeshop_order_meta', $orderMeta);
}
}

View File

@@ -0,0 +1,494 @@
<?php
namespace App\Services;
use Cart\Currency;
use Cart\Tax;
use Exception;
use Acme\ECommerceFramework\CriteriaBuilder\CriteriaBuilder;
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Acme\ECommerceFramework\ImageTool\ImageNotFoundException;
use Acme\ECommerceFramework\ImageTool\ImageUtils;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\ECommerce\PriceCalculator;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
use Acme\ECommerceFramework\QueryBuilder\Table;
use Acme\ECommerceFramework\Sentry\SentryService;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Support\PaginationHelper;
use Acme\ECommerceFramework\Support\Str;
use Psr\Log\LoggerInterface;
class ProductsService
{
private Builder $queryBuilder;
private Currency $currency;
private Tax $tax;
private SettingsService $settings;
private ImageFactory $image;
private OcRegistryDecorator $oc;
private LoggerInterface $logger;
private CriteriaBuilder $criteriaBuilder;
private PriceCalculator $priceCalculator;
public function __construct(
Builder $queryBuilder,
Currency $currency,
Tax $tax,
SettingsService $settings,
ImageFactory $image,
OcRegistryDecorator $registry,
LoggerInterface $logger,
CriteriaBuilder $criteriaBuilder,
PriceCalculator $priceCalculator
) {
$this->queryBuilder = $queryBuilder;
$this->currency = $currency;
$this->tax = $tax;
$this->settings = $settings;
$this->image = $image;
$this->oc = $registry;
$this->logger = $logger;
$this->criteriaBuilder = $criteriaBuilder;
$this->priceCalculator = $priceCalculator;
}
/**
* @throws ImageNotFoundException
*/
public function getProductsResponse(array $params, int $languageId, int $storeId): array
{
$page = $params['page'];
$perPage = $params['perPage'];
$search = $params['search'] ?? false;
$categoryName = '';
$maxPages = 200;
$filters = $params['filters'] ?? [];
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$currency = $this->settings->config()->getStore()->getOcDefaultCurrency();
$specialPriceSql = "(SELECT price
FROM oc_product_special ps
WHERE ps.product_id = products.product_id
AND ps.customer_group_id = $customerGroupId
AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND
(ps.date_end = '0000-00-00' OR ps.date_end > NOW()))
ORDER BY ps.priority ASC, ps.price ASC
LIMIT 1) AS special";
$productsQuery = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.quantity' => 'product_quantity',
'product_description.name' => 'product_name',
'products.price' => 'price',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
'manufacturer.name' => 'manufacturer_name',
'category_description.name' => 'category_name',
new RawExpression($specialPriceSql),
])
->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);
}
)
->join(
new Table(db_table('product_to_store'), 'product_to_store'),
function (JoinClause $join) use ($storeId) {
$join->on('product_to_store.product_id', '=', 'products.product_id')
->where('product_to_store.store_id', '=', $storeId);
}
)
->leftJoin(new Table(db_table('manufacturer'), 'manufacturer'), function (JoinClause $join) {
$join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id');
})
->leftJoin(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('products.product_id', '=', 'product_to_category.product_id')
->where('product_to_category.main_category', '=', 1);
})
->leftJoin(
new Table(db_table('category_description'), 'category_description'),
function (JoinClause $join) use ($languageId) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('category_description.language_id', '=', $languageId);
}
)
->where('products.status', '=', 1)
->whereRaw('products.date_available < NOW()')
->when($search, function (Builder $query) use ($search) {
$query->where('product_description.name', 'LIKE', '%' . $search . '%');
});
$this->criteriaBuilder->apply($productsQuery, $filters);
$total = $productsQuery->count();
$lastPage = min(PaginationHelper::calculateLastPage($total, $perPage), $maxPages);
$hasMore = $page + 1 <= $lastPage;
$products = $productsQuery
->forPage($page, $perPage)
->orderBy('date_modified', 'DESC')
->get();
$productIds = Arr::pluck($products, 'product_id');
$productsImages = [];
if ($productIds) {
$productsImages = $this->queryBuilder->newQuery()
->select([
'products_images.product_id' => 'product_id',
'products_images.image' => 'image',
])
->from(db_table('product_image'), 'products_images')
->orderBy('products_images.sort_order')
->whereIn('product_id', $productIds)
->get();
}
$span = SentryService::startSpan('crop_images', 'image.process');
$productsImagesMap = [];
foreach ($productsImages as $item) {
$productId = $item['product_id'];
// Ограничиваем количество картинок для каждого товара до 3
if (! isset($productsImagesMap[$productId])) {
$productsImagesMap[$productId] = [];
}
if (count($productsImagesMap[$productId]) < 2) {
$productsImagesMap[$productId][] = [
'url' => $this->image->make($item['image'])->crop($cropAlgorithm, $imageWidth, $imageHeight)->url(),
'alt' => 'Product Image',
];
}
}
SentryService::endSpan($span);
$debug = [];
if (env('APP_DEBUG')) {
$debug = [
'sql' => $productsQuery->toRawSql(),
];
}
return [
'data' => array_map(
function ($product) use ($productsImagesMap, $cropAlgorithm, $imageWidth, $imageHeight, $currency) {
$allImages = [];
$image = $this->image->make($product['product_image'], false)
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url();
$allImages[] = [
'url' => $image,
'alt' => Str::htmlEntityEncode($product['product_name']),
];
$price = $this->priceCalculator->format($product['price'], $product['tax_class_id']);
$priceNumeric = $this->priceCalculator->getPriceNumeric(
$product['price'],
$product['tax_class_id']
);
$special = false;
$specialPriceNumeric = null;
if ($product['special'] && (float) $product['special'] >= 0) {
$specialPriceNumeric = $this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->config()->getStore()->isOcConfigTax(),
);
$special = $this->currency->format(
$specialPriceNumeric,
$currency,
);
}
if (! empty($productsImagesMap[$product['product_id']])) {
$allImages = array_merge($allImages, $productsImagesMap[$product['product_id']]);
}
return [
'id' => (int) $product['product_id'],
'product_quantity' => (int) $product['product_quantity'],
'name' => Str::htmlEntityEncode($product['product_name']),
'price' => $price,
'special' => $special,
'image' => $image,
'images' => $allImages,
'special_numeric' => $specialPriceNumeric,
'price_numeric' => $priceNumeric,
'final_price_numeric' => $specialPriceNumeric ?: $priceNumeric,
'manufacturer_name' => $product['manufacturer_name'],
'category_name' => $product['category_name'],
];
},
$products
),
'meta' => [
'currentCategoryName' => $categoryName,
'hasMore' => $hasMore,
'debug' => $debug,
'total' => $total,
]
];
}
/**
* @throws EntityNotFoundException
* @throws Exception
*/
public function getProductById(int $productId): array
{
$this->oc->load->language('product/product');
$this->oc->load->model('catalog/category');
$this->oc->load->model('catalog/manufacturer');
$this->oc->load->model('catalog/product');
$this->oc->load->model('catalog/review');
$this->oc->load->model('tool/image');
$configTax = $this->oc->config->get('config_tax');
$product_info = $this->oc->model_catalog_product->getProduct($productId);
$currency = $this->oc->session->data['currency'];
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
}
$data = [];
$data['text_minimum'] = sprintf($this->oc->language->get('text_minimum'), $product_info['minimum']);
$data['tab_review'] = sprintf($this->oc->language->get('tab_review'), $product_info['reviews']);
$data['product_id'] = $productId;
$data['name'] = Str::htmlEntityEncode($product_info['name']);
$data['manufacturer'] = $product_info['manufacturer'];
$data['model'] = $product_info['model'];
$data['reward'] = $product_info['reward'];
$data['points'] = (int) $product_info['points'];
$data['description'] = Str::htmlEntityEncode($product_info['description']);
$data['share'] = Str::htmlEntityEncode(
$this->oc->url->link('product/product', [
'product_id' => $productId,
'utm_source' => 'acmeshop',
'utm_medium' => 'telegram',
'utm_campaign' => 'product_click',
'utm_content' => 'product_button',
]),
);
if ($product_info['quantity'] <= 0) {
$data['stock'] = $product_info['stock_status'];
} elseif ($this->oc->config->get('config_stock_display')) {
$data['stock'] = $product_info['quantity'];
} else {
$data['stock'] = $this->oc->language->get('text_instock');
}
$data['images'] = [];
$price = $this->priceCalculator->format($product_info['price'], $product_info['tax_class_id']);
$priceNumeric = $this->priceCalculator->getPriceNumeric($product_info['price'], $product_info['tax_class_id']);
$data['price'] = $price;
$data['currency'] = $currency;
$data['final_price_numeric'] = $priceNumeric;
if (! is_null($product_info['special']) && (float) $product_info['special'] >= 0) {
$productSpecialPrice = $this->tax->calculate(
$product_info['special'],
$product_info['tax_class_id'],
$configTax,
);
$data['special'] = $this->currency->format($productSpecialPrice, $currency);
$data['final_price_numeric'] = $productSpecialPrice;
$tax_price = (float) $product_info['special'];
} else {
$data['special'] = false;
$tax_price = (float) $product_info['price'];
}
if ($configTax) {
$data['tax'] = $this->currency->format($tax_price, $currency);
} else {
$data['tax'] = false;
}
$discounts = $this->oc->model_catalog_product->getProductDiscounts($productId);
$data['discounts'] = [];
foreach ($discounts as $discount) {
$data['discounts'][] = array(
'quantity' => $discount['quantity'],
'price' => $this->currency->format(
$this->tax->calculate(
$discount['price'],
$product_info['tax_class_id'],
$configTax,
),
$currency
)
);
}
$data['options'] = [];
foreach ($this->oc->model_catalog_product->getProductOptions($productId) as $option) {
$product_option_value_data = [];
foreach ($option['product_option_value'] as $option_value) {
if (! $option_value['subtract'] || ($option_value['quantity'] > 0)) {
$price = $this->currency->format(
$this->tax->calculate(
$option_value['price'],
$product_info['tax_class_id'],
$configTax ? 'P' : false
),
$currency
);
$product_option_value_data[] = array(
'product_option_value_id' => (int) $option_value['product_option_value_id'],
'option_value_id' => (int) $option_value['option_value_id'],
'name' => $option_value['name'],
'image' => $this->oc->model_tool_image->resize($option_value['image'], 50, 50),
'price' => $price,
'price_prefix' => $option_value['price_prefix'],
'selected' => false,
);
}
}
$data['options'][] = array(
'product_option_id' => $option['product_option_id'],
'product_option_value' => $product_option_value_data,
'option_id' => $option['option_id'],
'name' => $option['name'],
'type' => $option['type'],
'value' => $option['value'],
'required' => filter_var($option['required'], FILTER_VALIDATE_BOOLEAN),
);
}
if ($product_info['minimum']) {
$data['minimum'] = (int) $product_info['minimum'];
} else {
$data['minimum'] = 1;
}
$data['review_status'] = $this->oc->config->get('config_review_status');
$data['review_guest'] = true;
$data['customer_name'] = 'John Doe';
$data['reviews'] = sprintf($this->oc->language->get('text_reviews'), (int) $product_info['reviews']);
$data['rating'] = (int) $product_info['rating'];
$data['attribute_groups'] = $this->oc->model_catalog_product->getProductAttributes($productId);
$data['tags'] = array();
if ($product_info['tag']) {
$tags = explode(',', $product_info['tag']);
foreach ($tags as $tag) {
$data['tags'][] = array(
'tag' => trim($tag),
'href' => $this->oc->url->link('product/search', 'tag=' . trim($tag))
);
}
}
$data['recurrings'] = $this->oc->model_catalog_product->getProfiles($productId);
$data['category'] = $this->getProductMainCategory($productId);
$data['id'] = $productId;
$this->oc->model_catalog_product->updateViewed($productId);
return $data;
}
private function getProductMainCategory(int $productId): ?array
{
return $this->queryBuilder->newQuery()
->select([
'category_description.category_id' => 'id',
'category_description.name' => 'name',
])
->from(db_table('category_description'), 'category_description')
->join(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('product_to_category.main_category', '=', 1);
})
->where('product_to_category.product_id', '=', $productId)
->firstOrNull();
}
public function getProductImages(int $productId): array
{
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
$imageFullWidth = 1000;
$imageFullHeight = 1000;
$product_info = $this->oc->model_catalog_product->getProduct($productId);
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
}
$allImages = [];
if ($product_info['image']) {
$allImages[] = $product_info['image'];
}
$results = $this->oc->model_catalog_product->getProductImages($productId);
foreach ($results as $result) {
$allImages[] = $result['image'];
}
$images = [];
foreach ($allImages as $imagePath) {
try {
[$width, $height] = $this->image->make($imagePath)->getRealSize();
$images[] = [
'thumbnailURL' => $this->image->make($imagePath)
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url(),
'largeURL' => $this->image->make($imagePath)->resize($imageFullWidth, $imageFullHeight)->url(),
'width' => $width,
'height' => $height,
'alt' => Str::htmlEntityEncode($product_info['name']),
];
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
return $images;
}
}

View File

@@ -0,0 +1,449 @@
<?php
namespace App\Services;
use App\DTO\Settings\AppDTO;
use App\DTO\Settings\ConfigDTO;
use App\DTO\Settings\DatabaseDTO;
use App\DTO\Settings\LogsDTO;
use App\DTO\Settings\MetricsDTO;
use App\DTO\Settings\OrdersDTO;
use App\DTO\Settings\StoreDTO;
use App\DTO\Settings\TelegramDTO;
use App\DTO\Settings\TextsDTO;
use InvalidArgumentException;
class SettingsSerializerService
{
public function fromArray(array $data): ConfigDTO
{
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'database', 'logs'];
foreach ($keys as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException("Settings key '$key' is required!");
}
}
$this->validateApp($data['app']);
$this->validateTelegram($data['telegram']);
$this->validateMetrics($data['metrics']);
$this->validateStore($data['store']);
$this->validateOrders($data['orders']);
$this->validateTexts($data['texts']);
$this->validateDatabase($data['database']);
$this->validateLogs($data['logs']);
return new ConfigDTO(
$this->deserializeApp($data['app']),
$this->deserializeTelegram($data['telegram']),
$this->deserializeMetrics($data['metrics']),
$this->deserializeStore($data['store']),
$this->deserializeOrders($data['orders']),
$this->deserializeTexts($data['texts']),
$this->deserializeDatabase($data['database']),
$this->deserializeLogs($data['logs']),
);
}
/**
* @throws \JsonException
*/
public function serialize(ConfigDTO $settings): string
{
return json_encode($settings->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function deserializeApp(array $data): AppDTO
{
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_numeric($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
return new AppDTO(
$data['app_enabled'] ?? false,
$data['app_name'] ?? '',
$data['app_icon'] ?? null,
$data['theme_light'] ?? 'light',
$data['theme_dark'] ?? 'dark',
$data['app_debug'] ?? false,
$data['language_id'],
$data['shop_base_url'],
$data['haptic_enabled'] ?? true
);
}
private function deserializeTelegram(array $data): TelegramDTO
{
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
return new TelegramDTO(
$data['bot_token'],
$data['chat_id'],
$data['owner_notification_template'],
$data['customer_notification_template'],
$data['mini_app_url']
);
}
private function deserializeMetrics(array $data): MetricsDTO
{
return new MetricsDTO(
$data['yandex_metrika_enabled'] ?? false,
$data['yandex_metrika_counter'] ?? ''
);
}
private function deserializeStore(array $data): StoreDTO
{
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_numeric($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
return new StoreDTO(
$data['feature_coupons'] ?? true,
$data['feature_vouchers'] ?? true,
$data['show_category_products_button'] ?? true,
$data['product_interaction_mode'] ?? 'browser',
$data['manager_username'] ?? null,
$data['oc_default_currency'],
$data['oc_config_tax'],
$data['oc_store_id']
);
}
private function deserializeOrders(array $data): OrdersDTO
{
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_numeric($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
return new OrdersDTO(
$data['order_default_status_id'] ?? 1,
$data['oc_customer_group_id']
);
}
private function deserializeTexts(array $data): TextsDTO
{
return new TextsDTO(
$data['text_no_more_products'],
$data['text_empty_cart'],
$data['text_order_created_success'],
$data['text_manager_button'] ?? ''
);
}
// ==================== Validation Methods ====================
private function validateApp(array $data): void
{
if (! is_bool($data['app_enabled'])) {
throw new InvalidArgumentException('app.app_enabled must be a boolean');
}
if (! is_string($data['app_name'])) {
throw new InvalidArgumentException('app.app_name must be a string');
}
if (isset($data['app_icon']) && ! is_string($data['app_icon'])) {
throw new InvalidArgumentException('app.app_icon must be a string or null');
}
if (! is_string($data['theme_light'])) {
throw new InvalidArgumentException('app.theme_light must be a string');
}
if (! is_string($data['theme_dark'])) {
throw new InvalidArgumentException('app.theme_dark must be a string');
}
if (! is_bool($data['app_debug'])) {
throw new InvalidArgumentException('app.app_debug must be a boolean');
}
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_numeric($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if ($data['language_id'] <= 0) {
throw new InvalidArgumentException('app.language_id must be a positive integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
}
private function validateTelegram(array $data): void
{
if (isset($data['bot_token']) && ! is_string($data['bot_token'])) {
throw new InvalidArgumentException('telegram.bot_token must be a string or null');
}
if (isset($data['chat_id']) && ! is_numeric($data['chat_id'])) {
throw new InvalidArgumentException('telegram.chat_id must be an integer or null');
}
if (
isset($data['owner_notification_template']) && ! is_string(
$data['owner_notification_template']
)
) {
throw new InvalidArgumentException('telegram.owner_notification_template must be a string or null');
}
if (
isset($data['customer_notification_template']) && ! is_string(
$data['customer_notification_template']
)
) {
throw new InvalidArgumentException('telegram.customer_notification_template must be a string or null');
}
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
}
private function validateMetrics(array $data): void
{
if (isset($data['yandex_metrika_enabled']) && ! is_bool($data['yandex_metrika_enabled'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_enabled must be a boolean');
}
if (isset($data['yandex_metrika_counter']) && ! is_string($data['yandex_metrika_counter'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_counter must be a string');
}
}
private function validateStore(array $data): void
{
// enable_store больше не валидируется, так как заменен на product_interaction_mode
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
}
if (isset($data['feature_vouchers']) && ! is_bool($data['feature_vouchers'])) {
throw new InvalidArgumentException('store.feature_vouchers must be a boolean');
}
if (isset($data['show_category_products_button']) && ! is_bool($data['show_category_products_button'])) {
throw new InvalidArgumentException('store.show_category_products_button must be a boolean');
}
if (isset($data['product_interaction_mode']) && ! is_string($data['product_interaction_mode'])) {
throw new InvalidArgumentException('store.product_interaction_mode must be a string');
}
if (
isset($data['product_interaction_mode'])
&& ! in_array($data['product_interaction_mode'], ['order', 'manager', 'browser'], true)
) {
throw new InvalidArgumentException(
'store.product_interaction_mode must be one of: order, manager, browser'
);
}
if (isset($data['manager_username']) && $data['manager_username'] !== null) {
if (! is_string($data['manager_username'])) {
throw new InvalidArgumentException('store.manager_username must be a string or null');
}
// Проверяем, что это username (не числовой ID)
$managerUsername = trim($data['manager_username']);
if ($managerUsername !== '' && preg_match('/^-?\d+$/', $managerUsername)) {
throw new InvalidArgumentException(
'store.manager_username must be a username (e.g., @username), not a numeric ID'
);
}
}
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_numeric($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
if ($data['oc_store_id'] < 0) {
throw new InvalidArgumentException('store.oc_store_id must be a positive integer or equals 0');
}
}
private function validateOrders(array $data): void
{
if (isset($data['order_default_status_id'])) {
if (! is_numeric($data['order_default_status_id'])) {
throw new InvalidArgumentException('orders.order_default_status_id must be an integer');
}
if ($data['order_default_status_id'] <= 0) {
throw new InvalidArgumentException('orders.order_default_status_id must be a positive integer');
}
}
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_numeric($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
if ($data['oc_customer_group_id'] <= 0) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be a positive integer');
}
}
private function validateTexts(array $data): void
{
if (isset($data['text_no_more_products']) && ! is_string($data['text_no_more_products'])) {
throw new InvalidArgumentException('texts.text_no_more_products must be a string');
}
if (isset($data['text_empty_cart']) && ! is_string($data['text_empty_cart'])) {
throw new InvalidArgumentException('texts.text_empty_cart must be a string');
}
if (isset($data['text_order_created_success']) && ! is_string($data['text_order_created_success'])) {
throw new InvalidArgumentException('texts.text_order_created_success must be a string');
}
if (isset($data['text_manager_button']) && ! is_string($data['text_manager_button'])) {
throw new InvalidArgumentException('texts.text_manager_button must be a string');
}
}
private function deserializeLogs(array $logs): LogsDTO
{
return new LogsDTO(
$logs['path'],
);
}
private function deserializeDatabase(array $data): DatabaseDTO
{
return new DatabaseDTO(
$data['host'] ?? '',
$data['database'] ?? '',
$data['username'] ?? '',
$data['password'] ?? '',
$data['prefix'] ?? '',
$data['port'] ?? 3306
);
}
private function validateDatabase(array $data): void
{
if (isset($data['host']) && ! is_string($data['host'])) {
throw new InvalidArgumentException('database.host must be a string');
}
if (isset($data['database']) && ! is_string($data['database'])) {
throw new InvalidArgumentException('database.database must be a string');
}
if (isset($data['username']) && ! is_string($data['username'])) {
throw new InvalidArgumentException('database.username must be a string');
}
if (isset($data['password']) && ! is_string($data['password'])) {
throw new InvalidArgumentException('database.password must be a string');
}
if (isset($data['prefix']) && ! is_string($data['prefix'])) {
throw new InvalidArgumentException('database.prefix must be a string');
}
if (isset($data['port'])) {
if (is_string($data['port']) && ctype_digit($data['port'])) {
$data['port'] = (int) $data['port'];
}
if (! is_numeric($data['port'])) {
throw new InvalidArgumentException('database.port must be an integer');
}
if ($data['port'] <= 0 || $data['port'] > 65535) {
throw new InvalidArgumentException('database.port must be between 1 and 65535');
}
}
}
private function validateLogs(array $logs): void
{
if (! isset($logs['path'])) {
throw new InvalidArgumentException('Logs path must be set');
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services;
use App\DTO\Settings\ConfigDTO;
use Acme\ECommerceFramework\Config\Settings;
class SettingsService extends Settings
{
private ConfigDTO $config;
public function __construct(array $config, SettingsSerializerService $serializer)
{
parent::__construct($config);
$this->config = $serializer->fromArray($config);
}
public function config(): ConfigDTO
{
return $this->config;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Support;
final class Utils
{
/**
* @param string $string
* @return string
* @deprecated use Str::htmlEntityEncode instead
*/
public static function htmlEntityEncode(string $string): string
{
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Telegram;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use JsonException;
use Psr\Log\LoggerInterface;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\Commands\TelegramCommand;
use Acme\ECommerceFramework\Telegram\Enums\ChatAction;
use Acme\ECommerceFramework\Telegram\TelegramBotStateManager;
use Acme\ECommerceFramework\Telegram\TelegramService;
use Throwable;
class LinkCommand extends TelegramCommand
{
private LoggerInterface $logger;
public function __construct(
TelegramService $telegram,
TelegramBotStateManager $stateManager,
LoggerInterface $logger
) {
parent::__construct($telegram, $stateManager);
$this->logger = $logger;
}
/**
* @throws GuzzleException
* @throws JsonException
*/
public function handle(array $update): void
{
try {
$userId = $update['message']['from']['id'];
$chatId = $update['message']['chat']['id'];
$state = $this->state->getState($userId, $chatId);
if (! $state) {
$greeting = $this->telegram->escapeTgSpecialCharacters(
<<<HTML
Это удобный инструмент, который поможет вам 📎 создать красивое
сообщение с кнопкой для открытия вашего 🛒 Megapay магазина.
📌 Такое сообщение можно закрепить в канале или группе.
📤 Переслать клиентам в личные сообщения.
🚀 Или использовать повторно, когда нужно поделиться магазином.
Давайте начнём — отправьте текст, который вы хотите разместить в сообщении 👇
HTML
);
$this->telegram->sendMessage($chatId, $greeting);
$this->state->setState(self::class, $userId, $chatId, [
'step' => 'message_text',
'data' => [
'message_text' => '',
'btn_text' => '',
'btn_link' => '',
],
]);
return;
}
$step = $state['data']['step'];
if ($step === 'message_text') {
$message = Arr::get($update, 'message.text', 'Недопустимый текст сообщения');
$state['data']['data']['message_text'] = $message;
$state['data']['step'] = 'btn_text';
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$text = <<<HTML
🔸 Отлично!
Теперь укажите, какой текст будет на кнопке 👇
✍️ Напишите короткую, понятную фразу, например:
• Открыть магазин
• Каталог товаров
• Начать покупки
HTML;
$this->telegram->sendMessage($chatId, $text);
return;
}
if ($step === 'btn_text') {
$message = $update['message']['text'];
$state['data']['data']['btn_text'] = $message;
$state['data']['step'] = 'btn_link';
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$template = <<<MARKDOWN
🌐 Теперь отправьте ссылку на Telegram Mini App.
Ссылка должна начинаться с <pre>https://</pre>
📎 Инструкция, где взять ссылку:
👉 {LINK}
MARKDOWN;
$text = $this->telegram->prepareMessage($template, [
'{LINK}' => 'https://acme-inc.github.io/docs/telegram/telegram/#direct-link',
]);
$this->telegram->sendMessage($chatId, $text);
return;
}
if ($step === 'btn_link') {
$message = $update['message']['text'];
$state['data']['data']['btn_link'] = $message;
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$messageText = Arr::get($state, 'data.data.message_text', 'Текст сообщения');
$btnText = $this->telegram->escapeTgSpecialCharacters(
Arr::get($state, 'data.data.btn_text', 'Открыть магазин')
);
$btnLink = $message;
$replyMarkup = [
'inline_keyboard' => [
[
[
'text' => $btnText,
'url' => $btnLink,
]
]
],
];
$this->telegram->sendMessage(
$chatId,
$this->telegram->escapeTgSpecialCharacters($messageText),
$replyMarkup,
);
}
$this->state->clearState($userId, $chatId);
} catch (ClientException $exception) {
$this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents());
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
$this->telegram->sendMessage($chatId, 'Произошла ошибка');
}
}
}

44
backend/src/app/routes.php Executable file
View File

@@ -0,0 +1,44 @@
<?php
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\CronHandler;
use App\Handlers\ETLHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler;
use App\Handlers\HealthCheckHandler;
use App\Handlers\OrderHandler;
use App\Handlers\PrivacyPolicyHandler;
use App\Handlers\ProductsHandler;
use App\Handlers\SettingsHandler;
use App\Handlers\TelegramCustomerHandler;
use App\Handlers\TelegramHandler;
use App\Handlers\TelemetryHandler;
return [
'categoriesList' => [CategoriesHandler::class, 'index'],
'checkIsUserPrivacyConsented' => [PrivacyPolicyHandler::class, 'checkIsUserPrivacyConsented'],
'checkout' => [CartHandler::class, 'checkout'],
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
'getCart' => [CartHandler::class, 'index'],
'getForm' => [FormsHandler::class, 'getForm'],
'health' => [HealthCheckHandler::class, 'handle'],
'ingest' => [TelemetryHandler::class, 'ingest'],
'runSchedule' => [CronHandler::class, 'runSchedule'],
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'product_show' => [ProductsHandler::class, 'show'],
'products' => [ProductsHandler::class, 'index'],
'productsSearchPlaceholder' => [ProductsHandler::class, 'getSearchPlaceholder'],
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
'getCurrentCustomer' => [TelegramCustomerHandler::class, 'getCurrent'],
'settings' => [SettingsHandler::class, 'index'],
'storeOrder' => [OrderHandler::class, 'store'],
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
'webhook' => [TelegramHandler::class, 'webhook'],
'etlCustomers' => [ETLHandler::class, 'customers'],
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
'getProductImages' => [ProductsHandler::class, 'getProductImages'],
];

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Bastion;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Acme\ECommerceFramework\Application;
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
use Acme\ECommerceFramework\Router\RouteServiceProvider;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$routes = require __DIR__ . '/routes.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class,
CacheServiceProvider::class,
TelegramServiceProvider::class,
AcmeShopPulseServiceProvider::class,
ImageToolServiceProvider::class,
]);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Bastion\Exceptions;
use Exception;
class BotTokenConfiguratorException extends Exception
{
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Bastion\Handlers;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopEvent;
use Symfony\Component\HttpFoundation\JsonResponse;
class AcmeShopPulseStatsHandler
{
private AcmeShopEvent $eventModel;
private CacheInterface $cache;
private const CACHE_KEY = 'acmeshop_pulse_stats';
private const CACHE_TTL = 3600; // 1 час
public function __construct(AcmeShopEvent $eventModel, CacheInterface $cache)
{
$this->eventModel = $eventModel;
$this->cache = $cache;
}
public function getStats(): JsonResponse
{
$stats = $this->cache->get(self::CACHE_KEY);
if ($stats === null) {
$stats = $this->eventModel->getStats();
$this->cache->set(self::CACHE_KEY, $stats, self::CACHE_TTL);
}
return new JsonResponse(['data' => $stats]);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\Support\Str;
class AutocompleteHandler
{
private OcRegistryDecorator $registry;
private Builder $queryBuilder;
private SettingsService $settings;
public function __construct(
OcRegistryDecorator $registry,
Builder $queryBuilder,
SettingsService $settings
) {
$this->registry = $registry;
$this->queryBuilder = $queryBuilder;
$this->settings = $settings;
}
public function getCategoriesFlat(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
return new JsonResponse([
'data' => $categoriesFlat,
]);
}
public function getCategories(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
$categories = $this->buildCategoryTree($categoriesFlat);
return new JsonResponse([
'data' => $categories,
]);
}
public function getProductsById(Request $request): JsonResponse
{
$productIds = $request->json('product_ids', []);
$products = [];
if ($productIds) {
$products = array_map(function ($productId) {
$item = [
'id' => (int) $productId,
];
$product = $this->registry->model_catalog_product->getProduct($productId);
$item['name'] = $product ? Str::htmlEntityEncode($product['name']) : 'No name';
return $item;
}, $productIds);
}
return new JsonResponse([
'data' => $products,
]);
}
public function getCategoriesById(Request $request): JsonResponse
{
$ids = $request->json('category_ids', []);
$items = [];
if ($ids) {
$items = array_map(function ($id) {
$item = [
'id' => (int) $id,
];
$entity = $this->registry->model_catalog_category->getCategory($id);
$item['name'] = $entity ? Str::htmlEntityEncode($entity['name']) : 'No name';
return $item;
}, $ids);
}
return new JsonResponse([
'data' => $items,
]);
}
private function getFlatCategories(int $languageId): array
{
return $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
}
private function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = [];
foreach ($flat as $category) {
if ((int) $category['parent_id'] === (int) $parentId) {
$children = $this->buildCategoryTree($flat, $category['id']);
if ($children) {
$category['children'] = $children;
}
$branch[] = [
'key' => (int) $category['id'],
'label' => Str::htmlEntityEncode($category['name']),
'data' => [
'description' => Str::htmlEntityEncode($category['description']),
],
'icon' => null,
'children' => $category['children'] ?? [],
];
}
}
return $branch;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Symfony\Component\HttpFoundation\JsonResponse;
class DictionariesHandler
{
private Builder $queryBuilder;
private SettingsService $settings;
public function __construct(Builder $queryBuilder, SettingsService $settings)
{
$this->queryBuilder = $queryBuilder;
$this->settings = $settings;
}
public function getCategories(Request $request): JsonResponse
{
$perPage = $request->get('perPage', 20);
$categoryIds = $request->json('category_ids', []);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$data = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'categories.image' => 'image',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->when($categoryIds, function (Builder $query) use ($categoryIds) {
$query->whereIn('categories.category_id', $categoryIds);
})
->orderBy('parent_id')
->orderBy('sort_order')
->limit($perPage)
->get();
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Bastion\Handlers;
use JsonException;
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getFormByAlias(Request $request): JsonResponse
{
$alias = 'checkout';
//$request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('acmeshop_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'alias' => $alias,
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
'created_at' => $form['created_at'],
'updated_at' => $form['updated_at'],
],
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Bastion\Handlers;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\ImageTool\ImageFactory;
use Symfony\Component\HttpFoundation\Response;
class ImageHandler
{
private ImageFactory $image;
public function __construct(ImageFactory $image)
{
$this->image = $image;
}
public function getImage(Request $request): Response
{
$path = $request->query->get('path');
[$width, $height] = $this->parseSize($request->query->get('size'));
return $this->image
->make($path)
->resize($width, $height)
->response();
}
private function parseSize(?string $size = null): array
{
if (! $size) {
return [null, null];
}
$sizes = explode('x', $size);
return array_map(static fn($value) => is_numeric($value) ? (int) $value : null, $sizes);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace Bastion\Handlers;
use Acme\ECommerceFramework\Config\Settings;
use Symfony\Component\HttpFoundation\JsonResponse;
class LogsHandler
{
private Settings $settings;
public function __construct(Settings $settings)
{
$this->settings = $settings;
}
public function getLogs(): JsonResponse
{
$parsedLogs = [];
$logsPath = $this->findLastLogsFileInDir(
$this->settings->get('logs.path')
);
if ($logsPath) {
$lines = $this->readLastLogsRows($logsPath, 100);
$parsedLogs = $this->parseLogLines($lines);
}
return new JsonResponse(['data' => $parsedLogs]);
}
private function parseLogLines(array $lines): array
{
$parsed = [];
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
if (preg_match($pattern, $line, $matches)) {
$datetime = $matches[1] ?? '';
$channel = $matches[2] ?? '';
$level = $matches[3] ?? '';
$rest = $matches[4] ?? '';
// Извлекаем сообщение и контекст
// Контекст начинается с { и заканчивается соответствующим }
$message = $rest;
$context = null;
// Ищем JSON контекст (начинается с {, может быть после пробела или сразу)
$jsonStart = strpos($rest, ' {');
if ($jsonStart === false) {
$jsonStart = strpos($rest, '{');
} else {
$jsonStart++; // Пропускаем пробел перед {
}
if ($jsonStart !== false) {
$message = trim(substr($rest, 0, $jsonStart));
$jsonPart = substr($rest, $jsonStart);
// Находим конец JSON объекта, учитывая вложенность
$jsonEnd = $this->findJsonEnd($jsonPart);
if ($jsonEnd !== false) {
$contextJson = substr($jsonPart, 0, $jsonEnd + 1);
$decoded = json_decode($contextJson, true);
if (json_last_error() === JSON_ERROR_NONE) {
$context = $decoded;
}
}
}
// Форматируем дату для отображения (убираем микросекунды и временную зону для читаемости)
$formattedDatetime = $this->formatDateTime($datetime);
$message = rtrim($message, ' [] []');
$parsed[] = [
'datetime' => $formattedDatetime,
'datetime_raw' => $datetime,
'channel' => $channel,
'level' => $level,
'message' => $message,
'context' => $context,
'raw' => $line,
];
} else {
// Если строка не соответствует формату, сохраняем как есть
$parsed[] = [
'datetime' => '',
'datetime_raw' => '',
'channel' => '',
'level' => '',
'message' => $line,
'context' => null,
'raw' => $line,
];
}
}
return $parsed;
}
/**
* Находит позицию конца JSON объекта, учитывая вложенность
* @param string $json JSON строка, начинающаяся с {
* @return int|false Позиция закрывающей скобки или false, если не найдено
*/
private function findJsonEnd(string $json)
{
$depth = 0;
$inString = false;
$escape = false;
$len = strlen($json);
for ($i = 0; $i < $len; $i++) {
$char = $json[$i];
if ($escape) {
$escape = false;
continue;
}
if ($char === '\\') {
$escape = true;
continue;
}
if ($char === '"') {
$inString = !$inString;
continue;
}
if ($inString) {
continue;
}
if ($char === '{') {
$depth++;
} elseif ($char === '}') {
$depth--;
if ($depth === 0) {
return $i;
}
}
}
return false;
}
private function formatDateTime(string $datetime): string
{
// Парсим ISO 8601 формат: 2025-11-23T14:28:21.772518+00:00
// Преобразуем в более читаемый формат: 2025-11-23 14:28:21
if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/', $datetime, $dateMatches)) {
return $dateMatches[1] . ' ' . $dateMatches[2];
}
return $datetime;
}
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
{
$f = fopen($path, 'rb');
if (! $f) {
return [];
}
$lineCount = 0;
$chunk = '';
fseek($f, 0, SEEK_END);
$filesize = ftell($f);
while ($filesize > 0 && $lineCount < $lines) {
$seek = max($filesize - $buffer, 0);
$readLength = $filesize - $seek;
fseek($f, $seek);
$chunk = fread($f, $readLength) . $chunk;
$filesize = $seek;
$lineCount = substr_count($chunk, "\n");
}
fclose($f);
$linesArray = explode("\n", $chunk);
return array_slice($linesArray, -$lines);
}
private function findLastLogsFileInDir(string $dir): ?string
{
$files = glob($dir . '/acmeshop-*.log');
return $files ? end($files) : null;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Bastion\Handlers;
use App\Exceptions\TelegramCustomerNotFoundException;
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
use App\Models\TelegramCustomer;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Handler для отправки сообщений Telegram-пользователям из админ-панели
*
* @package Bastion\Handlers
*/
class SendMessageHandler
{
private TelegramService $telegramService;
private TelegramCustomer $telegramCustomerModel;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
TelegramCustomer $telegramCustomerModel,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->telegramCustomerModel = $telegramCustomerModel;
$this->logger = $logger;
}
/**
* Отправить сообщение Telegram-пользователю
*
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
* @return JsonResponse JSON ответ с результатом операции
* @throws TelegramCustomerNotFoundException Если пользователь не найден
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
* @throws RuntimeException Если данные невалидны
* @throws \Exception
* @throws GuzzleException
*/
public function sendMessage(Request $request): JsonResponse
{
$customerId = $this->extractCustomerId($request);
$message = $this->extractMessage($request);
// Находим запись по ID
$customer = $this->telegramCustomerModel->findById($customerId);
if (! $customer) {
throw new TelegramCustomerNotFoundException($customerId);
}
$telegramUserId = (int) $customer['telegram_user_id'];
// Проверяем, что пользователь разрешил писать ему в PM
if (! $customer['allows_write_to_pm']) {
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
}
// Отправляем сообщение (telegram_user_id используется как chat_id)
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
$this->telegramService->sendMessage(
$telegramUserId,
$message,
);
$this->logger->info('Message sent to Telegram user', [
'oc_customer_id' => $customerId,
'telegram_user_id' => $telegramUserId,
'message_length' => strlen($message),
]);
return new JsonResponse([
'success' => true,
'message' => 'Message sent successfully',
]);
}
/**
* Извлечь ID записи из запроса
*
* @param Request $request HTTP запрос
* @return int ID записи в таблице acmeshop_customers
* @throws RuntimeException Если ID отсутствует или невалиден
*/
private function extractCustomerId(Request $request): int
{
$jsonData = $request->json();
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
if ($customerId <= 0) {
throw new RuntimeException('Customer ID is required and must be positive');
}
return $customerId;
}
/**
* Извлечь сообщение из запроса
*
* @param Request $request HTTP запрос
* @return string Текст сообщения
* @throws RuntimeException Если сообщение отсутствует или пустое
*/
private function extractMessage(Request $request): string
{
$jsonData = $request->json();
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
if (empty($message)) {
throw new RuntimeException('Message is required and cannot be empty');
}
return $message;
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Bastion\Handlers;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Bastion\Services\BotTokenConfigurator;
use Bastion\Services\CronApiKeyRegenerator;
use Bastion\Services\SettingsService;
use Carbon\Carbon;
use Exception;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Config\Settings;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Acme\ECommerceFramework\Scheduler\Models\ScheduledJob;
use Acme\ECommerceFramework\Support\Arr;
use Psr\Log\LoggerInterface;
class SettingsHandler
{
private BotTokenConfigurator $botTokenConfigurator;
private CronApiKeyRegenerator $cronApiKeyRegenerator;
private Settings $settings;
private SettingsService $settingsUpdateService;
private CacheInterface $cache;
private LoggerInterface $logger;
private Builder $builder;
private ConnectionInterface $connection;
private ScheduledJob $scheduledJob;
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
CronApiKeyRegenerator $cronApiKeyRegenerator,
Settings $settings,
SettingsService $settingsUpdateService,
CacheInterface $cache,
LoggerInterface $logger,
Builder $builder,
ConnectionInterface $connection,
ScheduledJob $scheduledJob
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache;
$this->logger = $logger;
$this->builder = $builder;
$this->connection = $connection;
$this->scheduledJob = $scheduledJob;
}
/**
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
*/
public function regenerateCronScheduleUrl(Request $request): JsonResponse
{
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
$scheduleUrl = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$newApiKey
);
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
}
public function configureBotToken(Request $request): JsonResponse
{
try {
$data = $this->botTokenConfigurator->configure(trim($request->json('botToken', '')));
return new JsonResponse($data);
} catch (BotTokenConfiguratorException $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function getSettingsForm(): JsonResponse
{
$data = Arr::getWithKeys($this->settings->getAll(), [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
'pulse',
'cron',
]);
if (!isset($data['cron']['mode'])) {
$data['cron']['mode'] = 'disabled';
}
$data['forms'] = [];
// Add CRON system details (read-only)
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
$data['cron']['last_run'] = $this->getLastCronRunDate();
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$this->settings->get('cron.api_key', '')
);
$data['scheduled_jobs'] = $this->scheduledJob->all();
$forms = $this->builder->newQuery()
->from('acmeshop_forms')
->get();
if ($forms) {
foreach ($forms as $form) {
try {
$schema = json_decode($form['schema'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
$schema = [];
}
$data['forms'][$form['alias']] = [
'alias' => $form['alias'],
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
];
}
}
return new JsonResponse(compact('data'));
}
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
{
$base = rtrim($shopBaseUrl, '/');
if ($base === '') {
return '';
}
$params = http_build_query([
'route' => 'extension/tgshop/handle',
'api_action' => 'runSchedule',
'api_key' => $apiKey,
]);
return $base . '/index.php?' . $params;
}
public function saveSettingsForm(Request $request): JsonResponse
{
$input = $request->json();
$this->validate($input);
// Remove dynamic properties before saving
if (isset($input['cron'])) {
unset($input['cron']['cli_path']);
unset($input['cron']['last_run']);
unset($input['cron']['schedule_url']);
}
$this->settingsUpdateService->update(
Arr::getWithKeys($input, [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
'pulse',
'cron',
]),
);
// Update forms
$forms = Arr::get($input, 'forms', []);
foreach ($forms as $form) {
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
$this->builder->newQuery()
->where('alias', '=', $form['alias'])
->update('acmeshop_forms', [
'friendly_name' => $form['friendly_name'],
'is_custom' => $form['is_custom'],
'schema' => $schema,
]);
}
// Update scheduled jobs is_enabled and cron_expression
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
foreach ($scheduledJobs as $job) {
$id = (int) ($job['id'] ?? 0);
if ($id <= 0) {
continue;
}
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($isEnabled) {
$this->scheduledJob->enable($id);
} else {
$this->scheduledJob->disable($id);
}
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
if ($cronExpression !== '') {
$this->scheduledJob->updateCronExpression($id, $cronExpression);
}
}
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
private function validate(array $input): void
{
}
public function resetCache(): JsonResponse
{
$this->cache->clear();
$this->logger->info('Cache cleared manually.');
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
private function getLastCronRunDate(): ?string
{
try {
// Since we are in SettingsHandler, we already have access to container or we can inject SchedulerService
// But SettingsHandler is constructed via DI. Let's add SchedulerService to constructor.
// For now, let's use global retrieval via cache if possible, or assume it's injected.
// But wait, getLastCronRunDate logic was in controller.
// SchedulerService stores last run in cache. We have $this->cache here.
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
if ($lastRunTimestamp) {
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
}
return null;
} catch (Exception $e) {
return null;
}
}
public function getSystemInfo(): JsonResponse
{
$info = [];
$info['PHP Version'] = PHP_VERSION;
$info['PHP SAPI'] = PHP_SAPI;
$info['PHP Memory Limit'] = ini_get('memory_limit');
$info['PHP Memory Usage'] = $this->formatBytes(memory_get_usage(true));
$info['PHP Peak Memory Usage'] = $this->formatBytes(memory_get_peak_usage(true));
$info['PHP Max Execution Time'] = ini_get('max_execution_time') . 's';
$info['PHP Upload Max Filesize'] = ini_get('upload_max_filesize');
$info['PHP Post Max Size'] = ini_get('post_max_size');
try {
$mysqlVersion = $this->connection->select('SELECT VERSION() as version');
$info['MySQL Version'] = $mysqlVersion[0]['version'] ?? 'Unknown';
} catch (Exception $e) {
$info['MySQL Version'] = 'Error: ' . $e->getMessage();
}
$cacheDriver = env('MEGAPAY_CACHE_DRIVER', 'mysql');
$cacheClass = get_class($this->cache);
$info['Cache Driver'] = $cacheDriver . ' (' . basename(str_replace('\\', '/', $cacheClass)) . ')';
$info['Module Version'] = module_version();
$info['ECommerce Version'] = defined('VERSION') ? VERSION : 'Unknown';
$info['ECommerce Core Version'] = defined('VERSION_CORE') ? VERSION_CORE : 'Unknown';
$info['Operating System'] = PHP_OS;
$info['Server Software'] = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown';
$info['Document Root'] = $_SERVER['DOCUMENT_ROOT'] ?? 'Unknown';
$info['PHP Timezone'] = date_default_timezone_get();
$info['Server Time'] = date('Y-m-d H:i:s');
$info['UTC Time'] = gmdate('Y-m-d H:i:s');
$info['Loaded PHP Extensions'] = implode(', ', get_loaded_extensions());
$infoText = '';
foreach ($info as $key => $value) {
$infoText .= $key . ': ' . $value . "\n";
}
return new JsonResponse(['data' => $infoText]);
}
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,60 @@
<?php
namespace Bastion\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
class StatsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
public function getDashboardStats(): JsonResponse
{
$data = [
'orders_count' => 0,
'orders_total_amount' => 0,
'customers_count' => 0,
];
$ordersTotalAmount = $this->builder->newQuery()
->select([
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_total_count'),
new RawExpression('SUM(orders.total) AS orders_total_amount'),
])
->from(db_table('order'), 'orders')
->join('acmeshop_customers', function (JoinClause $join) {
$join->on('orders.customer_id', '=', 'acmeshop_customers.oc_customer_id');
})
->join('acmeshop_order_meta', function (JoinClause $join) {
$join->on('orders.order_id', '=', 'acmeshop_order_meta.oc_order_id')
->whereRaw('orders.store_id = acmeshop_order_meta.oc_store_id');
})
->firstOrNull();
if ($ordersTotalAmount) {
$data = [
'orders_count' => (int) $ordersTotalAmount['orders_total_count'],
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
'customers_count' => $this->countCustomersCount(),
];
}
return new JsonResponse(compact('data'));
}
private function countCustomersCount(): int
{
return $this->builder->newQuery()
->from('acmeshop_customers')
->count();
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Bastion\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Acme\ECommerceFramework\QueryBuilder\Builder;
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
use Acme\ECommerceFramework\Support\Arr;
class TelegramCustomersHandler
{
private const TABLE_NAME = 'acmeshop_customers';
private const DEFAULT_PAGE = 1;
private const DEFAULT_ROWS = 20;
private const DEFAULT_SORT_FIELD = 'last_seen_at';
private const DEFAULT_SORT_ORDER = 'DESC';
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
*
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
* @return JsonResponse JSON ответ с данными и метаинформацией
*/
public function getCustomers(Request $request): JsonResponse
{
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
$first = ($page - 1) * $rows;
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
$filters = $request->json('filters', []);
$globalFilter = Arr::get($filters, 'global.value');
// Создаем базовый query с фильтрами
$query = $this->buildBaseQuery();
$this->applyFilters($query, $filters, $globalFilter);
// Получаем общее количество записей
$countQuery = $this->buildCountQuery();
$this->applyFilters($countQuery, $filters, $globalFilter);
$totalRecords = (int) ($countQuery->value('total') ?? 0);
// Применяем сортировку и пагинацию
$customers = $query
->orderBy($sortField, $sortOrder)
->offset($first)
->limit($rows)
->get();
return new JsonResponse([
'data' => [
'data' => $this->mapToResponse($customers),
'totalRecords' => $totalRecords,
],
]);
}
/**
* Создать базовый query для выборки данных
*
* @return Builder
*/
private function buildBaseQuery(): Builder
{
return $this->builder->newQuery()
->select([
'id',
'telegram_user_id',
'oc_customer_id',
'tracking_id',
'username',
'first_name',
'last_name',
'language_code',
'is_premium',
'allows_write_to_pm',
'photo_url',
'last_seen_at',
'referral',
'orders_count',
'privacy_consented_at',
'created_at',
'updated_at',
])
->from(self::TABLE_NAME);
}
/**
* Создать query для подсчета общего количества записей
*
* @return Builder
*/
private function buildCountQuery(): Builder
{
return $this->builder->newQuery()
->select([new RawExpression('COUNT(*) as total')])
->from(self::TABLE_NAME);
}
/**
* Применить фильтры к query
*
* @param Builder $query Query builder
* @param array $filters Массив фильтров
* @param string|null $globalFilter Глобальный фильтр поиска
* @return void
*/
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
{
// Применяем глобальный фильтр
if ($globalFilter) {
$this->applyGlobalFilter($query, $globalFilter);
}
// Применяем фильтры по колонкам
$this->applyColumnFilters($query, $filters);
}
/**
* Применить глобальный фильтр поиска
*
* @param Builder $query Query builder
* @param string $searchTerm Поисковый запрос
* @return void
*/
private function applyGlobalFilter(Builder $query, string $searchTerm): void
{
$query->whereNested(function ($q) use ($searchTerm) {
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
->orWhere('username', 'LIKE', "%{$searchTerm}%")
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
});
}
/**
* Применить фильтры по колонкам
*
* @param Builder $query Query builder
* @param array $filters Массив фильтров
* @return void
*/
private function applyColumnFilters(Builder $query, array $filters): void
{
foreach ($filters as $field => $filter) {
if ($field === 'global') {
continue;
}
// Обработка сложных фильтров (constraints)
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
$this->applyConstraintFilters($query, $field, $filter);
continue;
}
// Обработка простых фильтров (обратная совместимость)
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
continue;
}
$value = $filter['value'];
$matchMode = Arr::get($filter, 'matchMode', 'contains');
$this->applyColumnFilter($query, $field, $value, $matchMode);
}
}
/**
* Применить сложные фильтры с условиями (AND/OR)
*
* @param Builder $query Query builder
* @param string $field Имя поля
* @param array $filter Данные фильтра
* @return void
*/
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
{
$operator = strtolower($filter['operator'] ?? 'and');
$constraints = $filter['constraints'];
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
$activeConstraints = array_filter($constraints, function ($constraint) {
if (!isset($constraint['value'])) {
return false;
}
$value = $constraint['value'];
// null означает "любой", пропускаем
if ($value === null) {
return false;
}
// Пустая строка пропускаем
if ($value === '') {
return false;
}
// false - валидное значение для boolean полей
return true;
});
if (empty($activeConstraints)) {
return;
}
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
// Для первого элемента всегда используем where, чтобы начать группу
$first = true;
foreach ($activeConstraints as $constraint) {
$value = $constraint['value'];
$matchMode = $constraint['matchMode'] ?? 'contains';
if ($first) {
$this->applyColumnFilter($q, $field, $value, $matchMode);
$first = false;
continue;
}
if ($operator === 'or') {
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
});
} else {
$this->applyColumnFilter($q, $field, $value, $matchMode);
}
}
});
}
/**
* Применить фильтр для одной колонки
*
* @param Builder $query Query builder
* @param string $field Имя поля
* @param mixed $value Значение фильтра
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
* @return void
*/
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
{
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
$likeValue = $this->buildLikeValue($value, $matchMode);
$query->where($field, 'LIKE', $likeValue);
} elseif ($matchMode === 'equals') {
$query->where($field, '=', $value);
} elseif ($matchMode === 'notEquals') {
$query->where($field, '!=', $value);
} elseif ($matchMode === 'gt') {
$query->where($field, '>', $value);
} elseif ($matchMode === 'lt') {
$query->where($field, '<', $value);
} elseif ($matchMode === 'gte') {
$query->where($field, '>=', $value);
} elseif ($matchMode === 'lte') {
$query->where($field, '<=', $value);
} elseif ($matchMode === 'dateIs') {
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
$date = date('Y-m-d', strtotime($value));
$query->where($field, '>=', $date . ' 00:00:00')
->where($field, '<=', $date . ' 23:59:59');
} elseif ($matchMode === 'dateIsNot') {
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
$date = date('Y-m-d', strtotime($value));
$query->whereNested(function ($q) use ($field, $date) {
$q->where($field, '<', $date . ' 00:00:00')
->orWhere($field, '>', $date . ' 23:59:59');
});
} elseif ($matchMode === 'dateBefore') {
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
} elseif ($matchMode === 'dateAfter') {
// "После" означает после конца указанного дня
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
}
}
/**
* Построить значение для LIKE запроса
*
* @param string $value Значение
* @param string $matchMode Режим совпадения
* @return string
*/
private function buildLikeValue(string $value, string $matchMode): string
{
if ($matchMode === 'startsWith') {
return "{$value}%";
}
if ($matchMode === 'endsWith') {
return "%{$value}";
}
return "%{$value}%";
}
/**
* Нормализовать порядок сортировки
*
* @param string $sortOrder Порядок сортировки
* @return string 'ASC' или 'DESC'
*/
private function normalizeSortOrder(string $sortOrder): string
{
$normalized = strtoupper($sortOrder);
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
}
private function mapToResponse(array $customers): array
{
return array_map(static function (array $customer) {
return [
'id' => (int) $customer['id'],
'telegram_user_id' => (int) $customer['telegram_user_id'],
'oc_customer_id' => (int) $customer['oc_customer_id'],
'tracking_id' => $customer['tracking_id'],
'username' => $customer['username'],
'first_name' => $customer['first_name'],
'last_name' => $customer['last_name'],
'language_code' => $customer['language_code'],
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
'photo_url' => $customer['photo_url'],
'last_seen_at' => $customer['last_seen_at'],
'referral' => $customer['referral'],
'orders_count' => (int) $customer['orders_count'],
'privacy_consented_at' => $customer['privacy_consented_at'],
'created_at' => $customer['created_at'],
'updated_at' => $customer['updated_at'],
];
}, $customers);
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Telegram\Enums\ChatAction;
use Symfony\Component\HttpFoundation\JsonResponse;
use Acme\ECommerceFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramClientException;
use Acme\ECommerceFramework\Telegram\TelegramService;
class TelegramHandler
{
private CacheInterface $cache;
private TelegramService $telegramService;
private SettingsService $settings;
public function __construct(CacheInterface $cache, TelegramService $telegramService, SettingsService $settings)
{
$this->cache = $cache;
$this->telegramService = $telegramService;
$this->settings = $settings;
}
public function getChatId(): JsonResponse
{
$message = $this->cache->get('tg_latest_msg');
if (! $message) {
return new JsonResponse([
// phpcs:ignore Generic.Files.LineLength
'message' => 'Сообщение не найдено. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд. У Вас есть 60 секунд после отправки сообщения в чат, чтобы нажать на кнопку! Это сделано в целях безопасности.'
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$text = Arr::get($message, 'text');
if ($text !== 'ecommerce_get_chatid') {
return new JsonResponse(
['message' => 'Последнее сообщение в чате не содержит кодовое слово.'],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
$chatId = Arr::get($message, 'chat.id');
if (! $chatId) {
return new JsonResponse([
// phpcs:ignore Generic.Files.LineLength
'message' => 'ChatID не найден. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд.'
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return new JsonResponse([
'data' => [
'chat_id' => $chatId,
],
]);
}
public function testTgMessage(Request $request): JsonResponse
{
$template = $request->json('template', 'Нет шаблона');
$token = $request->json('token');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@ecommerce.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
'{address}' => 'г. Москва',
'{total}' => 100000,
'{ip}' => '127.0.0.1',
'{created_at}' => date('Y-m-d H:i:s'),
];
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);
} catch (Exception $e) {
return new JsonResponse([
'message' => $e->getMessage(),
]);
}
}
/**
* @throws GuzzleException
* @throws TelegramClientException
* @throws \JsonException
*/
public function tgGetMe(): JsonResponse
{
if (! $this->settings->config()->getTelegram()->getBotToken()) {
return new JsonResponse(['data' => null]);
}
$data = $this->cache->get('tg_me_info');
if (! $data) {
$data = $this->telegramService->exec('getMe');
$this->cache->set('tg_me_info', $data, 60 * 5);
}
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Bastion\ScheduledTasks;
use GuzzleHttp\Exception\GuzzleException;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\Config\Settings;
use Acme\ECommerceFramework\Scheduler\TaskInterface;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopEvent;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseEventsSender;
use Psr\Log\LoggerInterface;
use Throwable;
class AcmeShopPulseSendEventsTask implements TaskInterface
{
private AcmeShopEvent $eventModel;
private AcmeShopPulseEventsSender $eventsSender;
private LoggerInterface $logger;
private CacheInterface $cache;
private Settings $settings;
private int $maxAttempts;
private int $batchSize;
public function __construct(
Settings $settings,
AcmeShopEvent $eventModel,
AcmeShopPulseEventsSender $eventsSender,
LoggerInterface $logger,
CacheInterface $cache
) {
$this->settings = $settings;
$this->eventModel = $eventModel;
$this->eventsSender = $eventsSender;
$this->logger = $logger;
$this->cache = $cache;
// Получаем конфигурацию из настроек пользователя
$this->maxAttempts = (int) $this->settings->get('pulse.max_attempts', env('PULSE_MAX_ATTEMPTS', 3));
$this->batchSize = (int) $this->settings->get('pulse.batch_size', env('PULSE_BATCH_SIZE', 50));
}
public function execute(): void
{
try {
// Получаем события со статусом pending
$events = $this->eventModel->findPending($this->batchSize);
if (empty($events)) {
$this->logger->debug('No pending events to send');
return;
}
$count = count($events);
$this->logger->info("Processing pending events: $count", [
'count' => $count,
]);
$processed = 0;
$succeeded = 0;
$failed = 0;
foreach ($events as $event) {
try {
$result = $this->processEvent($event);
$result ? $succeeded++ : $failed++;
} catch (Throwable $e) {
$this->logger->error("Failed to process event {$event['id']}: " . $e->getMessage(), [
'event_id' => $event['id'],
'event' => $event['event'] ?? null,
'payload' => $event['payload'] ?? null,
'exception' => $e,
]);
$failed++;
} finally {
$processed++;
}
}
$this->logger->info("Events processing completed", [
'processed' => $processed,
'succeeded' => $succeeded,
'failed' => $failed,
]);
} catch (Throwable $e) {
$this->logger->error("AcmeShopPulseSendEventsTask failed: " . $e->getMessage(), [
'exception' => $e,
]);
} finally {
// Сбрасываем кеш статистики после каждого прогона
$this->clearStatsCache();
}
}
/**
* Обработать одно событие
*
* @param array $event Данные события из БД
* @return bool true если событие успешно отправлено, false если требуется повторная попытка
* @throws Throwable
*/
private function processEvent(array $event): bool
{
$eventId = (int) $event['id'];
$attemptsCount = (int) $event['attempts_count'];
try {
// Пытаемся отправить событие
$success = $this->eventsSender->sendEvent($event);
if ($success) {
// Успешная отправка
$this->eventModel->updateStatus($eventId, 'sent');
$this->logger->debug("Event {$eventId} sent successfully", [
'event_id' => $eventId,
'event' => $event['event'],
]);
return true;
}
// AcmeShop Pulse не вернул подтверждение
$errorReason = 'No confirmation received from AcmeShop Pulse';
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
} catch (GuzzleException $e) {
// Ошибка HTTP запроса
$errorReason = 'HTTP error: ' . $e->getMessage();
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
} catch (Throwable $e) {
// Другие ошибки (валидация, подпись и т.д.)
$errorReason = 'Error: ' . $e->getMessage();
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
}
return false;
}
/**
* Обработать неудачную попытку отправки
*
* @param int $eventId ID события
* @param int $currentAttempts Текущее количество попыток
* @param string $errorReason Причина ошибки
*/
private function handleFailedAttempt(int $eventId, int $currentAttempts, string $errorReason): void
{
$newAttempts = $currentAttempts + 1;
if ($newAttempts >= $this->maxAttempts) {
// Превышен лимит попыток - переводим в failed
$this->eventModel->updateStatus($eventId, 'failed', $errorReason);
$this->logger->warning("Event {$eventId} marked as failed after {$newAttempts} attempts", [
'event_id' => $eventId,
'attempts' => $newAttempts,
'error' => $errorReason,
]);
return;
}
// Увеличиваем счетчик попыток, оставляем статус pending
$this->eventModel->incrementAttempts($eventId);
$this->logger->debug("Event {$eventId} attempt failed, will retry", [
'event_id' => $eventId,
'attempts' => $newAttempts,
'max_attempts' => $this->maxAttempts,
'error' => $errorReason,
]);
}
/**
* Сбросить кеш статистики
*/
private function clearStatsCache(): void
{
$this->cache->delete('acmeshop_pulse_stats');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Bastion\Services;
use App\Services\SettingsService;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
use Acme\ECommerceFramework\Router\Router;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramClientException;
use Acme\ECommerceFramework\Telegram\TelegramService;
class BotTokenConfigurator
{
private TelegramService $telegramService;
private SettingsService $settings;
private Router $router;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
SettingsService $settings,
Router $router,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->settings = $settings;
$this->router = $router;
$this->logger = $logger;
}
/**
* @throws BotTokenConfiguratorException
*/
public function configure(string $botToken): array
{
$this->telegramService->setBotToken($botToken);
try {
$me = $this->telegramService->exec('getMe');
$webhookUrl = $this->telegramService->getWebhookUrl();
if (! $webhookUrl) {
$this->telegramService->exec('setWebhook', [
'url' => $this->getWebhookUrl(),
]);
$webhookUrl = $this->telegramService->getWebhookUrl();
}
return [
'first_name' => Arr::get($me, 'result.first_name'),
'username' => Arr::get($me, 'result.username'),
'id' => Arr::get($me, 'result.id'),
'webhook_url' => $webhookUrl,
];
} catch (TelegramClientException $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
throw new BotTokenConfiguratorException(
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
);
}
throw new BotTokenConfiguratorException($exception->getMessage());
} catch (Exception | GuzzleException $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new BotTokenConfiguratorException($exception->getMessage());
}
}
/**
* @throws BotTokenConfiguratorException
*/
private function getWebhookUrl(): string
{
$publicUrl = rtrim($this->settings->config()->getApp()->getShopBaseUrl(), '/');
if (! $publicUrl) {
throw new BotTokenConfiguratorException('Public URL is not set in configuration.');
}
$webhook = $this->router->url('webhook');
return $publicUrl . $webhook;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Bastion\Services;
use Acme\ECommerceFramework\Config\Settings;
class CronApiKeyRegenerator
{
private Settings $settings;
private SettingsService $settingsUpdateService;
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
{
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
}
/**
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
*
* @return string новый api_key
*/
public function regenerate(): string
{
$newApiKey = bin2hex(random_bytes(32));
$all = $this->settings->getAll();
if (! isset($all['cron'])) {
$all['cron'] = [];
}
$all['cron']['api_key'] = $newApiKey;
$this->settingsUpdateService->update($all);
return $newApiKey;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Bastion\Services;
use Acme\ECommerceFramework\Cache\CacheInterface;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
use Acme\ECommerceFramework\Support\Arr;
class SettingsService
{
private OcRegistryDecorator $registry;
private CacheInterface $cache;
private ConnectionInterface $connection;
public function __construct(OcRegistryDecorator $registry, CacheInterface $cache, ConnectionInterface $connection)
{
$this->registry = $registry;
$this->cache = $cache;
$this->connection = $connection;
}
public function update(array $data): void
{
$this->connection->transaction(function () use ($data) {
$this->registry->model_setting_setting->editSetting('module_acmeshop', [
'module_acmeshop_settings' => $data,
]);
$this->registry->model_setting_setting->editSetting('module_tgshop', [
'module_tgshop_status' => Arr::get($data, 'app.app_enabled', false) ? 1 : 0,
]);
});
$this->cache->clear();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Bastion\Tasks;
use DateInterval;
use Exception;
use JsonException;
use Acme\ECommerceFramework\MaintenanceTasks\BaseMaintenanceTask;
use RuntimeException;
class CleanUpOldAssetsTask extends BaseMaintenanceTask
{
public function handle(): void
{
$spaPath = rtrim(DIR_IMAGE, '/') . '/catalog/tgshopspa';
$assetsPath = $spaPath . '/assets';
$manifestPath = $spaPath . '/manifest.json';
if (! file_exists($manifestPath)) {
return;
}
try {
$contents = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
$entry = $contents['index.html'] ?? null;
if (! $entry) {
throw new RuntimeException('Некорректный manifest.json — отсутствует ключ index.html.');
}
$keep = [$entry['file']];
if (! empty($entry['css'])) {
foreach ($entry['css'] as $css) {
$keep[] = $css;
}
}
$deletedFiles = 0;
$keptFiles = 0;
foreach (glob($assetsPath . '/*') as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if (! in_array($ext, ['js', 'css', 'map'])) {
continue;
}
$relative = 'assets/' . basename($file);
if (in_array($relative, $keep, true)) {
$keptFiles++;
continue;
}
if (is_file($file)) {
unlink($file);
$deletedFiles++;
}
}
if ($deletedFiles > 0) {
$this->logger->info(
sprintf('Очистка assets завершена. Удалено: %d, оставлено: %d', $deletedFiles, $keptFiles)
);
}
} catch (JsonException $e) {
$this->logger->error('Ошибка декодирования файла manifest.json: ' . $e->getMessage());
} catch (Exception $e) {
$this->logger->error('Ошибка удаления старых assets: ' . $e->getMessage(), ['exception' => $e]);
}
}
public function interval(): ?DateInterval
{
return new DateInterval('PT1H');
}
}

37
backend/src/bastion/routes.php Executable file
View File

@@ -0,0 +1,37 @@
<?php
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\FormsHandler;
use Bastion\Handlers\ImageHandler;
use Bastion\Handlers\LogsHandler;
use Bastion\Handlers\SendMessageHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
use Bastion\Handlers\AcmeShopPulseStatsHandler;
use Bastion\Handlers\TelegramCustomersHandler;
use Bastion\Handlers\TelegramHandler;
return [
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
'getCategories' => [DictionariesHandler::class, 'getCategories'],
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
'getChatId' => [TelegramHandler::class, 'getChatId'],
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
'getImage' => [ImageHandler::class, 'getImage'],
'getLogs' => [LogsHandler::class, 'getLogs'],
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
'getAcmeShopPulseStats' => [AcmeShopPulseStatsHandler::class, 'getStats'],
];

101
backend/src/cli.php Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env php
<?php
use Console\ApplicationFactory;
use Console\Commands\CacheClearCommand;
use Console\Commands\CustomerCountsCommand;
use Console\Commands\PulseSendEventsCommand;
use Console\Commands\ScheduleRunCommand;
use Console\Commands\VersionCommand;
use Console\Commands\ImagesWarmupCacheCommand;
use Console\Commands\ImagesCacheClearCommand;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Acme\ECommerceFramework\QueryBuilder\Connections\MySqlConnection;
use Acme\ECommerceFramework\Support\Arr;
use Symfony\Component\Console\Application;
if (PHP_SAPI !== 'cli') {
die("This script can only be run from CLI.\n");
}
$baseDir = __DIR__;
$debug = false;
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
require_once $baseDir . '/../../../admin/config.php';
} elseif (is_dir("$baseDir/oc_telegram_shop")) {
require_once "$baseDir/oc_telegram_shop/vendor/autoload.php";
require_once '/web/upload/admin/config.php';
} else {
throw new RuntimeException('Unable to locate application directory.');
}
// Get Settings from Database
$host = DB_HOSTNAME;
$username = DB_USERNAME;
$password = DB_PASSWORD;
$port = (int) DB_PORT;
$dbName = DB_DATABASE;
$prefix = DB_PREFIX;
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
$pdo = new PDO($dsn, $username, $password);
$connection = new MySqlConnection($pdo);
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_acmeshop_settings'");
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
$timezone = $timezone[0]['value'] ?? 'UTC';
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
$items = Arr::mergeArraysRecursively($json, [
'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 1,
'oc_timezone' => $timezone,
],
'paths' => [
'images' => DIR_IMAGE,
],
'logs' => [
'path' => DIR_LOGS,
],
'database' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => (int) DB_PORT,
],
'store' => [
'oc_store_id' => 0,
'oc_default_currency' => 'RUB',
'oc_config_tax' => false,
],
'orders' => [
'oc_customer_group_id' => 1,
],
'telegram' => [
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
],
]);
$logger = new Logger('AcmeShop_CLI', [], [], new DateTimeZone('UTC'));
$logger->pushHandler(
new RotatingFileHandler(
DIR_LOGS . '/acmeshop.log', 14, $debug ? Logger::DEBUG : Logger::INFO
),
);
$app = ApplicationFactory::create($items);
$app->setLogger($logger);
$app->boot();
$console = new Application('AcmeShop', module_version());
$console->add($app->get(VersionCommand::class));
$console->add($app->get(ScheduleRunCommand::class));
$console->add($app->get(PulseSendEventsCommand::class));
$console->add($app->get(ImagesWarmupCacheCommand::class));
$console->add($app->get(ImagesCacheClearCommand::class));
$console->add($app->get(CacheClearCommand::class));
$console->add($app->get(CustomerCountsCommand::class));
$console->run();

51
backend/src/composer.json Executable file
View File

@@ -0,0 +1,51 @@
{
"name": "nikitakiselev/oc_telegram_shop",
"version": "v2.2.1",
"autoload": {
"psr-4": {
"Acme\\ECommerceFramework\\": "framework/",
"App\\": "app/",
"Bastion\\": "bastion/",
"Console\\": "console/",
"Tests\\": "tests/"
},
"files": [
"framework/Support/helpers.php"
]
},
"authors": [
{
"name": "Nikita Kiselev",
"email": "dev@example.com"
}
],
"require": {
"doctrine/dbal": "^3.10",
"ext-json": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.9",
"intervention/image": "^2.7",
"monolog/monolog": "^2.10",
"nesbot/carbon": "^2.73",
"php": "^7.4",
"predis/predis": "^2.0",
"psr/container": "^2.0",
"psr/log": "^1.1",
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.2",
"symfony/http-foundation": "^5.4",
"symfony/console": "^5.4",
"dragonmantank/cron-expression": "^3.5",
"sentry/sentry": "^4.19"
},
"require-dev": {
"doctrine/sql-formatter": "^1.3",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "*",
"marcocesarato/php-conventional-changelog": "^1.17"
}
}

6584
backend/src/composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

122
backend/src/configs/app.php Executable file
View File

@@ -0,0 +1,122 @@
<?php
return [
'app' => [
'app_enabled' => true,
'app_name' => 'Megapay',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false,
'image_aspect_ratio' => '1:1',
'image_crop_algorithm' => 'cover',
'haptic_enabled' => true,
],
'telegram' => [
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => <<<HTML
📦 <b>Новый заказ {order_id}</b>
Магазин: <b>{store_name}</b>
<b>Покупатель</b>
Имя: {customer}
Email: {email}
Телефон: {phone}
IP: {ip}
<b>Комментарий к заказу</b>
{comment}
<b>Сумма заказа:</b> {total}
<b>Дата оформления:</b> {created_at}
HTML,
"customer_notification_template" => <<<HTML
<b>Заказ оформлен</b>
Спасибо за ваш заказ в магазине <b>{store_name}</b>.
<b>Номер заказа:</b> {order_id}
<b>Сумма заказа:</b> {total}р.
<b>Дата оформления:</b> {created_at}
Информация о заказе сохранена.
При необходимости с вами свяжутся представители магазина.
HTML,
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'feature_coupons' => true,
'feature_vouchers' => true,
'show_category_products_button' => true,
'product_interaction_mode' => 'browser',
'manager_username' => null,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
'text_manager_button' => '💬 Связаться с менеджером',
'start_message' => <<<HTML
👋 <b>Добро пожаловать!</b>
Вы находитесь в официальном магазине.
Здесь вы можете ознакомиться с товарами, узнать подробности и оформить заказ прямо в Telegram.
Нажмите кнопку ниже, чтобы перейти в каталог.
HTML,
'start_image' => null,
'start_button' => [
'text' => '🛍 Перейти в каталог',
],
],
'orders' => [
'order_default_status_id' => 1,
],
'pulse' => [
'api_key' => '',
'batch_size' => 50,
'max_attempts' => 3,
],
'mainpage_blocks' => [
[
'type' => 'products_feed',
'title' => '',
'description' => '',
'is_enabled' => true,
'goal_name' => '',
'data' => [
'max_page_count' => 10,
'image_aspect_ratio' => '1:1',
],
],
],
'cache' => [
'namespace' => 'acmeshop',
'default_lifetime' => 60 * 60 * 24,
'options' => [
'db_table' => 'acmeshop_cache_items',
],
],
'paths' => [
'images_cache' => 'cache/acmeshop',
],
'cron' => [
'mode' => 'disabled',
'api_key' => '',
],
];

View File

@@ -0,0 +1,9 @@
<?php
use Bastion\Tasks\CleanUpOldAssetsTask;
return [
'tasks' => [
CleanUpOldAssetsTask::class,
],
];

View File

@@ -0,0 +1,36 @@
<?php
namespace Console;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Acme\ECommerceFramework\Application;
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
use Acme\ECommerceFramework\Scheduler\SchedulerServiceProvider;
use Acme\ECommerceFramework\Support\Arr;
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
AppServiceProvider::class,
CacheServiceProvider::class,
TelegramServiceProvider::class,
AcmeShopPulseServiceProvider::class,
SchedulerServiceProvider::class,
ImageToolServiceProvider::class,
]);
}
}

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

View File

View File

@@ -0,0 +1,74 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\Support\Arr;
return new class extends Migration {
public function up(): void
{
$ecommerce = $this->app->get(OcRegistryDecorator::class);
$ecommerce->load->model('setting/setting');
$legacySettings = $ecommerce->model_setting_setting->getSetting('module_tgshop');
if (! $legacySettings) {
return;
}
$newSettings = $ecommerce->model_setting_setting->getSetting('module_acmeshop');
static $mapLegacyToNewSettings = [
'module_tgshop_app_icon' => 'app.app_icon',
'module_tgshop_theme_light' => 'app.theme_light',
'module_tgshop_bot_token' => 'telegram.bot_token',
'module_tgshop_status' => 'app.app_enabled',
'module_tgshop_app_name' => 'app.app_name',
'module_tgshop_theme_dark' => 'app.theme_dark',
'module_tgshop_debug' => 'app.app_debug',
'module_tgshop_chat_id' => 'telegram.chat_id',
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
'module_tgshop_enable_store' => 'store.enable_store',
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
'module_tgshop_feature_coupons' => 'store.feature_coupons',
'module_tgshop_text_no_more_products' => 'texts.text_no_more_products',
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
];
if (! $newSettings) {
$data = [];
foreach ($mapLegacyToNewSettings as $key => $value) {
if (array_key_exists($key, $legacySettings)) {
if ($key === 'module_tgshop_status') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_debug') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_chat_id') {
$newValue = (int) $legacySettings[$key];
} elseif ($key === 'module_tgshop_enable_store') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_order_default_status_id') {
$newValue = (int) $legacySettings[$key];
} elseif ($key === 'module_tgshop_feature_vouchers') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_feature_coupons') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} else {
$newValue = $legacySettings[$key];
}
Arr::set($data, $value, $newValue);
}
}
$ecommerce->model_setting_setting->editSetting('module_acmeshop', [
'module_acmeshop_settings' => $data,
]);
$this->logger->info('Выполнено обновление настроек с 1й версии модуля.');
}
$ecommerce->model_setting_setting->deleteSetting('module_tgshop');
}
};

View File

@@ -0,0 +1,19 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$legacyFilesToRemove = [
DIR_TEMPLATE . '/extension/module/tgshop_init.twig',
];
foreach ($legacyFilesToRemove as $file) {
if (file_exists($file)) {
unlink($file);
$this->logger->info('Удалён старый файл: ' . $file);
}
}
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `acmeshop_forms` (
`id` bigint(11) AUTO_INCREMENT PRIMARY KEY,
`alias` varchar(100) NOT NULL,
`friendly_name` varchar(100) NOT NULL,
`is_custom` tinyint(1) NOT NULL DEFAULT 0,
`schema` longtext NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) collate = utf8_unicode_ci
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Carbon\Carbon;
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$checkoutForm = json_encode(self::getCheckoutFormSchema(), JSON_THROW_ON_ERROR);
$this->database->insert('acmeshop_forms', [
'alias' => 'checkout',
'friendly_name' => 'Оформление заказа',
'schema' => $checkoutForm,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
private static function getCheckoutFormSchema(): array
{
return [
[
'id' => 'field_1_1763897608480',
'$formkit' => 'text',
'name' => 'firstname',
'label' => 'Имя',
'placeholder' => 'Например: Иван',
'help' => 'Введите ваше имя',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_2_1763897611020',
'$formkit' => 'text',
'name' => 'lastname',
'label' => 'Фамилия',
'placeholder' => 'Например: Иванов',
'help' => 'Введите вашу фамилию',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_5_1763897626036',
'$formkit' => 'tel',
'name' => 'telephone',
'label' => 'Телефон',
'placeholder' => 'Например: +7 (999) 000-00-00',
'validation' => 'required|length:0,32',
'help' => 'Введите ваш номер телефона.',
'prefixIcon' => 'telephone',
'locked' => true,
],
[
'id' => 'field_4_1763897617570',
'$formkit' => 'textarea',
'name' => 'comment',
'label' => 'Комментарий к заказу',
'placeholder' => 'Например: Домофон не работает',
'help' => 'Дополнительная информация к заказу',
'validation' => 'length:0,5000',
'locked' => true,
],
];
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$tableName = 'acmeshop_customers';
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `{$tableName}` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`telegram_user_id` BIGINT(20) UNSIGNED NOT NULL,
`oc_customer_id` INT(11) UNSIGNED DEFAULT NULL,
`username` VARCHAR(255) DEFAULT NULL,
`first_name` VARCHAR(255) DEFAULT NULL,
`last_name` VARCHAR(255) DEFAULT NULL,
`language_code` VARCHAR(10) DEFAULT NULL,
`is_premium` TINYINT(1) UNSIGNED DEFAULT 0,
`allows_write_to_pm` TINYINT(1) UNSIGNED DEFAULT 0,
`photo_url` VARCHAR(512) DEFAULT NULL,
`last_seen_at` DATETIME DEFAULT NULL,
`referral` VARCHAR(255) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_telegram_user_id` (`telegram_user_id`),
KEY `idx_oc_customer_id` (`oc_customer_id`),
KEY `idx_last_seen_at` (`last_seen_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,16 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
ALTER TABLE `acmeshop_customers`
ADD COLUMN `privacy_consented_at` TIMESTAMP NULL DEFAULT NULL AFTER `referral`;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$tableName = 'acmeshop_order_meta';
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `{$tableName}` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`oc_order_id` INT(11) UNSIGNED NOT NULL,
`oc_store_id` INT(11) UNSIGNED NOT NULL,
`acmeshop_customer_id` INT(11) UNSIGNED DEFAULT NULL,
`meta_data` JSON DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_oc_order_id` (`oc_order_id`),
KEY `idx_oc_store_id` (`oc_store_id`),
KEY `idx_acmeshop_customer_id` (`acmeshop_customer_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,16 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
ALTER TABLE `acmeshop_customers`
ADD COLUMN `orders_count` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `referral`;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,16 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
ALTER TABLE `acmeshop_customers`
ADD COLUMN `tracking_id` VARCHAR(64) NOT NULL AFTER `oc_customer_id`;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$tableName = 'acmeshop_events';
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `{$tableName}` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`event` VARCHAR(255) NOT NULL,
`payload` TEXT NOT NULL,
`idempotency_key` VARCHAR(64) NOT NULL,
`event_time` DATETIME NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`attempts_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`error_reason` TEXT DEFAULT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_idempotency_key` (`idempotency_key`),
KEY `idx_status` (`status`),
KEY `idx_event_time` (`event_time`),
KEY `idx_updated_at` (`updated_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Acme\ECommerceFramework\Migrations\Migration;
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
use Acme\ECommerceFramework\Support\Arr;
return new class extends Migration {
public function up(): void
{
$ecommerce = $this->app->get(OcRegistryDecorator::class);
$ecommerce->load->model('setting/setting');
$currentSettings = $ecommerce->model_setting_setting->getSetting('module_acmeshop');
if (! $currentSettings || ! isset($currentSettings['module_acmeshop_settings'])) {
$this->logger->info("Settings not found in database, migration skipped");
return;
}
$allSettings = $currentSettings['module_acmeshop_settings'];
// Проверяем наличие store.enable_store
$enableStore = Arr::get($allSettings, 'store.enable_store');
if ($enableStore !== null) {
// Определяем значение product_interaction_mode на основе store.enable_store
$productInteractionMode = filter_var($enableStore, FILTER_VALIDATE_BOOLEAN)
? 'order'
: 'browser';
// Устанавливаем product_interaction_mode, если его еще нет
if (!isset($allSettings['store']['product_interaction_mode'])) {
Arr::set($allSettings, 'store.product_interaction_mode', $productInteractionMode);
$this->logger->info("Migrated store.enable_store to product_interaction_mode: {$enableStore} -> {$productInteractionMode}");
} else {
$this->logger->info("product_interaction_mode already exists, skipping migration but removing store.enable_store");
}
// Удаляем store.enable_store из настроек
Arr::unset($allSettings, 'store.enable_store');
$this->logger->info("Removed store.enable_store from settings");
// Сохраняем обновленные настройки через ECommerce модель
$ecommerce->model_setting_setting->editSetting('module_acmeshop', [
'module_acmeshop_settings' => $allSettings,
]);
$this->logger->info("Successfully migrated store.enable_store to product_interaction_mode and removed store.enable_store from settings");
} else {
$this->logger->info("store.enable_store not found in settings, migration skipped");
}
}
};

Some files were not shown because too many files have changed in this diff Show More