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 3abcb18f0c
588 changed files with 65779 additions and 0 deletions

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'],
];