WIP
This commit is contained in:
12
backend/src/.env.example
Executable file
12
backend/src/.env.example
Executable file
@@ -0,0 +1,12 @@
|
||||
APP_DEBUG=true
|
||||
PULSE_API_HOST=https://pulse.telecart.pro/api/
|
||||
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||
|
||||
TELECART_CACHE_DRIVER=redis
|
||||
#TELECART_REDIS_HOST=redis
|
||||
#TELECART_REDIS_PORT=6379
|
||||
#TELECART_REDIS_DATABASE=0
|
||||
|
||||
SENTRY_ENABLED=false
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENABLE_LOGS=false
|
||||
11
backend/src/.env.production
Executable file
11
backend/src/.env.production
Executable file
@@ -0,0 +1,11 @@
|
||||
APP_DEBUG=false
|
||||
PULSE_API_HOST=https://pulse.telecart.pro/api/
|
||||
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||
TELECART_CACHE_DRIVER=mysql
|
||||
TELECART_REDIS_HOST=redis
|
||||
TELECART_REDIS_PORT=6379
|
||||
TELECART_REDIS_DATABASE=0
|
||||
|
||||
SENTRY_ENABLED=false
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENABLE_LOGS=false
|
||||
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 Openguru\OpenCartFramework\Application;
|
||||
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
|
||||
use Openguru\OpenCartFramework\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,
|
||||
TeleCartPulseServiceProvider::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 Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Table;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Exceptions\CriteriaBuilderException;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Table;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\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 = telecart_customers.oc_customer_id
|
||||
), 0),
|
||||
telecart_customers.updated_at
|
||||
)
|
||||
';
|
||||
}
|
||||
|
||||
private function getCustomerQuery(?Carbon $updatedAt = null): Builder
|
||||
{
|
||||
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
|
||||
|
||||
return $this->builder->newQuery()
|
||||
->from('telecart_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',
|
||||
'telecart_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 = telecart_customers.oc_customer_id
|
||||
) AS first_order_date'
|
||||
),
|
||||
new RawExpression(
|
||||
'(
|
||||
SELECT MAX(date_added)
|
||||
FROM oc_order
|
||||
WHERE oc_order.customer_id = telecart_customers.oc_customer_id
|
||||
) AS last_order_date'
|
||||
),
|
||||
new RawExpression(
|
||||
"COALESCE((
|
||||
SELECT
|
||||
SUM(total)
|
||||
FROM
|
||||
oc_order
|
||||
WHERE
|
||||
oc_order.customer_id = telecart_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 = telecart_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 Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\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('telecart_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 Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Openguru\OpenCartFramework\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@opencart.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\TelecartCustomerService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TrackingIdGenerator;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class TelegramCustomerHandler
|
||||
{
|
||||
private TelecartCustomerService $telegramCustomerService;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramInitDataDecoder $initDataDecoder;
|
||||
|
||||
public function __construct(
|
||||
TelecartCustomerService $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 Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Contracts\TelegramCommandInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramCommandNotFoundException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
class TelemetryHandler
|
||||
{
|
||||
private TeleCartPulseService $teleCartPulseService;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
TeleCartPulseService $teleCartPulseService,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->teleCartPulseService = $teleCartPulseService;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PulseIngestException
|
||||
*/
|
||||
public function ingest(Request $request): JsonResponse
|
||||
{
|
||||
$this->teleCartPulseService->handleIngest($request->json());
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
public function heartbeat(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->teleCartPulseService->handleHeartbeat();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('TeleCart 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 Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TrackingIdGenerator;
|
||||
use RuntimeException;
|
||||
|
||||
class TelegramCustomer
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_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 покупателя в OpenCart
|
||||
* @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 Openguru\OpenCartFramework\Container\ServiceProvider;
|
||||
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
|
||||
use Openguru\OpenCartFramework\CriteriaBuilder\RulesRegistry;
|
||||
use Openguru\OpenCartFramework\Telegram\Commands\ChatIdCommand;
|
||||
use Openguru\OpenCartFramework\Telegram\Commands\StartCommand;
|
||||
use Openguru\OpenCartFramework\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 бота. Присылает ссылку на открытие Telecart магазина.'
|
||||
);
|
||||
}
|
||||
|
||||
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 Openguru\OpenCartFramework\Container\Container;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\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 Openguru\OpenCartFramework\OpenCart\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();
|
||||
}
|
||||
}
|
||||
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 Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\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 $telecartCustomerId): ?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 ($telecartCustomerId) {
|
||||
$this->builder
|
||||
->where('id', '=', $telecartCustomerId)
|
||||
->update('telecart_customers', [
|
||||
'oc_customer_id' => $lastInsertId,
|
||||
]);
|
||||
}
|
||||
|
||||
return $lastInsertId;
|
||||
}
|
||||
|
||||
public function findByTelecartCustomerId(int $telegramCustomerId): ?array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select(['oc_customers.*'])
|
||||
->from(db_table('customer'), 'oc_customers')
|
||||
->join('telecart_customers', function (JoinClause $join) {
|
||||
$join->on('telecart_customers.oc_customer_id', '=', 'oc_customers.customer_id');
|
||||
})
|
||||
->where('telecart_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 findOrCreateByTelecartCustomerId(int $telecartCustomerId, array $orderData): ?array
|
||||
{
|
||||
$ocCustomer = $this->findByTelecartCustomerId($telecartCustomerId);
|
||||
|
||||
if (! $ocCustomer) {
|
||||
$ocCustomerId = $this->create($orderData, $telecartCustomerId);
|
||||
|
||||
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 Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\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 TelecartCustomerService $telecartCustomerService;
|
||||
private OcCustomerService $ocCustomerService;
|
||||
private OrderMetaService $orderMetaService;
|
||||
|
||||
public function __construct(
|
||||
ConnectionInterface $database,
|
||||
CartService $cartService,
|
||||
OcRegistryDecorator $registry,
|
||||
SettingsService $settings,
|
||||
TelegramService $telegramService,
|
||||
LoggerInterface $logger,
|
||||
TelecartCustomerService $telegramCustomerService,
|
||||
OcCustomerService $ocCustomerService,
|
||||
OrderMetaService $orderMetaService
|
||||
) {
|
||||
$this->database = $database;
|
||||
$this->cartService = $cartService;
|
||||
$this->oc = $registry;
|
||||
$this->settings = $settings;
|
||||
$this->telegramService = $telegramService;
|
||||
$this->logger = $logger;
|
||||
$this->telecartCustomerService = $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();
|
||||
|
||||
$telecartCustomer = $this->telecartCustomerService->saveOrUpdate($telegramUserdata);
|
||||
$telecartCustomerId = (int) $telecartCustomer['id'];
|
||||
$ocCustomer = $this->ocCustomerService->findOrCreateByTelecartCustomerId($telecartCustomerId, $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, $telecartCustomerId);
|
||||
}
|
||||
|
||||
$this->telecartCustomerService->increaseOrdersCount($telecartCustomerId);
|
||||
|
||||
$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 Openguru\OpenCartFramework\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 $telecartCustomerId = null): void
|
||||
{
|
||||
$orderMeta = [
|
||||
'oc_order_id' => $orderId,
|
||||
'oc_store_id' => $storeId,
|
||||
'telecart_customer_id' => $telecartCustomerId,
|
||||
'meta_data' => json_encode($fields, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
|
||||
$this->connection->insert('telecart_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 Openguru\OpenCartFramework\CriteriaBuilder\CriteriaBuilder;
|
||||
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageNotFoundException;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageUtils;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\OpenCart\PriceCalculator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Table;
|
||||
use Openguru\OpenCartFramework\Sentry\SentryService;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Support\PaginationHelper;
|
||||
use Openguru\OpenCartFramework\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' => 'telecart',
|
||||
'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 Openguru\OpenCartFramework\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;
|
||||
}
|
||||
}
|
||||
129
backend/src/app/Services/TelecartCustomerService.php
Executable file
129
backend/src/app/Services/TelecartCustomerService.php
Executable file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\TelegramCustomer;
|
||||
use Carbon\Carbon;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Support\Utils;
|
||||
use RuntimeException;
|
||||
|
||||
class TelecartCustomerService
|
||||
{
|
||||
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 OpenCart Customer to Telegram User ID and return Telecart 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 $telecartCustomerId): void
|
||||
{
|
||||
$this->telegramCustomer->increase($telecartCustomerId, '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);
|
||||
}
|
||||
}
|
||||
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 Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Commands\TelegramCommand;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
|
||||
use Openguru\OpenCartFramework\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
|
||||
Это удобный инструмент, который поможет вам 📎 создать красивое
|
||||
сообщение с кнопкой для открытия вашего 🛒 Telecart магазина.
|
||||
|
||||
📌 Такое сообщение можно закрепить в канале или группе.
|
||||
📤 Переслать клиентам в личные сообщения.
|
||||
🚀 Или использовать повторно, когда нужно поделиться магазином.
|
||||
|
||||
Давайте начнём — отправьте текст, который вы хотите разместить в сообщении 👇
|
||||
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://telecart-labs.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'],
|
||||
];
|
||||
40
backend/src/bastion/ApplicationFactory.php
Executable file
40
backend/src/bastion/ApplicationFactory.php
Executable file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion;
|
||||
|
||||
use App\ServiceProviders\AppServiceProvider;
|
||||
use App\ServiceProviders\SettingsServiceProvider;
|
||||
use Openguru\OpenCartFramework\Application;
|
||||
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||
|
||||
class ApplicationFactory
|
||||
{
|
||||
public static function create(array $settings): Application
|
||||
{
|
||||
$defaultConfig = require __DIR__ . '/../configs/app.php';
|
||||
$routes = require __DIR__ . '/routes.php';
|
||||
|
||||
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
||||
|
||||
return (new Application($merged))
|
||||
->withRoutes(fn() => $routes)
|
||||
->withServiceProviders([
|
||||
SettingsServiceProvider::class,
|
||||
QueryBuilderServiceProvider::class,
|
||||
RouteServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
CacheServiceProvider::class,
|
||||
TelegramServiceProvider::class,
|
||||
TeleCartPulseServiceProvider::class,
|
||||
ImageToolServiceProvider::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
9
backend/src/bastion/Exceptions/BotTokenConfiguratorException.php
Executable file
9
backend/src/bastion/Exceptions/BotTokenConfiguratorException.php
Executable file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class BotTokenConfiguratorException extends Exception
|
||||
{
|
||||
}
|
||||
146
backend/src/bastion/Handlers/AutocompleteHandler.php
Executable file
146
backend/src/bastion/Handlers/AutocompleteHandler.php
Executable file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\Support\Str;
|
||||
|
||||
class AutocompleteHandler
|
||||
{
|
||||
private OcRegistryDecorator $registry;
|
||||
private Builder $queryBuilder;
|
||||
private SettingsService $settings;
|
||||
|
||||
public function __construct(
|
||||
OcRegistryDecorator $registry,
|
||||
Builder $queryBuilder,
|
||||
SettingsService $settings
|
||||
) {
|
||||
$this->registry = $registry;
|
||||
$this->queryBuilder = $queryBuilder;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getCategoriesFlat(): JsonResponse
|
||||
{
|
||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||
$categoriesFlat = $this->getFlatCategories($languageId);
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $categoriesFlat,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCategories(): JsonResponse
|
||||
{
|
||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||
|
||||
$categoriesFlat = $this->getFlatCategories($languageId);
|
||||
|
||||
$categories = $this->buildCategoryTree($categoriesFlat);
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProductsById(Request $request): JsonResponse
|
||||
{
|
||||
$productIds = $request->json('product_ids', []);
|
||||
$products = [];
|
||||
|
||||
if ($productIds) {
|
||||
$products = array_map(function ($productId) {
|
||||
$item = [
|
||||
'id' => (int) $productId,
|
||||
];
|
||||
$product = $this->registry->model_catalog_product->getProduct($productId);
|
||||
|
||||
$item['name'] = $product ? Str::htmlEntityEncode($product['name']) : 'No name';
|
||||
|
||||
return $item;
|
||||
}, $productIds);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCategoriesById(Request $request): JsonResponse
|
||||
{
|
||||
$ids = $request->json('category_ids', []);
|
||||
$items = [];
|
||||
|
||||
if ($ids) {
|
||||
$items = array_map(function ($id) {
|
||||
$item = [
|
||||
'id' => (int) $id,
|
||||
];
|
||||
$entity = $this->registry->model_catalog_category->getCategory($id);
|
||||
|
||||
$item['name'] = $entity ? Str::htmlEntityEncode($entity['name']) : 'No name';
|
||||
|
||||
return $item;
|
||||
}, $ids);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getFlatCategories(int $languageId): array
|
||||
{
|
||||
return $this->queryBuilder->newQuery()
|
||||
->select([
|
||||
'categories.category_id' => 'id',
|
||||
'categories.parent_id' => 'parent_id',
|
||||
'descriptions.name' => 'name',
|
||||
'descriptions.description' => 'description',
|
||||
])
|
||||
->from(db_table('category'), 'categories')
|
||||
->join(
|
||||
db_table('category_description') . ' AS descriptions',
|
||||
function (JoinClause $join) use ($languageId) {
|
||||
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||
->where('descriptions.language_id', '=', $languageId);
|
||||
}
|
||||
)
|
||||
->where('categories.status', '=', 1)
|
||||
->orderBy('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function buildCategoryTree(array $flat, $parentId = 0): array
|
||||
{
|
||||
$branch = [];
|
||||
|
||||
foreach ($flat as $category) {
|
||||
if ((int) $category['parent_id'] === (int) $parentId) {
|
||||
$children = $this->buildCategoryTree($flat, $category['id']);
|
||||
if ($children) {
|
||||
$category['children'] = $children;
|
||||
}
|
||||
|
||||
$branch[] = [
|
||||
'key' => (int) $category['id'],
|
||||
'label' => Str::htmlEntityEncode($category['name']),
|
||||
'data' => [
|
||||
'description' => Str::htmlEntityEncode($category['description']),
|
||||
],
|
||||
'icon' => null,
|
||||
'children' => $category['children'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
55
backend/src/bastion/Handlers/DictionariesHandler.php
Executable file
55
backend/src/bastion/Handlers/DictionariesHandler.php
Executable file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
class DictionariesHandler
|
||||
{
|
||||
private Builder $queryBuilder;
|
||||
private SettingsService $settings;
|
||||
|
||||
public function __construct(Builder $queryBuilder, SettingsService $settings)
|
||||
{
|
||||
$this->queryBuilder = $queryBuilder;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getCategories(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = $request->get('perPage', 20);
|
||||
$categoryIds = $request->json('category_ids', []);
|
||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||
|
||||
$data = $this->queryBuilder->newQuery()
|
||||
->select([
|
||||
'categories.category_id' => 'id',
|
||||
'categories.parent_id' => 'parent_id',
|
||||
'categories.image' => 'image',
|
||||
'descriptions.name' => 'name',
|
||||
'descriptions.description' => 'description',
|
||||
])
|
||||
->from(db_table('category'), 'categories')
|
||||
->join(
|
||||
db_table('category_description') . ' AS descriptions',
|
||||
function (JoinClause $join) use ($languageId) {
|
||||
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||
->where('descriptions.language_id', '=', $languageId);
|
||||
}
|
||||
)
|
||||
->where('categories.status', '=', 1)
|
||||
->when($categoryIds, function (Builder $query) use ($categoryIds) {
|
||||
$query->whereIn('categories.category_id', $categoryIds);
|
||||
})
|
||||
->orderBy('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->limit($perPage)
|
||||
->get();
|
||||
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
}
|
||||
57
backend/src/bastion/Handlers/FormsHandler.php
Executable file
57
backend/src/bastion/Handlers/FormsHandler.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use JsonException;
|
||||
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
|
||||
class FormsHandler
|
||||
{
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws EntityNotFoundException
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function getFormByAlias(Request $request): JsonResponse
|
||||
{
|
||||
$alias = 'checkout';
|
||||
//$request->json('alias');
|
||||
if (! $alias) {
|
||||
return new JsonResponse([
|
||||
'error' => 'Form alias is required',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$form = $this->builder->newQuery()
|
||||
->from('telecart_forms')
|
||||
->where('alias', '=', $alias)
|
||||
->firstOrNull();
|
||||
|
||||
if (! $form) {
|
||||
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
|
||||
}
|
||||
|
||||
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'alias' => $alias,
|
||||
'friendly_name' => $form['friendly_name'],
|
||||
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
|
||||
'schema' => $schema,
|
||||
'created_at' => $form['created_at'],
|
||||
'updated_at' => $form['updated_at'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
backend/src/bastion/Handlers/ImageHandler.php
Executable file
39
backend/src/bastion/Handlers/ImageHandler.php
Executable file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ImageHandler
|
||||
{
|
||||
private ImageFactory $image;
|
||||
|
||||
public function __construct(ImageFactory $image)
|
||||
{
|
||||
$this->image = $image;
|
||||
}
|
||||
|
||||
public function getImage(Request $request): Response
|
||||
{
|
||||
$path = $request->query->get('path');
|
||||
[$width, $height] = $this->parseSize($request->query->get('size'));
|
||||
|
||||
return $this->image
|
||||
->make($path)
|
||||
->resize($width, $height)
|
||||
->response();
|
||||
}
|
||||
|
||||
private function parseSize(?string $size = null): array
|
||||
{
|
||||
if (! $size) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$sizes = explode('x', $size);
|
||||
|
||||
return array_map(static fn($value) => is_numeric($value) ? (int) $value : null, $sizes);
|
||||
}
|
||||
}
|
||||
205
backend/src/bastion/Handlers/LogsHandler.php
Executable file
205
backend/src/bastion/Handlers/LogsHandler.php
Executable file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
class LogsHandler
|
||||
{
|
||||
private Settings $settings;
|
||||
|
||||
public function __construct(Settings $settings)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getLogs(): JsonResponse
|
||||
{
|
||||
$parsedLogs = [];
|
||||
|
||||
$logsPath = $this->findLastLogsFileInDir(
|
||||
$this->settings->get('logs.path')
|
||||
);
|
||||
|
||||
if ($logsPath) {
|
||||
$lines = $this->readLastLogsRows($logsPath, 100);
|
||||
$parsedLogs = $this->parseLogLines($lines);
|
||||
}
|
||||
|
||||
return new JsonResponse(['data' => $parsedLogs]);
|
||||
}
|
||||
|
||||
private function parseLogLines(array $lines): array
|
||||
{
|
||||
$parsed = [];
|
||||
|
||||
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($pattern, $line, $matches)) {
|
||||
$datetime = $matches[1] ?? '';
|
||||
$channel = $matches[2] ?? '';
|
||||
$level = $matches[3] ?? '';
|
||||
$rest = $matches[4] ?? '';
|
||||
|
||||
// Извлекаем сообщение и контекст
|
||||
// Контекст начинается с { и заканчивается соответствующим }
|
||||
$message = $rest;
|
||||
$context = null;
|
||||
|
||||
// Ищем JSON контекст (начинается с {, может быть после пробела или сразу)
|
||||
$jsonStart = strpos($rest, ' {');
|
||||
if ($jsonStart === false) {
|
||||
$jsonStart = strpos($rest, '{');
|
||||
} else {
|
||||
$jsonStart++; // Пропускаем пробел перед {
|
||||
}
|
||||
|
||||
if ($jsonStart !== false) {
|
||||
$message = trim(substr($rest, 0, $jsonStart));
|
||||
$jsonPart = substr($rest, $jsonStart);
|
||||
|
||||
// Находим конец JSON объекта, учитывая вложенность
|
||||
$jsonEnd = $this->findJsonEnd($jsonPart);
|
||||
if ($jsonEnd !== false) {
|
||||
$contextJson = substr($jsonPart, 0, $jsonEnd + 1);
|
||||
$decoded = json_decode($contextJson, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$context = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Форматируем дату для отображения (убираем микросекунды и временную зону для читаемости)
|
||||
$formattedDatetime = $this->formatDateTime($datetime);
|
||||
|
||||
$message = rtrim($message, ' [] []');
|
||||
|
||||
$parsed[] = [
|
||||
'datetime' => $formattedDatetime,
|
||||
'datetime_raw' => $datetime,
|
||||
'channel' => $channel,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
'raw' => $line,
|
||||
];
|
||||
} else {
|
||||
// Если строка не соответствует формату, сохраняем как есть
|
||||
$parsed[] = [
|
||||
'datetime' => '',
|
||||
'datetime_raw' => '',
|
||||
'channel' => '',
|
||||
'level' => '',
|
||||
'message' => $line,
|
||||
'context' => null,
|
||||
'raw' => $line,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит позицию конца JSON объекта, учитывая вложенность
|
||||
* @param string $json JSON строка, начинающаяся с {
|
||||
* @return int|false Позиция закрывающей скобки или false, если не найдено
|
||||
*/
|
||||
private function findJsonEnd(string $json)
|
||||
{
|
||||
$depth = 0;
|
||||
$inString = false;
|
||||
$escape = false;
|
||||
$len = strlen($json);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$char = $json[$i];
|
||||
|
||||
if ($escape) {
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$inString = !$inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '{') {
|
||||
$depth++;
|
||||
} elseif ($char === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function formatDateTime(string $datetime): string
|
||||
{
|
||||
// Парсим ISO 8601 формат: 2025-11-23T14:28:21.772518+00:00
|
||||
// Преобразуем в более читаемый формат: 2025-11-23 14:28:21
|
||||
if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/', $datetime, $dateMatches)) {
|
||||
return $dateMatches[1] . ' ' . $dateMatches[2];
|
||||
}
|
||||
|
||||
return $datetime;
|
||||
}
|
||||
|
||||
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
|
||||
{
|
||||
$f = fopen($path, 'rb');
|
||||
if (! $f) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lineCount = 0;
|
||||
$chunk = '';
|
||||
|
||||
fseek($f, 0, SEEK_END);
|
||||
$filesize = ftell($f);
|
||||
|
||||
while ($filesize > 0 && $lineCount < $lines) {
|
||||
$seek = max($filesize - $buffer, 0);
|
||||
$readLength = $filesize - $seek;
|
||||
|
||||
fseek($f, $seek);
|
||||
$chunk = fread($f, $readLength) . $chunk;
|
||||
|
||||
$filesize = $seek;
|
||||
$lineCount = substr_count($chunk, "\n");
|
||||
}
|
||||
|
||||
fclose($f);
|
||||
|
||||
$linesArray = explode("\n", $chunk);
|
||||
|
||||
return array_slice($linesArray, -$lines);
|
||||
}
|
||||
|
||||
private function findLastLogsFileInDir(string $dir): ?string
|
||||
{
|
||||
$files = glob($dir . '/telecart-*.log');
|
||||
|
||||
return $files ? end($files) : null;
|
||||
}
|
||||
}
|
||||
123
backend/src/bastion/Handlers/SendMessageHandler.php
Executable file
123
backend/src/bastion/Handlers/SendMessageHandler.php
Executable file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Exceptions\TelegramCustomerNotFoundException;
|
||||
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
|
||||
use App\Models\TelegramCustomer;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Handler для отправки сообщений Telegram-пользователям из админ-панели
|
||||
*
|
||||
* @package Bastion\Handlers
|
||||
*/
|
||||
class SendMessageHandler
|
||||
{
|
||||
private TelegramService $telegramService;
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
TelegramService $telegramService,
|
||||
TelegramCustomer $telegramCustomerModel,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->telegramService = $telegramService;
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение Telegram-пользователю
|
||||
*
|
||||
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
|
||||
* @return JsonResponse JSON ответ с результатом операции
|
||||
* @throws TelegramCustomerNotFoundException Если пользователь не найден
|
||||
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
|
||||
* @throws RuntimeException Если данные невалидны
|
||||
* @throws \Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function sendMessage(Request $request): JsonResponse
|
||||
{
|
||||
$customerId = $this->extractCustomerId($request);
|
||||
$message = $this->extractMessage($request);
|
||||
|
||||
// Находим запись по ID
|
||||
$customer = $this->telegramCustomerModel->findById($customerId);
|
||||
if (! $customer) {
|
||||
throw new TelegramCustomerNotFoundException($customerId);
|
||||
}
|
||||
|
||||
$telegramUserId = (int) $customer['telegram_user_id'];
|
||||
|
||||
// Проверяем, что пользователь разрешил писать ему в PM
|
||||
if (! $customer['allows_write_to_pm']) {
|
||||
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
|
||||
}
|
||||
|
||||
// Отправляем сообщение (telegram_user_id используется как chat_id)
|
||||
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
|
||||
$this->telegramService->sendMessage(
|
||||
$telegramUserId,
|
||||
$message,
|
||||
);
|
||||
|
||||
$this->logger->info('Message sent to Telegram user', [
|
||||
'oc_customer_id' => $customerId,
|
||||
'telegram_user_id' => $telegramUserId,
|
||||
'message_length' => strlen($message),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Message sent successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь ID записи из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return int ID записи в таблице telecart_customers
|
||||
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||
*/
|
||||
private function extractCustomerId(Request $request): int
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
|
||||
|
||||
if ($customerId <= 0) {
|
||||
throw new RuntimeException('Customer ID is required and must be positive');
|
||||
}
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь сообщение из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return string Текст сообщения
|
||||
* @throws RuntimeException Если сообщение отсутствует или пустое
|
||||
*/
|
||||
private function extractMessage(Request $request): string
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
|
||||
|
||||
if (empty($message)) {
|
||||
throw new RuntimeException('Message is required and cannot be empty');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
306
backend/src/bastion/Handlers/SettingsHandler.php
Executable file
306
backend/src/bastion/Handlers/SettingsHandler.php
Executable file
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||
use Bastion\Services\BotTokenConfigurator;
|
||||
use Bastion\Services\CronApiKeyRegenerator;
|
||||
use Bastion\Services\SettingsService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SettingsHandler
|
||||
{
|
||||
private BotTokenConfigurator $botTokenConfigurator;
|
||||
private CronApiKeyRegenerator $cronApiKeyRegenerator;
|
||||
private Settings $settings;
|
||||
private SettingsService $settingsUpdateService;
|
||||
private CacheInterface $cache;
|
||||
private LoggerInterface $logger;
|
||||
private Builder $builder;
|
||||
private ConnectionInterface $connection;
|
||||
private ScheduledJob $scheduledJob;
|
||||
|
||||
public function __construct(
|
||||
BotTokenConfigurator $botTokenConfigurator,
|
||||
CronApiKeyRegenerator $cronApiKeyRegenerator,
|
||||
Settings $settings,
|
||||
SettingsService $settingsUpdateService,
|
||||
CacheInterface $cache,
|
||||
LoggerInterface $logger,
|
||||
Builder $builder,
|
||||
ConnectionInterface $connection,
|
||||
ScheduledJob $scheduledJob
|
||||
) {
|
||||
$this->botTokenConfigurator = $botTokenConfigurator;
|
||||
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
|
||||
$this->settings = $settings;
|
||||
$this->settingsUpdateService = $settingsUpdateService;
|
||||
$this->cache = $cache;
|
||||
$this->logger = $logger;
|
||||
$this->builder = $builder;
|
||||
$this->connection = $connection;
|
||||
$this->scheduledJob = $scheduledJob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
|
||||
*/
|
||||
public function regenerateCronScheduleUrl(Request $request): JsonResponse
|
||||
{
|
||||
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
|
||||
$scheduleUrl = $this->buildCronScheduleUrl(
|
||||
$this->settings->get('app.shop_base_url', ''),
|
||||
$newApiKey
|
||||
);
|
||||
|
||||
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
|
||||
}
|
||||
|
||||
public function configureBotToken(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$data = $this->botTokenConfigurator->configure(trim($request->json('botToken', '')));
|
||||
|
||||
return new JsonResponse($data);
|
||||
} catch (BotTokenConfiguratorException $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (Exception $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSettingsForm(): JsonResponse
|
||||
{
|
||||
$data = Arr::getWithKeys($this->settings->getAll(), [
|
||||
'app',
|
||||
'telegram',
|
||||
'metrics',
|
||||
'store',
|
||||
'orders',
|
||||
'texts',
|
||||
'sliders',
|
||||
'mainpage_blocks',
|
||||
'pulse',
|
||||
'cron',
|
||||
]);
|
||||
|
||||
if (!isset($data['cron']['mode'])) {
|
||||
$data['cron']['mode'] = 'disabled';
|
||||
}
|
||||
|
||||
$data['forms'] = [];
|
||||
|
||||
// Add CRON system details (read-only)
|
||||
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
|
||||
$data['cron']['last_run'] = $this->getLastCronRunDate();
|
||||
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
|
||||
$this->settings->get('app.shop_base_url', ''),
|
||||
$this->settings->get('cron.api_key', '')
|
||||
);
|
||||
|
||||
$data['scheduled_jobs'] = $this->scheduledJob->all();
|
||||
|
||||
$forms = $this->builder->newQuery()
|
||||
->from('telecart_forms')
|
||||
->get();
|
||||
|
||||
if ($forms) {
|
||||
foreach ($forms as $form) {
|
||||
try {
|
||||
$schema = json_decode($form['schema'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $exception) {
|
||||
$schema = [];
|
||||
}
|
||||
|
||||
$data['forms'][$form['alias']] = [
|
||||
'alias' => $form['alias'],
|
||||
'friendly_name' => $form['friendly_name'],
|
||||
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
|
||||
'schema' => $schema,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
|
||||
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
|
||||
{
|
||||
$base = rtrim($shopBaseUrl, '/');
|
||||
if ($base === '') {
|
||||
return '';
|
||||
}
|
||||
$params = http_build_query([
|
||||
'route' => 'extension/tgshop/handle',
|
||||
'api_action' => 'runSchedule',
|
||||
'api_key' => $apiKey,
|
||||
]);
|
||||
|
||||
return $base . '/index.php?' . $params;
|
||||
}
|
||||
|
||||
public function saveSettingsForm(Request $request): JsonResponse
|
||||
{
|
||||
$input = $request->json();
|
||||
|
||||
$this->validate($input);
|
||||
|
||||
// Remove dynamic properties before saving
|
||||
if (isset($input['cron'])) {
|
||||
unset($input['cron']['cli_path']);
|
||||
unset($input['cron']['last_run']);
|
||||
unset($input['cron']['schedule_url']);
|
||||
}
|
||||
|
||||
$this->settingsUpdateService->update(
|
||||
Arr::getWithKeys($input, [
|
||||
'app',
|
||||
'telegram',
|
||||
'metrics',
|
||||
'store',
|
||||
'orders',
|
||||
'texts',
|
||||
'sliders',
|
||||
'mainpage_blocks',
|
||||
'pulse',
|
||||
'cron',
|
||||
]),
|
||||
);
|
||||
|
||||
// Update forms
|
||||
$forms = Arr::get($input, 'forms', []);
|
||||
foreach ($forms as $form) {
|
||||
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
|
||||
$this->builder->newQuery()
|
||||
->where('alias', '=', $form['alias'])
|
||||
->update('telecart_forms', [
|
||||
'friendly_name' => $form['friendly_name'],
|
||||
'is_custom' => $form['is_custom'],
|
||||
'schema' => $schema,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update scheduled jobs is_enabled and cron_expression
|
||||
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
|
||||
foreach ($scheduledJobs as $job) {
|
||||
$id = (int) ($job['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if ($isEnabled) {
|
||||
$this->scheduledJob->enable($id);
|
||||
} else {
|
||||
$this->scheduledJob->disable($id);
|
||||
}
|
||||
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
|
||||
if ($cronExpression !== '') {
|
||||
$this->scheduledJob->updateCronExpression($id, $cronExpression);
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
}
|
||||
|
||||
public function resetCache(): JsonResponse
|
||||
{
|
||||
$this->cache->clear();
|
||||
|
||||
$this->logger->info('Cache cleared manually.');
|
||||
|
||||
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
private function getLastCronRunDate(): ?string
|
||||
{
|
||||
try {
|
||||
// Since we are in SettingsHandler, we already have access to container or we can inject SchedulerService
|
||||
// But SettingsHandler is constructed via DI. Let's add SchedulerService to constructor.
|
||||
// For now, let's use global retrieval via cache if possible, or assume it's injected.
|
||||
// But wait, getLastCronRunDate logic was in controller.
|
||||
// SchedulerService stores last run in cache. We have $this->cache here.
|
||||
|
||||
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
|
||||
|
||||
if ($lastRunTimestamp) {
|
||||
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSystemInfo(): JsonResponse
|
||||
{
|
||||
$info = [];
|
||||
|
||||
$info['PHP Version'] = PHP_VERSION;
|
||||
$info['PHP SAPI'] = PHP_SAPI;
|
||||
$info['PHP Memory Limit'] = ini_get('memory_limit');
|
||||
$info['PHP Memory Usage'] = $this->formatBytes(memory_get_usage(true));
|
||||
$info['PHP Peak Memory Usage'] = $this->formatBytes(memory_get_peak_usage(true));
|
||||
$info['PHP Max Execution Time'] = ini_get('max_execution_time') . 's';
|
||||
$info['PHP Upload Max Filesize'] = ini_get('upload_max_filesize');
|
||||
$info['PHP Post Max Size'] = ini_get('post_max_size');
|
||||
|
||||
try {
|
||||
$mysqlVersion = $this->connection->select('SELECT VERSION() as version');
|
||||
$info['MySQL Version'] = $mysqlVersion[0]['version'] ?? 'Unknown';
|
||||
} catch (Exception $e) {
|
||||
$info['MySQL Version'] = 'Error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
$cacheDriver = env('TELECART_CACHE_DRIVER', 'mysql');
|
||||
$cacheClass = get_class($this->cache);
|
||||
$info['Cache Driver'] = $cacheDriver . ' (' . basename(str_replace('\\', '/', $cacheClass)) . ')';
|
||||
|
||||
$info['Module Version'] = module_version();
|
||||
$info['OpenCart Version'] = defined('VERSION') ? VERSION : 'Unknown';
|
||||
$info['OpenCart Core Version'] = defined('VERSION_CORE') ? VERSION_CORE : 'Unknown';
|
||||
|
||||
$info['Operating System'] = PHP_OS;
|
||||
$info['Server Software'] = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown';
|
||||
$info['Document Root'] = $_SERVER['DOCUMENT_ROOT'] ?? 'Unknown';
|
||||
|
||||
$info['PHP Timezone'] = date_default_timezone_get();
|
||||
$info['Server Time'] = date('Y-m-d H:i:s');
|
||||
$info['UTC Time'] = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$info['Loaded PHP Extensions'] = implode(', ', get_loaded_extensions());
|
||||
|
||||
$infoText = '';
|
||||
foreach ($info as $key => $value) {
|
||||
$infoText .= $key . ': ' . $value . "\n";
|
||||
}
|
||||
|
||||
return new JsonResponse(['data' => $infoText]);
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
60
backend/src/bastion/Handlers/StatsHandler.php
Executable file
60
backend/src/bastion/Handlers/StatsHandler.php
Executable file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
|
||||
class StatsHandler
|
||||
{
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
public function getDashboardStats(): JsonResponse
|
||||
{
|
||||
$data = [
|
||||
'orders_count' => 0,
|
||||
'orders_total_amount' => 0,
|
||||
'customers_count' => 0,
|
||||
];
|
||||
|
||||
$ordersTotalAmount = $this->builder->newQuery()
|
||||
->select([
|
||||
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_total_count'),
|
||||
new RawExpression('SUM(orders.total) AS orders_total_amount'),
|
||||
])
|
||||
->from(db_table('order'), 'orders')
|
||||
->join('telecart_customers', function (JoinClause $join) {
|
||||
$join->on('orders.customer_id', '=', 'telecart_customers.oc_customer_id');
|
||||
})
|
||||
->join('telecart_order_meta', function (JoinClause $join) {
|
||||
$join->on('orders.order_id', '=', 'telecart_order_meta.oc_order_id')
|
||||
->whereRaw('orders.store_id = telecart_order_meta.oc_store_id');
|
||||
})
|
||||
->firstOrNull();
|
||||
|
||||
|
||||
if ($ordersTotalAmount) {
|
||||
$data = [
|
||||
'orders_count' => (int) $ordersTotalAmount['orders_total_count'],
|
||||
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
|
||||
'customers_count' => $this->countCustomersCount(),
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
|
||||
private function countCustomersCount(): int
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from('telecart_customers')
|
||||
->count();
|
||||
}
|
||||
}
|
||||
33
backend/src/bastion/Handlers/TeleCartPulseStatsHandler.php
Executable file
33
backend/src/bastion/Handlers/TeleCartPulseStatsHandler.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartEvent;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
class TeleCartPulseStatsHandler
|
||||
{
|
||||
private TeleCartEvent $eventModel;
|
||||
private CacheInterface $cache;
|
||||
private const CACHE_KEY = 'telecart_pulse_stats';
|
||||
private const CACHE_TTL = 3600; // 1 час
|
||||
|
||||
public function __construct(TeleCartEvent $eventModel, CacheInterface $cache)
|
||||
{
|
||||
$this->eventModel = $eventModel;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
public function getStats(): JsonResponse
|
||||
{
|
||||
$stats = $this->cache->get(self::CACHE_KEY);
|
||||
|
||||
if ($stats === null) {
|
||||
$stats = $this->eventModel->getStats();
|
||||
$this->cache->set(self::CACHE_KEY, $stats, self::CACHE_TTL);
|
||||
}
|
||||
|
||||
return new JsonResponse(['data' => $stats]);
|
||||
}
|
||||
}
|
||||
344
backend/src/bastion/Handlers/TelegramCustomersHandler.php
Executable file
344
backend/src/bastion/Handlers/TelegramCustomersHandler.php
Executable file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
class TelegramCustomersHandler
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_customers';
|
||||
private const DEFAULT_PAGE = 1;
|
||||
private const DEFAULT_ROWS = 20;
|
||||
private const DEFAULT_SORT_FIELD = 'last_seen_at';
|
||||
private const DEFAULT_SORT_ORDER = 'DESC';
|
||||
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
|
||||
*
|
||||
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
|
||||
* @return JsonResponse JSON ответ с данными и метаинформацией
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
|
||||
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
|
||||
$first = ($page - 1) * $rows;
|
||||
|
||||
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
|
||||
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
|
||||
|
||||
$filters = $request->json('filters', []);
|
||||
$globalFilter = Arr::get($filters, 'global.value');
|
||||
|
||||
// Создаем базовый query с фильтрами
|
||||
$query = $this->buildBaseQuery();
|
||||
$this->applyFilters($query, $filters, $globalFilter);
|
||||
|
||||
// Получаем общее количество записей
|
||||
$countQuery = $this->buildCountQuery();
|
||||
$this->applyFilters($countQuery, $filters, $globalFilter);
|
||||
$totalRecords = (int) ($countQuery->value('total') ?? 0);
|
||||
|
||||
// Применяем сортировку и пагинацию
|
||||
$customers = $query
|
||||
->orderBy($sortField, $sortOrder)
|
||||
->offset($first)
|
||||
->limit($rows)
|
||||
->get();
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'data' => $this->mapToResponse($customers),
|
||||
'totalRecords' => $totalRecords,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать базовый query для выборки данных
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildBaseQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([
|
||||
'id',
|
||||
'telegram_user_id',
|
||||
'oc_customer_id',
|
||||
'tracking_id',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'language_code',
|
||||
'is_premium',
|
||||
'allows_write_to_pm',
|
||||
'photo_url',
|
||||
'last_seen_at',
|
||||
'referral',
|
||||
'orders_count',
|
||||
'privacy_consented_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать query для подсчета общего количества записей
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildCountQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([new RawExpression('COUNT(*) as total')])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры к query
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @param string|null $globalFilter Глобальный фильтр поиска
|
||||
* @return void
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
|
||||
{
|
||||
// Применяем глобальный фильтр
|
||||
if ($globalFilter) {
|
||||
$this->applyGlobalFilter($query, $globalFilter);
|
||||
}
|
||||
|
||||
// Применяем фильтры по колонкам
|
||||
$this->applyColumnFilters($query, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить глобальный фильтр поиска
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $searchTerm Поисковый запрос
|
||||
* @return void
|
||||
*/
|
||||
private function applyGlobalFilter(Builder $query, string $searchTerm): void
|
||||
{
|
||||
$query->whereNested(function ($q) use ($searchTerm) {
|
||||
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('username', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры по колонкам
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilters(Builder $query, array $filters): void
|
||||
{
|
||||
foreach ($filters as $field => $filter) {
|
||||
if ($field === 'global') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка сложных фильтров (constraints)
|
||||
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
|
||||
$this->applyConstraintFilters($query, $field, $filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка простых фильтров (обратная совместимость)
|
||||
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $filter['value'];
|
||||
$matchMode = Arr::get($filter, 'matchMode', 'contains');
|
||||
|
||||
$this->applyColumnFilter($query, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить сложные фильтры с условиями (AND/OR)
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param array $filter Данные фильтра
|
||||
* @return void
|
||||
*/
|
||||
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
|
||||
{
|
||||
$operator = strtolower($filter['operator'] ?? 'and');
|
||||
$constraints = $filter['constraints'];
|
||||
|
||||
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
|
||||
$activeConstraints = array_filter($constraints, function ($constraint) {
|
||||
if (!isset($constraint['value'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $constraint['value'];
|
||||
// null означает "любой", пропускаем
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
// Пустая строка пропускаем
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
// false - валидное значение для boolean полей
|
||||
return true;
|
||||
});
|
||||
|
||||
if (empty($activeConstraints)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
|
||||
// Для первого элемента всегда используем where, чтобы начать группу
|
||||
$first = true;
|
||||
|
||||
foreach ($activeConstraints as $constraint) {
|
||||
$value = $constraint['value'];
|
||||
$matchMode = $constraint['matchMode'] ?? 'contains';
|
||||
|
||||
if ($first) {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
$first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operator === 'or') {
|
||||
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
|
||||
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
|
||||
});
|
||||
} else {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтр для одной колонки
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param mixed $value Значение фильтра
|
||||
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
|
||||
{
|
||||
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
|
||||
$likeValue = $this->buildLikeValue($value, $matchMode);
|
||||
$query->where($field, 'LIKE', $likeValue);
|
||||
} elseif ($matchMode === 'equals') {
|
||||
$query->where($field, '=', $value);
|
||||
} elseif ($matchMode === 'notEquals') {
|
||||
$query->where($field, '!=', $value);
|
||||
} elseif ($matchMode === 'gt') {
|
||||
$query->where($field, '>', $value);
|
||||
} elseif ($matchMode === 'lt') {
|
||||
$query->where($field, '<', $value);
|
||||
} elseif ($matchMode === 'gte') {
|
||||
$query->where($field, '>=', $value);
|
||||
} elseif ($matchMode === 'lte') {
|
||||
$query->where($field, '<=', $value);
|
||||
} elseif ($matchMode === 'dateIs') {
|
||||
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->where($field, '>=', $date . ' 00:00:00')
|
||||
->where($field, '<=', $date . ' 23:59:59');
|
||||
} elseif ($matchMode === 'dateIsNot') {
|
||||
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->whereNested(function ($q) use ($field, $date) {
|
||||
$q->where($field, '<', $date . ' 00:00:00')
|
||||
->orWhere($field, '>', $date . ' 23:59:59');
|
||||
});
|
||||
} elseif ($matchMode === 'dateBefore') {
|
||||
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
|
||||
} elseif ($matchMode === 'dateAfter') {
|
||||
// "После" означает после конца указанного дня
|
||||
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Построить значение для LIKE запроса
|
||||
*
|
||||
* @param string $value Значение
|
||||
* @param string $matchMode Режим совпадения
|
||||
* @return string
|
||||
*/
|
||||
private function buildLikeValue(string $value, string $matchMode): string
|
||||
{
|
||||
if ($matchMode === 'startsWith') {
|
||||
return "{$value}%";
|
||||
}
|
||||
|
||||
if ($matchMode === 'endsWith') {
|
||||
return "%{$value}";
|
||||
}
|
||||
|
||||
return "%{$value}%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализовать порядок сортировки
|
||||
*
|
||||
* @param string $sortOrder Порядок сортировки
|
||||
* @return string 'ASC' или 'DESC'
|
||||
*/
|
||||
private function normalizeSortOrder(string $sortOrder): string
|
||||
{
|
||||
$normalized = strtoupper($sortOrder);
|
||||
|
||||
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
|
||||
}
|
||||
|
||||
private function mapToResponse(array $customers): array
|
||||
{
|
||||
return array_map(static function (array $customer) {
|
||||
return [
|
||||
'id' => (int) $customer['id'],
|
||||
'telegram_user_id' => (int) $customer['telegram_user_id'],
|
||||
'oc_customer_id' => (int) $customer['oc_customer_id'],
|
||||
'tracking_id' => $customer['tracking_id'],
|
||||
'username' => $customer['username'],
|
||||
'first_name' => $customer['first_name'],
|
||||
'last_name' => $customer['last_name'],
|
||||
'language_code' => $customer['language_code'],
|
||||
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||||
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
|
||||
'photo_url' => $customer['photo_url'],
|
||||
'last_seen_at' => $customer['last_seen_at'],
|
||||
'referral' => $customer['referral'],
|
||||
'orders_count' => (int) $customer['orders_count'],
|
||||
'privacy_consented_at' => $customer['privacy_consented_at'],
|
||||
'created_at' => $customer['created_at'],
|
||||
'updated_at' => $customer['updated_at'],
|
||||
];
|
||||
}, $customers);
|
||||
}
|
||||
}
|
||||
140
backend/src/bastion/Handlers/TelegramHandler.php
Executable file
140
backend/src/bastion/Handlers/TelegramHandler.php
Executable file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
|
||||
class TelegramHandler
|
||||
{
|
||||
private CacheInterface $cache;
|
||||
private TelegramService $telegramService;
|
||||
private SettingsService $settings;
|
||||
|
||||
public function __construct(CacheInterface $cache, TelegramService $telegramService, SettingsService $settings)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
$this->telegramService = $telegramService;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getChatId(): JsonResponse
|
||||
{
|
||||
$message = $this->cache->get('tg_latest_msg');
|
||||
|
||||
if (! $message) {
|
||||
return new JsonResponse([
|
||||
// phpcs:ignore Generic.Files.LineLength
|
||||
'message' => 'Сообщение не найдено. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд. У Вас есть 60 секунд после отправки сообщения в чат, чтобы нажать на кнопку! Это сделано в целях безопасности.'
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$text = Arr::get($message, 'text');
|
||||
if ($text !== 'opencart_get_chatid') {
|
||||
return new JsonResponse(
|
||||
['message' => 'Последнее сообщение в чате не содержит кодовое слово.'],
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$chatId = Arr::get($message, 'chat.id');
|
||||
|
||||
if (! $chatId) {
|
||||
return new JsonResponse([
|
||||
// phpcs:ignore Generic.Files.LineLength
|
||||
'message' => 'ChatID не найден. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд.'
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'chat_id' => $chatId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testTgMessage(Request $request): JsonResponse
|
||||
{
|
||||
$template = $request->json('template', 'Нет шаблона');
|
||||
$token = $request->json('token');
|
||||
$chatId = $request->json('chat_id');
|
||||
|
||||
if (! $token) {
|
||||
return new JsonResponse([
|
||||
'message' => 'Не задан Telegram BotToken',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $chatId) {
|
||||
return new JsonResponse([
|
||||
'message' => 'Не задан ChatID.',
|
||||
]);
|
||||
}
|
||||
|
||||
$variables = [
|
||||
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
|
||||
'{order_id}' => 777,
|
||||
'{customer}' => 'Иван Васильевич',
|
||||
'{email}' => 'telegram@opencart.com',
|
||||
'{phone}' => '+79999999999',
|
||||
'{comment}' => 'Это тестовый заказ',
|
||||
'{address}' => 'г. Москва',
|
||||
'{total}' => 100000,
|
||||
'{ip}' => '127.0.0.1',
|
||||
'{created_at}' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||
|
||||
try {
|
||||
$this->telegramService
|
||||
->setBotToken($token)
|
||||
->sendMessage($chatId, $message);
|
||||
|
||||
return new JsonResponse([
|
||||
'message' => 'Сообщение отправлено. Проверьте Telegram.',
|
||||
]);
|
||||
} catch (ClientException $exception) {
|
||||
$json = json_decode($exception->getResponse()->getBody(), true);
|
||||
|
||||
return new JsonResponse([
|
||||
'message' => $json['description'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return new JsonResponse([
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
* @throws TelegramClientException
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function tgGetMe(): JsonResponse
|
||||
{
|
||||
if (! $this->settings->config()->getTelegram()->getBotToken()) {
|
||||
return new JsonResponse(['data' => null]);
|
||||
}
|
||||
|
||||
$data = $this->cache->get('tg_me_info');
|
||||
|
||||
if (! $data) {
|
||||
$data = $this->telegramService->exec('getMe');
|
||||
$this->cache->set('tg_me_info', $data, 60 * 5);
|
||||
}
|
||||
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
}
|
||||
175
backend/src/bastion/ScheduledTasks/TeleCartPulseSendEventsTask.php
Executable file
175
backend/src/bastion/ScheduledTasks/TeleCartPulseSendEventsTask.php
Executable file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\ScheduledTasks;
|
||||
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Scheduler\TaskInterface;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartEvent;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseEventsSender;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
class TeleCartPulseSendEventsTask implements TaskInterface
|
||||
{
|
||||
private TeleCartEvent $eventModel;
|
||||
private TeleCartPulseEventsSender $eventsSender;
|
||||
private LoggerInterface $logger;
|
||||
private CacheInterface $cache;
|
||||
private Settings $settings;
|
||||
private int $maxAttempts;
|
||||
private int $batchSize;
|
||||
|
||||
public function __construct(
|
||||
Settings $settings,
|
||||
TeleCartEvent $eventModel,
|
||||
TeleCartPulseEventsSender $eventsSender,
|
||||
LoggerInterface $logger,
|
||||
CacheInterface $cache
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->eventModel = $eventModel;
|
||||
$this->eventsSender = $eventsSender;
|
||||
$this->logger = $logger;
|
||||
$this->cache = $cache;
|
||||
|
||||
// Получаем конфигурацию из настроек пользователя
|
||||
$this->maxAttempts = (int) $this->settings->get('pulse.max_attempts', env('PULSE_MAX_ATTEMPTS', 3));
|
||||
$this->batchSize = (int) $this->settings->get('pulse.batch_size', env('PULSE_BATCH_SIZE', 50));
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
try {
|
||||
// Получаем события со статусом pending
|
||||
$events = $this->eventModel->findPending($this->batchSize);
|
||||
|
||||
if (empty($events)) {
|
||||
$this->logger->debug('No pending events to send');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = count($events);
|
||||
$this->logger->info("Processing pending events: $count", [
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
$processed = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($events as $event) {
|
||||
try {
|
||||
$result = $this->processEvent($event);
|
||||
$result ? $succeeded++ : $failed++;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Failed to process event {$event['id']}: " . $e->getMessage(), [
|
||||
'event_id' => $event['id'],
|
||||
'event' => $event['event'] ?? null,
|
||||
'payload' => $event['payload'] ?? null,
|
||||
'exception' => $e,
|
||||
]);
|
||||
$failed++;
|
||||
} finally {
|
||||
$processed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->info("Events processing completed", [
|
||||
'processed' => $processed,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("TeleCartPulseSendEventsTask failed: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
} finally {
|
||||
// Сбрасываем кеш статистики после каждого прогона
|
||||
$this->clearStatsCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать одно событие
|
||||
*
|
||||
* @param array $event Данные события из БД
|
||||
* @return bool true если событие успешно отправлено, false если требуется повторная попытка
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function processEvent(array $event): bool
|
||||
{
|
||||
$eventId = (int) $event['id'];
|
||||
$attemptsCount = (int) $event['attempts_count'];
|
||||
|
||||
try {
|
||||
// Пытаемся отправить событие
|
||||
$success = $this->eventsSender->sendEvent($event);
|
||||
|
||||
if ($success) {
|
||||
// Успешная отправка
|
||||
$this->eventModel->updateStatus($eventId, 'sent');
|
||||
$this->logger->debug("Event {$eventId} sent successfully", [
|
||||
'event_id' => $eventId,
|
||||
'event' => $event['event'],
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// TeleCart Pulse не вернул подтверждение
|
||||
$errorReason = 'No confirmation received from TeleCart Pulse';
|
||||
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||
} catch (GuzzleException $e) {
|
||||
// Ошибка HTTP запроса
|
||||
$errorReason = 'HTTP error: ' . $e->getMessage();
|
||||
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||
} catch (Throwable $e) {
|
||||
// Другие ошибки (валидация, подпись и т.д.)
|
||||
$errorReason = 'Error: ' . $e->getMessage();
|
||||
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать неудачную попытку отправки
|
||||
*
|
||||
* @param int $eventId ID события
|
||||
* @param int $currentAttempts Текущее количество попыток
|
||||
* @param string $errorReason Причина ошибки
|
||||
*/
|
||||
private function handleFailedAttempt(int $eventId, int $currentAttempts, string $errorReason): void
|
||||
{
|
||||
$newAttempts = $currentAttempts + 1;
|
||||
|
||||
if ($newAttempts >= $this->maxAttempts) {
|
||||
// Превышен лимит попыток - переводим в failed
|
||||
$this->eventModel->updateStatus($eventId, 'failed', $errorReason);
|
||||
$this->logger->warning("Event {$eventId} marked as failed after {$newAttempts} attempts", [
|
||||
'event_id' => $eventId,
|
||||
'attempts' => $newAttempts,
|
||||
'error' => $errorReason,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик попыток, оставляем статус pending
|
||||
$this->eventModel->incrementAttempts($eventId);
|
||||
$this->logger->debug("Event {$eventId} attempt failed, will retry", [
|
||||
'event_id' => $eventId,
|
||||
'attempts' => $newAttempts,
|
||||
'max_attempts' => $this->maxAttempts,
|
||||
'error' => $errorReason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбросить кеш статистики
|
||||
*/
|
||||
private function clearStatsCache(): void
|
||||
{
|
||||
$this->cache->delete('telecart_pulse_stats');
|
||||
}
|
||||
}
|
||||
90
backend/src/bastion/Services/BotTokenConfigurator.php
Executable file
90
backend/src/bastion/Services/BotTokenConfigurator.php
Executable file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Services;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Openguru\OpenCartFramework\Router\Router;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
|
||||
class BotTokenConfigurator
|
||||
{
|
||||
private TelegramService $telegramService;
|
||||
private SettingsService $settings;
|
||||
private Router $router;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
TelegramService $telegramService,
|
||||
SettingsService $settings,
|
||||
Router $router,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->telegramService = $telegramService;
|
||||
$this->settings = $settings;
|
||||
$this->router = $router;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BotTokenConfiguratorException
|
||||
*/
|
||||
public function configure(string $botToken): array
|
||||
{
|
||||
$this->telegramService->setBotToken($botToken);
|
||||
|
||||
try {
|
||||
$me = $this->telegramService->exec('getMe');
|
||||
$webhookUrl = $this->telegramService->getWebhookUrl();
|
||||
|
||||
if (! $webhookUrl) {
|
||||
$this->telegramService->exec('setWebhook', [
|
||||
'url' => $this->getWebhookUrl(),
|
||||
]);
|
||||
$webhookUrl = $this->telegramService->getWebhookUrl();
|
||||
}
|
||||
|
||||
return [
|
||||
'first_name' => Arr::get($me, 'result.first_name'),
|
||||
'username' => Arr::get($me, 'result.username'),
|
||||
'id' => Arr::get($me, 'result.id'),
|
||||
'webhook_url' => $webhookUrl,
|
||||
];
|
||||
} catch (TelegramClientException $exception) {
|
||||
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
|
||||
throw new BotTokenConfiguratorException(
|
||||
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
|
||||
);
|
||||
}
|
||||
|
||||
throw new BotTokenConfiguratorException($exception->getMessage());
|
||||
} catch (Exception | GuzzleException $exception) {
|
||||
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||
throw new BotTokenConfiguratorException($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BotTokenConfiguratorException
|
||||
*/
|
||||
private function getWebhookUrl(): string
|
||||
{
|
||||
$publicUrl = rtrim($this->settings->config()->getApp()->getShopBaseUrl(), '/');
|
||||
|
||||
if (! $publicUrl) {
|
||||
throw new BotTokenConfiguratorException('Public URL is not set in configuration.');
|
||||
}
|
||||
|
||||
$webhook = $this->router->url('webhook');
|
||||
|
||||
return $publicUrl . $webhook;
|
||||
}
|
||||
}
|
||||
37
backend/src/bastion/Services/CronApiKeyRegenerator.php
Normal file
37
backend/src/bastion/Services/CronApiKeyRegenerator.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Services;
|
||||
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
|
||||
class CronApiKeyRegenerator
|
||||
{
|
||||
private Settings $settings;
|
||||
private SettingsService $settingsUpdateService;
|
||||
|
||||
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->settingsUpdateService = $settingsUpdateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
|
||||
*
|
||||
* @return string новый api_key
|
||||
*/
|
||||
public function regenerate(): string
|
||||
{
|
||||
$newApiKey = bin2hex(random_bytes(32));
|
||||
$all = $this->settings->getAll();
|
||||
if (! isset($all['cron'])) {
|
||||
$all['cron'] = [];
|
||||
}
|
||||
$all['cron']['api_key'] = $newApiKey;
|
||||
$this->settingsUpdateService->update($all);
|
||||
|
||||
return $newApiKey;
|
||||
}
|
||||
}
|
||||
37
backend/src/bastion/Services/SettingsService.php
Executable file
37
backend/src/bastion/Services/SettingsService.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Services;
|
||||
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
private OcRegistryDecorator $registry;
|
||||
private CacheInterface $cache;
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
public function __construct(OcRegistryDecorator $registry, CacheInterface $cache, ConnectionInterface $connection)
|
||||
{
|
||||
$this->registry = $registry;
|
||||
$this->cache = $cache;
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
public function update(array $data): void
|
||||
{
|
||||
$this->connection->transaction(function () use ($data) {
|
||||
$this->registry->model_setting_setting->editSetting('module_telecart', [
|
||||
'module_telecart_settings' => $data,
|
||||
]);
|
||||
|
||||
$this->registry->model_setting_setting->editSetting('module_tgshop', [
|
||||
'module_tgshop_status' => Arr::get($data, 'app.app_enabled', false) ? 1 : 0,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->cache->clear();
|
||||
}
|
||||
}
|
||||
73
backend/src/bastion/Tasks/CleanUpOldAssetsTask.php
Executable file
73
backend/src/bastion/Tasks/CleanUpOldAssetsTask.php
Executable file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Bastion\Tasks;
|
||||
|
||||
use DateInterval;
|
||||
use Exception;
|
||||
use JsonException;
|
||||
use Openguru\OpenCartFramework\MaintenanceTasks\BaseMaintenanceTask;
|
||||
use RuntimeException;
|
||||
|
||||
class CleanUpOldAssetsTask extends BaseMaintenanceTask
|
||||
{
|
||||
public function handle(): void
|
||||
{
|
||||
$spaPath = rtrim(DIR_IMAGE, '/') . '/catalog/tgshopspa';
|
||||
$assetsPath = $spaPath . '/assets';
|
||||
$manifestPath = $spaPath . '/manifest.json';
|
||||
if (! file_exists($manifestPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$entry = $contents['index.html'] ?? null;
|
||||
if (! $entry) {
|
||||
throw new RuntimeException('Некорректный manifest.json — отсутствует ключ index.html.');
|
||||
}
|
||||
|
||||
$keep = [$entry['file']];
|
||||
if (! empty($entry['css'])) {
|
||||
foreach ($entry['css'] as $css) {
|
||||
$keep[] = $css;
|
||||
}
|
||||
}
|
||||
|
||||
$deletedFiles = 0;
|
||||
$keptFiles = 0;
|
||||
|
||||
foreach (glob($assetsPath . '/*') as $file) {
|
||||
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||
if (! in_array($ext, ['js', 'css', 'map'])) {
|
||||
continue;
|
||||
}
|
||||
$relative = 'assets/' . basename($file);
|
||||
if (in_array($relative, $keep, true)) {
|
||||
$keptFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
$deletedFiles++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($deletedFiles > 0) {
|
||||
$this->logger->info(
|
||||
sprintf('Очистка assets завершена. Удалено: %d, оставлено: %d', $deletedFiles, $keptFiles)
|
||||
);
|
||||
}
|
||||
} catch (JsonException $e) {
|
||||
$this->logger->error('Ошибка декодирования файла manifest.json: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Ошибка удаления старых assets: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
public function interval(): ?DateInterval
|
||||
{
|
||||
return new DateInterval('PT1H');
|
||||
}
|
||||
}
|
||||
37
backend/src/bastion/routes.php
Executable file
37
backend/src/bastion/routes.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Bastion\Handlers\AutocompleteHandler;
|
||||
use Bastion\Handlers\DictionariesHandler;
|
||||
use Bastion\Handlers\FormsHandler;
|
||||
use Bastion\Handlers\ImageHandler;
|
||||
use Bastion\Handlers\LogsHandler;
|
||||
use Bastion\Handlers\SendMessageHandler;
|
||||
use Bastion\Handlers\SettingsHandler;
|
||||
use Bastion\Handlers\StatsHandler;
|
||||
use Bastion\Handlers\TeleCartPulseStatsHandler;
|
||||
use Bastion\Handlers\TelegramCustomersHandler;
|
||||
use Bastion\Handlers\TelegramHandler;
|
||||
|
||||
return [
|
||||
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
|
||||
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
|
||||
'getImage' => [ImageHandler::class, 'getImage'],
|
||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
|
||||
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
|
||||
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
|
||||
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
|
||||
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
|
||||
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
|
||||
'getTeleCartPulseStats' => [TeleCartPulseStatsHandler::class, 'getStats'],
|
||||
];
|
||||
101
backend/src/cli.php
Executable file
101
backend/src/cli.php
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Console\ApplicationFactory;
|
||||
use Console\Commands\CacheClearCommand;
|
||||
use Console\Commands\CustomerCountsCommand;
|
||||
use Console\Commands\PulseSendEventsCommand;
|
||||
use Console\Commands\ScheduleRunCommand;
|
||||
use Console\Commands\VersionCommand;
|
||||
use Console\Commands\ImagesWarmupCacheCommand;
|
||||
use Console\Commands\ImagesCacheClearCommand;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Logger;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Symfony\Component\Console\Application;
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
die("This script can only be run from CLI.\n");
|
||||
}
|
||||
|
||||
$baseDir = __DIR__;
|
||||
$debug = false;
|
||||
|
||||
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
|
||||
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
|
||||
require_once $baseDir . '/../../../admin/config.php';
|
||||
} elseif (is_dir("$baseDir/oc_telegram_shop")) {
|
||||
require_once "$baseDir/oc_telegram_shop/vendor/autoload.php";
|
||||
require_once '/web/upload/admin/config.php';
|
||||
} else {
|
||||
throw new RuntimeException('Unable to locate application directory.');
|
||||
}
|
||||
|
||||
// Get Settings from Database
|
||||
$host = DB_HOSTNAME;
|
||||
$username = DB_USERNAME;
|
||||
$password = DB_PASSWORD;
|
||||
$port = (int) DB_PORT;
|
||||
$dbName = DB_DATABASE;
|
||||
$prefix = DB_PREFIX;
|
||||
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
|
||||
$pdo = new PDO($dsn, $username, $password);
|
||||
$connection = new MySqlConnection($pdo);
|
||||
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_settings'");
|
||||
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
|
||||
$timezone = $timezone[0]['value'] ?? 'UTC';
|
||||
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$items = Arr::mergeArraysRecursively($json, [
|
||||
'app' => [
|
||||
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
|
||||
'language_id' => 1,
|
||||
'oc_timezone' => $timezone,
|
||||
],
|
||||
'paths' => [
|
||||
'images' => DIR_IMAGE,
|
||||
],
|
||||
'logs' => [
|
||||
'path' => DIR_LOGS,
|
||||
],
|
||||
'database' => [
|
||||
'host' => DB_HOSTNAME,
|
||||
'database' => DB_DATABASE,
|
||||
'username' => DB_USERNAME,
|
||||
'password' => DB_PASSWORD,
|
||||
'prefix' => DB_PREFIX,
|
||||
'port' => (int) DB_PORT,
|
||||
],
|
||||
'store' => [
|
||||
'oc_store_id' => 0,
|
||||
'oc_default_currency' => 'RUB',
|
||||
'oc_config_tax' => false,
|
||||
],
|
||||
'orders' => [
|
||||
'oc_customer_group_id' => 1,
|
||||
],
|
||||
'telegram' => [
|
||||
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
|
||||
],
|
||||
]);
|
||||
|
||||
$logger = new Logger('TeleCart_CLI', [], [], new DateTimeZone('UTC'));
|
||||
$logger->pushHandler(
|
||||
new RotatingFileHandler(
|
||||
DIR_LOGS . '/telecart.log', 14, $debug ? Logger::DEBUG : Logger::INFO
|
||||
),
|
||||
);
|
||||
|
||||
$app = ApplicationFactory::create($items);
|
||||
$app->setLogger($logger);
|
||||
$app->boot();
|
||||
|
||||
$console = new Application('TeleCart', module_version());
|
||||
$console->add($app->get(VersionCommand::class));
|
||||
$console->add($app->get(ScheduleRunCommand::class));
|
||||
$console->add($app->get(PulseSendEventsCommand::class));
|
||||
$console->add($app->get(ImagesWarmupCacheCommand::class));
|
||||
$console->add($app->get(ImagesCacheClearCommand::class));
|
||||
$console->add($app->get(CacheClearCommand::class));
|
||||
$console->add($app->get(CustomerCountsCommand::class));
|
||||
$console->run();
|
||||
51
backend/src/composer.json
Executable file
51
backend/src/composer.json
Executable file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "nikitakiselev/oc_telegram_shop",
|
||||
"version": "v2.2.1",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Openguru\\OpenCartFramework\\": "framework/",
|
||||
"App\\": "app/",
|
||||
"Bastion\\": "bastion/",
|
||||
"Console\\": "console/",
|
||||
"Tests\\": "tests/"
|
||||
},
|
||||
"files": [
|
||||
"framework/Support/helpers.php"
|
||||
]
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nikita Kiselev",
|
||||
"email": "mail@nikitakiselev.ru"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"doctrine/dbal": "^3.10",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"intervention/image": "^2.7",
|
||||
"monolog/monolog": "^2.10",
|
||||
"nesbot/carbon": "^2.73",
|
||||
"php": "^7.4",
|
||||
"predis/predis": "^2.0",
|
||||
"psr/container": "^2.0",
|
||||
"psr/log": "^1.1",
|
||||
"symfony/cache": "^5.4",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"ramsey/uuid": "^4.2",
|
||||
"symfony/http-foundation": "^5.4",
|
||||
"symfony/console": "^5.4",
|
||||
"dragonmantank/cron-expression": "^3.5",
|
||||
"sentry/sentry": "^4.19"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/sql-formatter": "^1.3",
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"squizlabs/php_codesniffer": "*",
|
||||
"marcocesarato/php-conventional-changelog": "^1.17"
|
||||
}
|
||||
}
|
||||
6584
backend/src/composer.lock
generated
Executable file
6584
backend/src/composer.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
122
backend/src/configs/app.php
Executable file
122
backend/src/configs/app.php
Executable file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app' => [
|
||||
'app_enabled' => true,
|
||||
'app_name' => 'Telecart',
|
||||
'app_icon' => null,
|
||||
"theme_light" => "light",
|
||||
"theme_dark" => "dark",
|
||||
"app_debug" => false,
|
||||
'image_aspect_ratio' => '1:1',
|
||||
'image_crop_algorithm' => 'cover',
|
||||
'haptic_enabled' => true,
|
||||
],
|
||||
|
||||
'telegram' => [
|
||||
"bot_token" => "",
|
||||
"chat_id" => null,
|
||||
"owner_notification_template" => <<<HTML
|
||||
📦 <b>Новый заказ №{order_id}</b>
|
||||
Магазин: <b>{store_name}</b>
|
||||
|
||||
<b>Покупатель</b>
|
||||
Имя: {customer}
|
||||
Email: {email}
|
||||
Телефон: {phone}
|
||||
IP: {ip}
|
||||
|
||||
<b>Комментарий к заказу</b>
|
||||
{comment}
|
||||
|
||||
<b>Сумма заказа:</b> {total}
|
||||
<b>Дата оформления:</b> {created_at}
|
||||
HTML,
|
||||
"customer_notification_template" => <<<HTML
|
||||
✅ <b>Заказ оформлен</b>
|
||||
|
||||
Спасибо за ваш заказ в магазине <b>{store_name}</b>.
|
||||
|
||||
<b>Номер заказа:</b> №{order_id}
|
||||
<b>Сумма заказа:</b> {total}р.
|
||||
<b>Дата оформления:</b> {created_at}
|
||||
|
||||
Информация о заказе сохранена.
|
||||
При необходимости с вами свяжутся представители магазина.
|
||||
HTML,
|
||||
"mini_app_url" => "",
|
||||
],
|
||||
|
||||
"metrics" => [
|
||||
"yandex_metrika_enabled" => false,
|
||||
"yandex_metrika_counter" => "",
|
||||
],
|
||||
|
||||
'store' => [
|
||||
'feature_coupons' => true,
|
||||
'feature_vouchers' => true,
|
||||
'show_category_products_button' => true,
|
||||
'product_interaction_mode' => 'browser',
|
||||
'manager_username' => null,
|
||||
],
|
||||
|
||||
'texts' => [
|
||||
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
|
||||
'text_empty_cart' => 'Ваша корзина пуста.',
|
||||
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
|
||||
'text_manager_button' => '💬 Связаться с менеджером',
|
||||
'start_message' => <<<HTML
|
||||
👋 <b>Добро пожаловать!</b>
|
||||
|
||||
Вы находитесь в официальном магазине.
|
||||
Здесь вы можете ознакомиться с товарами, узнать подробности и оформить заказ прямо в Telegram.
|
||||
|
||||
Нажмите кнопку ниже, чтобы перейти в каталог.
|
||||
HTML,
|
||||
'start_image' => null,
|
||||
'start_button' => [
|
||||
'text' => '🛍 Перейти в каталог',
|
||||
],
|
||||
],
|
||||
|
||||
'orders' => [
|
||||
'order_default_status_id' => 1,
|
||||
],
|
||||
|
||||
'pulse' => [
|
||||
'api_key' => '',
|
||||
'batch_size' => 50,
|
||||
'max_attempts' => 3,
|
||||
],
|
||||
|
||||
'mainpage_blocks' => [
|
||||
[
|
||||
'type' => 'products_feed',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'is_enabled' => true,
|
||||
'goal_name' => '',
|
||||
'data' => [
|
||||
'max_page_count' => 10,
|
||||
'image_aspect_ratio' => '1:1',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'namespace' => 'telecart',
|
||||
'default_lifetime' => 60 * 60 * 24,
|
||||
'options' => [
|
||||
'db_table' => 'telecart_cache_items',
|
||||
],
|
||||
],
|
||||
|
||||
'paths' => [
|
||||
'images_cache' => 'cache/telecart',
|
||||
],
|
||||
|
||||
'cron' => [
|
||||
'mode' => 'disabled',
|
||||
'api_key' => '',
|
||||
],
|
||||
];
|
||||
9
backend/src/configs/maintenance.php
Executable file
9
backend/src/configs/maintenance.php
Executable file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Bastion\Tasks\CleanUpOldAssetsTask;
|
||||
|
||||
return [
|
||||
'tasks' => [
|
||||
CleanUpOldAssetsTask::class,
|
||||
],
|
||||
];
|
||||
36
backend/src/console/ApplicationFactory.php
Executable file
36
backend/src/console/ApplicationFactory.php
Executable file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Console;
|
||||
|
||||
use App\ServiceProviders\AppServiceProvider;
|
||||
use App\ServiceProviders\SettingsServiceProvider;
|
||||
use Openguru\OpenCartFramework\Application;
|
||||
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||
|
||||
class ApplicationFactory
|
||||
{
|
||||
public static function create(array $settings): Application
|
||||
{
|
||||
$defaultConfig = require __DIR__ . '/../configs/app.php';
|
||||
|
||||
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
||||
|
||||
return (new Application($merged))
|
||||
->withServiceProviders([
|
||||
SettingsServiceProvider::class,
|
||||
QueryBuilderServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
CacheServiceProvider::class,
|
||||
TelegramServiceProvider::class,
|
||||
TeleCartPulseServiceProvider::class,
|
||||
SchedulerServiceProvider::class,
|
||||
ImageToolServiceProvider::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
backend/src/console/Commands/CacheClearCommand.php
Normal file
43
backend/src/console/Commands/CacheClearCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class CacheClearCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'cache:clear';
|
||||
protected static $defaultDescription = 'Очистка кеша модуля TeleCart';
|
||||
|
||||
private CacheInterface $cache;
|
||||
|
||||
public function __construct(CacheInterface $cache)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Очистка кеша модуля TeleCart');
|
||||
|
||||
try {
|
||||
$this->cache->clear();
|
||||
$io->success('Кеш успешно очищен!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$io->error('Ошибка при очистке кеша: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
backend/src/console/Commands/CustomerCountsCommand.php
Executable file
43
backend/src/console/Commands/CustomerCountsCommand.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class CustomerCountsCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'customer:counts';
|
||||
protected static $defaultDescription = 'Обновление счетчиков заказов для всех клиентов';
|
||||
|
||||
private ConnectionInterface $database;
|
||||
|
||||
public function __construct(ConnectionInterface $database)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Обновление счетчиков заказов клиентов');
|
||||
$io->writeln('Выполняется пересчёт счетчиков заказов...');
|
||||
|
||||
$sql = <<<SQL
|
||||
update telecart_customers
|
||||
set orders_count = (select count(*) from oc_order where oc_order.customer_id = telecart_customers.oc_customer_id)
|
||||
where true;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
|
||||
$io->success('Счетчики заказов успешно обновлены!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
156
backend/src/console/Commands/ImagesCacheClearCommand.php
Executable file
156
backend/src/console/Commands/ImagesCacheClearCommand.php
Executable file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ImagesCacheClearCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'images:cache-clear';
|
||||
protected static $defaultDescription = 'Очистка кеша изображений товаров';
|
||||
|
||||
private Container $container;
|
||||
|
||||
public function __construct(Container $container)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Очистка кеша изображений товаров');
|
||||
|
||||
// Получаем пути из конфига
|
||||
$imagesDir = $this->container->getConfigValue('paths.images');
|
||||
$cachePath = $this->container->getConfigValue('paths.images_cache', 'cache/telecart');
|
||||
$cachePath = ltrim($cachePath, '/');
|
||||
$fullCachePath = rtrim($imagesDir, '/') . '/' . $cachePath;
|
||||
|
||||
if (!is_dir($fullCachePath)) {
|
||||
$io->warning("Директория кеша не существует: {$fullCachePath}");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section('Информация');
|
||||
$io->listing([
|
||||
"Директория изображений: {$imagesDir}",
|
||||
"Путь кеша: {$cachePath}",
|
||||
"Полный путь кеша: {$fullCachePath}",
|
||||
]);
|
||||
|
||||
// Подсчитываем файлы перед удалением
|
||||
$fileCount = 0;
|
||||
$totalSize = 0;
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($fullCachePath, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()) {
|
||||
$fileCount++;
|
||||
$totalSize += $file->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileCount === 0) {
|
||||
$io->info('Кеш пуст, нечего очищать.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section('Статистика перед очисткой');
|
||||
$io->listing([
|
||||
"Файлов: {$fileCount}",
|
||||
"Размер: " . $this->formatBytes($totalSize),
|
||||
]);
|
||||
|
||||
// Запрашиваем подтверждение
|
||||
if (!$io->confirm('Вы уверены, что хотите удалить все файлы из кеша?', false)) {
|
||||
$io->info('Очистка кеша отменена.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Удаляем файлы и директории
|
||||
$deletedFiles = 0;
|
||||
$deletedDirs = 0;
|
||||
$errors = 0;
|
||||
|
||||
$progressBar = $io->createProgressBar($fileCount);
|
||||
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$progressBar->setMessage('Удаление файлов...');
|
||||
$progressBar->start();
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($fullCachePath, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
try {
|
||||
if ($file->isFile()) {
|
||||
if (@unlink($file->getPathname())) {
|
||||
$deletedFiles++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
$progressBar->advance();
|
||||
} elseif ($file->isDir()) {
|
||||
if (@rmdir($file->getPathname())) {
|
||||
$deletedDirs++;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем саму директорию кеша, если она пуста
|
||||
if (is_dir($fullCachePath)) {
|
||||
@rmdir($fullCachePath);
|
||||
}
|
||||
|
||||
$progressBar->setMessage('Завершено');
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
// Выводим статистику
|
||||
$io->section('Результаты');
|
||||
$io->table(
|
||||
['Метрика', 'Значение'],
|
||||
[
|
||||
['Удалено файлов', $deletedFiles],
|
||||
['Удалено директорий', $deletedDirs],
|
||||
['Ошибок', $errors],
|
||||
]
|
||||
);
|
||||
|
||||
if ($errors > 0) {
|
||||
$io->warning("Обнаружено {$errors} ошибок при удалении файлов.");
|
||||
} else {
|
||||
$io->success('Кеш изображений успешно очищен!');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
|
||||
242
backend/src/console/Commands/ImagesWarmupCacheCommand.php
Executable file
242
backend/src/console/Commands/ImagesWarmupCacheCommand.php
Executable file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Exception;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
|
||||
use Openguru\OpenCartFramework\ImageTool\ImageUtils;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ImagesWarmupCacheCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'images:warmup';
|
||||
protected static $defaultDescription = 'Прогрев кеша изображений товаров';
|
||||
|
||||
private Builder $queryBuilder;
|
||||
private ImageFactory $image;
|
||||
private SettingsService $settings;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
Builder $queryBuilder,
|
||||
ImageFactory $image,
|
||||
SettingsService $settings,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->queryBuilder = $queryBuilder;
|
||||
$this->image = $image;
|
||||
$this->settings = $settings;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument(
|
||||
'product_id',
|
||||
InputArgument::OPTIONAL,
|
||||
'ID товара для прогрева кеша (если не указан, прогреваются все товары)'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$productId = $input->getArgument('product_id');
|
||||
|
||||
$io->title('Прогрев кеша изображений товаров');
|
||||
|
||||
// Получаем настройки
|
||||
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
|
||||
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
|
||||
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
|
||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||
|
||||
$io->section('Настройки');
|
||||
$io->listing([
|
||||
"Соотношение сторон: {$aspectRatio}",
|
||||
"Алгоритм обрезки: {$cropAlgorithm}",
|
||||
"Размер изображения: {$imageWidth}x{$imageHeight}",
|
||||
"Размер миниатюры: 500x500",
|
||||
"Размер большого изображения: 1000x1000",
|
||||
]);
|
||||
|
||||
// Получаем список товаров
|
||||
$products = $this->getProducts($productId, $languageId);
|
||||
|
||||
if (empty($products)) {
|
||||
$io->warning('Товары не найдены');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalProducts = count($products);
|
||||
$io->section("Найдено товаров: {$totalProducts}");
|
||||
|
||||
$stats = [
|
||||
'products' => 0,
|
||||
'main_images' => 0,
|
||||
'additional_images' => 0,
|
||||
'thumbnails' => 0,
|
||||
'large_images' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$progressBar = $io->createProgressBar($totalProducts);
|
||||
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$progressBar->setMessage('Обработка товаров...');
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($products as $product) {
|
||||
$productId = $product['product_id'];
|
||||
$productName = $product['product_name'] ?? "ID: {$productId}";
|
||||
$progressBar->setMessage("Товар: {$productName}");
|
||||
|
||||
try {
|
||||
// Прогреваем основное изображение товара
|
||||
if (!empty($product['product_image'])) {
|
||||
try {
|
||||
$this->image->make($product['product_image'])
|
||||
->crop($cropAlgorithm, $imageWidth, $imageHeight)
|
||||
->url();
|
||||
$stats['main_images']++;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error("Ошибка при прогреве основного изображения товара {$productId}: " . $e->getMessage());
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем дополнительные изображения товара
|
||||
$additionalImages = $this->getProductAdditionalImages($productId);
|
||||
$processedAdditional = 0;
|
||||
foreach ($additionalImages as $imagePath) {
|
||||
if ($processedAdditional >= 2) {
|
||||
break; // Ограничиваем до 2 дополнительных изображений, как в ProductsService
|
||||
}
|
||||
try {
|
||||
$this->image->make($imagePath)
|
||||
->crop($cropAlgorithm, $imageWidth, $imageHeight)
|
||||
->url();
|
||||
$stats['additional_images']++;
|
||||
$processedAdditional++;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error("Ошибка при прогреве дополнительного изображения товара {$productId}: " . $e->getMessage());
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Прогреваем изображения для детальной страницы (миниатюры и большие)
|
||||
$allImages = [];
|
||||
if (!empty($product['product_image'])) {
|
||||
$allImages[] = $product['product_image'];
|
||||
}
|
||||
$allImages = array_merge($allImages, $additionalImages);
|
||||
|
||||
foreach ($allImages as $imagePath) {
|
||||
try {
|
||||
// Миниатюра
|
||||
$this->image->make($imagePath)
|
||||
->contain(500, 500)
|
||||
->url();
|
||||
$stats['thumbnails']++;
|
||||
|
||||
// Большое изображение
|
||||
$this->image->make($imagePath)
|
||||
->resize(1000, 1000)
|
||||
->url();
|
||||
$stats['large_images']++;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error("Ошибка при прогреве изображений для детальной страницы товара {$productId}: " . $e->getMessage());
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
$stats['products']++;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error("Ошибка при обработке товара {$productId}: " . $e->getMessage());
|
||||
$stats['errors']++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->setMessage('Завершено');
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
// Выводим статистику
|
||||
$io->section('Статистика');
|
||||
$io->table(
|
||||
['Метрика', 'Значение'],
|
||||
[
|
||||
['Обработано товаров', $stats['products']],
|
||||
['Основных изображений', $stats['main_images']],
|
||||
['Дополнительных изображений', $stats['additional_images']],
|
||||
['Миниатюр (500x500)', $stats['thumbnails']],
|
||||
['Больших изображений (1000x1000)', $stats['large_images']],
|
||||
['Ошибок', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
if ($stats['errors'] > 0) {
|
||||
$io->warning("Обнаружено {$stats['errors']} ошибок. Проверьте логи для подробностей.");
|
||||
} else {
|
||||
$io->success('Кеш изображений успешно прогрет!');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список товаров для прогрева кеша
|
||||
*/
|
||||
private function getProducts(?string $productId, int $languageId): array
|
||||
{
|
||||
$query = $this->queryBuilder->newQuery()
|
||||
->select([
|
||||
'products.product_id' => 'product_id',
|
||||
'products.image' => 'product_image',
|
||||
'product_description.name' => 'product_name',
|
||||
])
|
||||
->from(db_table('product'), 'products')
|
||||
->join(
|
||||
db_table('product_description') . ' AS product_description',
|
||||
function (JoinClause $join) use ($languageId) {
|
||||
$join->on('products.product_id', '=', 'product_description.product_id')
|
||||
->where('product_description.language_id', '=', $languageId);
|
||||
}
|
||||
)
|
||||
->where('products.status', '=', 1)
|
||||
->whereRaw('products.date_available < NOW()');
|
||||
|
||||
if ($productId !== null) {
|
||||
$query->where('products.product_id', '=', (int) $productId);
|
||||
}
|
||||
|
||||
return $query->orderBy('products.product_id', 'ASC')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает дополнительные изображения товара
|
||||
*/
|
||||
private function getProductAdditionalImages(int $productId): array
|
||||
{
|
||||
$images = $this->queryBuilder->newQuery()
|
||||
->select(['products_images.image' => 'image'])
|
||||
->from(db_table('product_image'), 'products_images')
|
||||
->where('products_images.product_id', '=', $productId)
|
||||
->orderBy('products_images.sort_order')
|
||||
->get();
|
||||
|
||||
return Arr::pluck($images, 'image');
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/src/console/Commands/PulseSendEventsCommand.php
Executable file
29
backend/src/console/Commands/PulseSendEventsCommand.php
Executable file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class PulseSendEventsCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'pulse:send';
|
||||
protected static $defaultDescription = 'Manually send pulse events ignoring schedule.';
|
||||
private TeleCartPulseSendEventsTask $teleCartPulseSendEventsTask;
|
||||
|
||||
public function __construct(TeleCartPulseSendEventsTask $teleCartPulseSendEventsTask)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->teleCartPulseSendEventsTask = $teleCartPulseSendEventsTask;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('<info>Sending Pulse events.</info>');
|
||||
$this->teleCartPulseSendEventsTask->execute();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
80
backend/src/console/Commands/ScheduleRunCommand.php
Executable file
80
backend/src/console/Commands/ScheduleRunCommand.php
Executable file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ScheduleRunCommand extends TeleCartCommand
|
||||
{
|
||||
private SchedulerService $scheduler;
|
||||
private Settings $settings;
|
||||
|
||||
protected static $defaultName = 'schedule:run';
|
||||
protected static $defaultDescription = 'Run scheduled commands';
|
||||
|
||||
public function __construct(SchedulerService $scheduler, Settings $settings)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->scheduler = $scheduler;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'ignore-global-lock',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Ignore global scheduler lock (e.g. when running multiple cron instances)'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$mode = $this->settings->get('cron.mode', 'disabled');
|
||||
if ($mode !== 'system') {
|
||||
$output->writeln('<comment>Scheduler not in CRON mode. Skipping CLI execution.</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln(
|
||||
sprintf(
|
||||
'[%s] <info>TeleCart Scheduler Running...</info>',
|
||||
Carbon::now()->toJSON(),
|
||||
)
|
||||
);
|
||||
|
||||
$ignoreGlobalLock = (bool) $input->getOption('ignore-global-lock');
|
||||
$result = $this->scheduler->run($ignoreGlobalLock);
|
||||
|
||||
// Print Executed
|
||||
if (empty($result->executed)) {
|
||||
$output->writeln('No tasks executed.');
|
||||
} else {
|
||||
foreach ($result->executed as $item) {
|
||||
$output->writeln(sprintf('<info>Executed:</info> %s (%.4fs)', $item['name'], $item['duration']));
|
||||
}
|
||||
}
|
||||
|
||||
// Print Failed
|
||||
foreach ($result->failed as $item) {
|
||||
$output->writeln(sprintf('<error>Failed:</error> %s - %s', $item['name'], $item['error']));
|
||||
}
|
||||
|
||||
// Print Skipped (verbose only)
|
||||
if ($output->isVerbose()) {
|
||||
foreach ($result->skipped as $item) {
|
||||
$output->writeln(sprintf('<comment>Skipped:</comment> %s - %s', $item['name'], $item['reason']));
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
13
backend/src/console/Commands/TeleCartCommand.php
Executable file
13
backend/src/console/Commands/TeleCartCommand.php
Executable file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
abstract class TeleCartCommand extends Command
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
19
backend/src/console/Commands/VersionCommand.php
Executable file
19
backend/src/console/Commands/VersionCommand.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class VersionCommand extends TeleCartCommand
|
||||
{
|
||||
protected static $defaultName = 'version';
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('TeleCart Version: ' . module_version());
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
0
backend/src/database/migrations/.gitkeep
Executable file
0
backend/src/database/migrations/.gitkeep
Executable file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$opencart = $this->app->get(OcRegistryDecorator::class);
|
||||
$opencart->load->model('setting/setting');
|
||||
|
||||
$legacySettings = $opencart->model_setting_setting->getSetting('module_tgshop');
|
||||
if (! $legacySettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newSettings = $opencart->model_setting_setting->getSetting('module_telecart');
|
||||
|
||||
static $mapLegacyToNewSettings = [
|
||||
'module_tgshop_app_icon' => 'app.app_icon',
|
||||
'module_tgshop_theme_light' => 'app.theme_light',
|
||||
'module_tgshop_bot_token' => 'telegram.bot_token',
|
||||
'module_tgshop_status' => 'app.app_enabled',
|
||||
'module_tgshop_app_name' => 'app.app_name',
|
||||
'module_tgshop_theme_dark' => 'app.theme_dark',
|
||||
'module_tgshop_debug' => 'app.app_debug',
|
||||
'module_tgshop_chat_id' => 'telegram.chat_id',
|
||||
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
|
||||
'module_tgshop_enable_store' => 'store.enable_store',
|
||||
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
|
||||
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
|
||||
'module_tgshop_feature_coupons' => 'store.feature_coupons',
|
||||
'module_tgshop_text_no_more_products' => 'texts.text_no_more_products',
|
||||
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
|
||||
];
|
||||
|
||||
if (! $newSettings) {
|
||||
$data = [];
|
||||
|
||||
foreach ($mapLegacyToNewSettings as $key => $value) {
|
||||
if (array_key_exists($key, $legacySettings)) {
|
||||
if ($key === 'module_tgshop_status') {
|
||||
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
|
||||
} elseif ($key === 'module_tgshop_debug') {
|
||||
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
|
||||
} elseif ($key === 'module_tgshop_chat_id') {
|
||||
$newValue = (int) $legacySettings[$key];
|
||||
} elseif ($key === 'module_tgshop_enable_store') {
|
||||
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
|
||||
} elseif ($key === 'module_tgshop_order_default_status_id') {
|
||||
$newValue = (int) $legacySettings[$key];
|
||||
} elseif ($key === 'module_tgshop_feature_vouchers') {
|
||||
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
|
||||
} elseif ($key === 'module_tgshop_feature_coupons') {
|
||||
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
|
||||
} else {
|
||||
$newValue = $legacySettings[$key];
|
||||
}
|
||||
|
||||
Arr::set($data, $value, $newValue);
|
||||
}
|
||||
}
|
||||
|
||||
$opencart->model_setting_setting->editSetting('module_telecart', [
|
||||
'module_telecart_settings' => $data,
|
||||
]);
|
||||
|
||||
$this->logger->info('Выполнено обновление настроек с 1й версии модуля.');
|
||||
}
|
||||
|
||||
$opencart->model_setting_setting->deleteSetting('module_tgshop');
|
||||
}
|
||||
};
|
||||
19
backend/src/database/migrations/20260101000002_remove_legacy_files.php
Executable file
19
backend/src/database/migrations/20260101000002_remove_legacy_files.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$legacyFilesToRemove = [
|
||||
DIR_TEMPLATE . '/extension/module/tgshop_init.twig',
|
||||
];
|
||||
|
||||
foreach ($legacyFilesToRemove as $file) {
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
$this->logger->info('Удалён старый файл: ' . $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `telecart_forms` (
|
||||
`id` bigint(11) AUTO_INCREMENT PRIMARY KEY,
|
||||
`alias` varchar(100) NOT NULL,
|
||||
`friendly_name` varchar(100) NOT NULL,
|
||||
`is_custom` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`schema` longtext NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) collate = utf8_unicode_ci
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$checkoutForm = json_encode(self::getCheckoutFormSchema(), JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->database->insert('telecart_forms', [
|
||||
'alias' => 'checkout',
|
||||
'friendly_name' => 'Оформление заказа',
|
||||
'schema' => $checkoutForm,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getCheckoutFormSchema(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'field_1_1763897608480',
|
||||
'$formkit' => 'text',
|
||||
'name' => 'firstname',
|
||||
'label' => 'Имя',
|
||||
'placeholder' => 'Например: Иван',
|
||||
'help' => 'Введите ваше имя',
|
||||
'validation' => 'required|length:0,32',
|
||||
'prefixIcon' => 'avatarMan',
|
||||
'locked' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'field_2_1763897611020',
|
||||
'$formkit' => 'text',
|
||||
'name' => 'lastname',
|
||||
'label' => 'Фамилия',
|
||||
'placeholder' => 'Например: Иванов',
|
||||
'help' => 'Введите вашу фамилию',
|
||||
'validation' => 'required|length:0,32',
|
||||
'prefixIcon' => 'avatarMan',
|
||||
'locked' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'field_5_1763897626036',
|
||||
'$formkit' => 'tel',
|
||||
'name' => 'telephone',
|
||||
'label' => 'Телефон',
|
||||
'placeholder' => 'Например: +7 (999) 000-00-00',
|
||||
'validation' => 'required|length:0,32',
|
||||
'help' => 'Введите ваш номер телефона.',
|
||||
'prefixIcon' => 'telephone',
|
||||
'locked' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'field_4_1763897617570',
|
||||
'$formkit' => 'textarea',
|
||||
'name' => 'comment',
|
||||
'label' => 'Комментарий к заказу',
|
||||
'placeholder' => 'Например: Домофон не работает',
|
||||
'help' => 'Дополнительная информация к заказу',
|
||||
'validation' => 'length:0,5000',
|
||||
'locked' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_customers';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`telegram_user_id` BIGINT(20) UNSIGNED NOT NULL,
|
||||
`oc_customer_id` INT(11) UNSIGNED DEFAULT NULL,
|
||||
`username` VARCHAR(255) DEFAULT NULL,
|
||||
`first_name` VARCHAR(255) DEFAULT NULL,
|
||||
`last_name` VARCHAR(255) DEFAULT NULL,
|
||||
`language_code` VARCHAR(10) DEFAULT NULL,
|
||||
`is_premium` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`allows_write_to_pm` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`photo_url` VARCHAR(512) DEFAULT NULL,
|
||||
`last_seen_at` DATETIME DEFAULT NULL,
|
||||
`referral` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_telegram_user_id` (`telegram_user_id`),
|
||||
KEY `idx_oc_customer_id` (`oc_customer_id`),
|
||||
KEY `idx_last_seen_at` (`last_seen_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
ALTER TABLE `telecart_customers`
|
||||
ADD COLUMN `privacy_consented_at` TIMESTAMP NULL DEFAULT NULL AFTER `referral`;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_order_meta';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`oc_order_id` INT(11) UNSIGNED NOT NULL,
|
||||
`oc_store_id` INT(11) UNSIGNED NOT NULL,
|
||||
`telecart_customer_id` INT(11) UNSIGNED DEFAULT NULL,
|
||||
`meta_data` JSON DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_oc_order_id` (`oc_order_id`),
|
||||
KEY `idx_oc_store_id` (`oc_store_id`),
|
||||
KEY `idx_telecart_customer_id` (`telecart_customer_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
ALTER TABLE `telecart_customers`
|
||||
ADD COLUMN `orders_count` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `referral`;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
ALTER TABLE `telecart_customers`
|
||||
ADD COLUMN `tracking_id` VARCHAR(64) NOT NULL AFTER `oc_customer_id`;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_events';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`event` VARCHAR(255) NOT NULL,
|
||||
`payload` TEXT NOT NULL,
|
||||
`idempotency_key` VARCHAR(64) NOT NULL,
|
||||
`event_time` DATETIME NOT NULL,
|
||||
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
`attempts_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`error_reason` TEXT DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_idempotency_key` (`idempotency_key`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_event_time` (`event_time`),
|
||||
KEY `idx_updated_at` (`updated_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user