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
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:
7
backend/src/app/Adapters/OcCartAdapter.php
Executable file
7
backend/src/app/Adapters/OcCartAdapter.php
Executable file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Adapters;
|
||||
|
||||
class OcCartAdapter
|
||||
{
|
||||
}
|
||||
31
backend/src/app/Adapters/OcModelCatalogProductAdapter.php
Executable file
31
backend/src/app/Adapters/OcModelCatalogProductAdapter.php
Executable 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);
|
||||
}
|
||||
}
|
||||
44
backend/src/app/ApplicationFactory.php
Executable file
44
backend/src/app/ApplicationFactory.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
98
backend/src/app/DTO/Settings/AppDTO.php
Executable file
98
backend/src/app/DTO/Settings/AppDTO.php
Executable 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
89
backend/src/app/DTO/Settings/ConfigDTO.php
Executable file
89
backend/src/app/DTO/Settings/ConfigDTO.php
Executable 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
71
backend/src/app/DTO/Settings/DatabaseDTO.php
Executable file
71
backend/src/app/DTO/Settings/DatabaseDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
backend/src/app/DTO/Settings/LogsDTO.php
Executable file
25
backend/src/app/DTO/Settings/LogsDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
35
backend/src/app/DTO/Settings/MetricsDTO.php
Executable file
35
backend/src/app/DTO/Settings/MetricsDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
backend/src/app/DTO/Settings/OrdersDTO.php
Executable file
33
backend/src/app/DTO/Settings/OrdersDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
90
backend/src/app/DTO/Settings/StoreDTO.php
Executable file
90
backend/src/app/DTO/Settings/StoreDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
62
backend/src/app/DTO/Settings/TelegramDTO.php
Executable file
62
backend/src/app/DTO/Settings/TelegramDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
backend/src/app/DTO/Settings/TextsDTO.php
Executable file
53
backend/src/app/DTO/Settings/TextsDTO.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
backend/src/app/Exceptions/CustomExceptionHandler.php
Executable file
25
backend/src/app/Exceptions/CustomExceptionHandler.php
Executable 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;
|
||||
}
|
||||
}
|
||||
28
backend/src/app/Exceptions/OrderValidationFailedException.php
Executable file
28
backend/src/app/Exceptions/OrderValidationFailedException.php
Executable 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;
|
||||
}
|
||||
}
|
||||
24
backend/src/app/Exceptions/TelegramCustomerNotFoundException.php
Executable file
24
backend/src/app/Exceptions/TelegramCustomerNotFoundException.php
Executable 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
|
||||
);
|
||||
}
|
||||
}
|
||||
24
backend/src/app/Exceptions/TelegramCustomerWriteNotAllowedException.php
Executable file
24
backend/src/app/Exceptions/TelegramCustomerWriteNotAllowedException.php
Executable 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
|
||||
);
|
||||
}
|
||||
}
|
||||
70
backend/src/app/Filters/ProductAttribute.php
Executable file
70
backend/src/app/Filters/ProductAttribute.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
backend/src/app/Filters/ProductCategories.php
Executable file
61
backend/src/app/Filters/ProductCategories.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
backend/src/app/Filters/ProductCategory.php
Executable file
63
backend/src/app/Filters/ProductCategory.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
backend/src/app/Filters/ProductManufacturer.php
Executable file
48
backend/src/app/Filters/ProductManufacturer.php
Executable 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
backend/src/app/Filters/ProductModel.php
Executable file
45
backend/src/app/Filters/ProductModel.php
Executable 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
backend/src/app/Filters/ProductPrice.php
Executable file
161
backend/src/app/Filters/ProductPrice.php
Executable 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];
|
||||
}
|
||||
}
|
||||
68
backend/src/app/Filters/ProductQuantity.php
Executable file
68
backend/src/app/Filters/ProductQuantity.php
Executable 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);
|
||||
}
|
||||
}
|
||||
35
backend/src/app/Filters/ProductStatus.php
Executable file
35
backend/src/app/Filters/ProductStatus.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
backend/src/app/Handlers/BlocksHandler.php
Executable file
26
backend/src/app/Handlers/BlocksHandler.php
Executable 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'));
|
||||
}
|
||||
}
|
||||
54
backend/src/app/Handlers/CartHandler.php
Executable file
54
backend/src/app/Handlers/CartHandler.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
127
backend/src/app/Handlers/CategoriesHandler.php
Executable file
127
backend/src/app/Handlers/CategoriesHandler.php
Executable 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;
|
||||
}
|
||||
}
|
||||
58
backend/src/app/Handlers/CronHandler.php
Normal file
58
backend/src/app/Handlers/CronHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
187
backend/src/app/Handlers/ETLHandler.php
Executable file
187
backend/src/app/Handlers/ETLHandler.php
Executable 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/src/app/Handlers/FiltersHandler.php
Executable file
49
backend/src/app/Handlers/FiltersHandler.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
backend/src/app/Handlers/FormsHandler.php
Executable file
51
backend/src/app/Handlers/FormsHandler.php
Executable 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
backend/src/app/Handlers/HealthCheckHandler.php
Executable file
16
backend/src/app/Handlers/HealthCheckHandler.php
Executable 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);
|
||||
}
|
||||
}
|
||||
37
backend/src/app/Handlers/OrderHandler.php
Executable file
37
backend/src/app/Handlers/OrderHandler.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
backend/src/app/Handlers/PrivacyPolicyHandler.php
Executable file
73
backend/src/app/Handlers/PrivacyPolicyHandler.php
Executable 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);
|
||||
}
|
||||
}
|
||||
125
backend/src/app/Handlers/ProductsHandler.php
Executable file
125
backend/src/app/Handlers/ProductsHandler.php
Executable 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);
|
||||
}
|
||||
}
|
||||
115
backend/src/app/Handlers/SettingsHandler.php
Executable file
115
backend/src/app/Handlers/SettingsHandler.php
Executable 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
backend/src/app/Handlers/TelegramCustomerHandler.php
Executable file
134
backend/src/app/Handlers/TelegramCustomerHandler.php
Executable 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');
|
||||
}
|
||||
}
|
||||
99
backend/src/app/Handlers/TelegramHandler.php
Executable file
99
backend/src/app/Handlers/TelegramHandler.php
Executable 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([]);
|
||||
}
|
||||
}
|
||||
48
backend/src/app/Handlers/TelemetryHandler.php
Executable file
48
backend/src/app/Handlers/TelemetryHandler.php
Executable 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']);
|
||||
}
|
||||
}
|
||||
135
backend/src/app/Models/TelegramCustomer.php
Executable file
135
backend/src/app/Models/TelegramCustomer.php
Executable 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]);
|
||||
}
|
||||
}
|
||||
69
backend/src/app/ServiceProviders/AppServiceProvider.php
Executable file
69
backend/src/app/ServiceProviders/AppServiceProvider.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
backend/src/app/ServiceProviders/SettingsServiceProvider.php
Executable file
21
backend/src/app/ServiceProviders/SettingsServiceProvider.php
Executable 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
151
backend/src/app/Services/BlocksService.php
Executable file
151
backend/src/app/Services/BlocksService.php
Executable 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;
|
||||
}
|
||||
}
|
||||
314
backend/src/app/Services/CartService.php
Executable file
314
backend/src/app/Services/CartService.php
Executable 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();
|
||||
}
|
||||
}
|
||||
129
backend/src/app/Services/MegapayCustomerService.php
Executable file
129
backend/src/app/Services/MegapayCustomerService.php
Executable 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);
|
||||
}
|
||||
}
|
||||
88
backend/src/app/Services/OcCustomerService.php
Executable file
88
backend/src/app/Services/OcCustomerService.php
Executable 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;
|
||||
}
|
||||
}
|
||||
316
backend/src/app/Services/OrderCreateService.php
Executable file
316
backend/src/app/Services/OrderCreateService.php
Executable 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
backend/src/app/Services/OrderMetaService.php
Executable file
27
backend/src/app/Services/OrderMetaService.php
Executable 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);
|
||||
}
|
||||
}
|
||||
494
backend/src/app/Services/ProductsService.php
Executable file
494
backend/src/app/Services/ProductsService.php
Executable 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;
|
||||
}
|
||||
}
|
||||
449
backend/src/app/Services/SettingsSerializerService.php
Executable file
449
backend/src/app/Services/SettingsSerializerService.php
Executable 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
23
backend/src/app/Services/SettingsService.php
Executable file
23
backend/src/app/Services/SettingsService.php
Executable 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;
|
||||
}
|
||||
}
|
||||
16
backend/src/app/Support/Utils.php
Executable file
16
backend/src/app/Support/Utils.php
Executable 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');
|
||||
}
|
||||
}
|
||||
149
backend/src/app/Telegram/LinkCommand.php
Executable file
149
backend/src/app/Telegram/LinkCommand.php
Executable 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
44
backend/src/app/routes.php
Executable 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'],
|
||||
];
|
||||
Reference in New Issue
Block a user