diff --git a/Makefile b/Makefile
index 911aecd..b3b6db1 100644
--- a/Makefile
+++ b/Makefile
@@ -71,4 +71,7 @@ test-coverage:
phar:
docker build -t telecart_local_build -f ./docker/build.dockerfile . && \
- docker run -v "./src/upload/system/library/oc_telegram_shop:/build" telecart_local_build sh -c 'sh /scripts/build_phar.sh'
\ No newline at end of file
+ docker run -v "./src/upload/system/library/oc_telegram_shop:/build" telecart_local_build sh -c 'sh /scripts/build_phar.sh'
+
+cli:
+ docker compose exec -w /module/oc_telegram_shop/upload web bash -c "/usr/local/bin/php cli.php $(ARGS)"
\ No newline at end of file
diff --git a/cli b/cli
new file mode 100755
index 0000000..f586d46
--- /dev/null
+++ b/cli
@@ -0,0 +1,2 @@
+#!/bin/bash
+docker compose exec -w /module/oc_telegram_shop/upload web php cli.php "$@"
\ No newline at end of file
diff --git a/module/oc_telegram_shop/upload/admin/controller/extension/module/tgshop.php b/module/oc_telegram_shop/upload/admin/controller/extension/module/tgshop.php
index 3b136cf..0929dab 100755
--- a/module/oc_telegram_shop/upload/admin/controller/extension/module/tgshop.php
+++ b/module/oc_telegram_shop/upload/admin/controller/extension/module/tgshop.php
@@ -112,7 +112,7 @@ class ControllerExtensionModuleTgshop extends Controller
$data['themes'] = self::$themes;
$data['telecart_module_version'] = module_version();
$data['shop_base_url'] = HTTPS_CATALOG;
-
+
$data['action'] = $this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
@@ -263,7 +263,7 @@ class ControllerExtensionModuleTgshop extends Controller
private function createLogger(bool $debug = false): Logger
{
- $log = new Logger('TeleCart_Admin');
+ $log = new Logger('TeleCart_Admin', [], [], new DateTimeZone('UTC'));
$log->pushHandler(
new RotatingFileHandler(
DIR_LOGS . '/telecart.log', 14, $debug ? Logger::DEBUG : Logger::INFO
diff --git a/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php b/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php
index 9094d4c..8f6d10c 100755
--- a/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php
+++ b/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php
@@ -127,7 +127,7 @@ JS;
private function createLogger(bool $appDebug = false, string $app = 'TeleCart'): LoggerInterface
{
- $log = new Logger($app);
+ $log = new Logger($app, [], [], new DateTimeZone('UTC'));
$log->pushHandler(
new RotatingFileHandler(DIR_LOGS . '/telecart.log', 14, $appDebug ? Logger::DEBUG : Logger::INFO),
);
diff --git a/module/oc_telegram_shop/upload/cli.php b/module/oc_telegram_shop/upload/cli.php
old mode 100644
new mode 100755
index 4435272..901df93
--- a/module/oc_telegram_shop/upload/cli.php
+++ b/module/oc_telegram_shop/upload/cli.php
@@ -1,18 +1,95 @@
#!/usr/bin/env php
select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_settings'");
+$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,
+ ],
+ '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' => 1,
+ 'oc_config_tax' => false,
+ ],
+ 'orders' => [
+ 'oc_customer_group_id' => 1,
+ ],
+ 'telegram' => [
+ 'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
+ ],
+]);
+
+// Create logger
+$logger = new Logger('TeleCart_CLI', [], [], new DateTimeZone('UTC'));
+$logger->pushHandler(
+ new RotatingFileHandler(
+ DIR_LOGS . '/telecart.log', 14, $debug ? Logger::DEBUG : Logger::INFO
+ ),
+);
+
+// Creates TeleCart application.
+$app = ApplicationFactory::create($items);
+$app->setLogger($logger);
+$app->boot();
+
+// Creates Console and bind commands.
+$console = new Application('TeleCart', module_version());
+$console->add($app->get(VersionCommand::class));
+$console->add($app->get(ScheduleRunCommand::class));
+$console->add($app->get(ScheduleListCommand::class));
+$console->run();
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/composer.json b/module/oc_telegram_shop/upload/oc_telegram_shop/composer.json
index 547dffa..bc067e3 100755
--- a/module/oc_telegram_shop/upload/oc_telegram_shop/composer.json
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/composer.json
@@ -6,6 +6,7 @@
"Openguru\\OpenCartFramework\\": "framework/",
"App\\": "src/",
"Bastion\\": "bastion/",
+ "Console\\": "console/",
"Tests\\": "tests/"
},
"files": [
@@ -32,7 +33,9 @@
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.2",
- "symfony/http-foundation": "^5.4"
+ "symfony/http-foundation": "^5.4",
+ "symfony/console": "^5.4",
+ "dragonmantank/cron-expression": "^3.5"
},
"require-dev": {
"doctrine/sql-formatter": "^1.3",
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/composer.lock b/module/oc_telegram_shop/upload/oc_telegram_shop/composer.lock
index f31bcdb..5cafe58 100755
--- a/module/oc_telegram_shop/upload/oc_telegram_shop/composer.lock
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "73829e240f399344756292ca05f62e89",
+ "content-hash": "73f60d8ed1037cbbd7e6368936ed1dc7",
"packages": [
{
"name": "brick/math",
@@ -389,6 +389,70 @@
],
"time": "2022-10-12T20:51:15+00:00"
},
+ {
+ "name": "dragonmantank/cron-expression",
+ "version": "v3.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dragonmantank/cron-expression.git",
+ "reference": "1b2de7f4a468165dca07b142240733a1973e766d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/1b2de7f4a468165dca07b142240733a1973e766d",
+ "reference": "1b2de7f4a468165dca07b142240733a1973e766d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0"
+ },
+ "replace": {
+ "mtdowling/cron-expression": "^1.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.32|^2.1.31",
+ "phpunit/phpunit": "^8.5.48|^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cron\\": "src/Cron/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Tankersley",
+ "email": "chris@ctankersley.com",
+ "homepage": "https://github.com/dragonmantank"
+ }
+ ],
+ "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due",
+ "keywords": [
+ "cron",
+ "schedule"
+ ],
+ "support": {
+ "issues": "https://github.com/dragonmantank/cron-expression/issues",
+ "source": "https://github.com/dragonmantank/cron-expression/tree/v3.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/dragonmantank",
+ "type": "github"
+ }
+ ],
+ "time": "2025-10-31T18:36:32+00:00"
+ },
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",
@@ -1912,6 +1976,105 @@
],
"time": "2024-09-25T14:11:13+00:00"
},
+ {
+ "name": "symfony/console",
+ "version": "v5.4.47",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php73": "^1.9",
+ "symfony/polyfill-php80": "^1.16",
+ "symfony/service-contracts": "^1.1|^2|^3",
+ "symfony/string": "^5.1|^6.0"
+ },
+ "conflict": {
+ "psr/log": ">=3",
+ "symfony/dependency-injection": "<4.4",
+ "symfony/dotenv": "<5.1",
+ "symfony/event-dispatcher": "<4.4",
+ "symfony/lock": "<4.4",
+ "symfony/process": "<4.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2",
+ "symfony/config": "^4.4|^5.0|^6.0",
+ "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+ "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+ "symfony/lock": "^4.4|^5.0|^6.0",
+ "symfony/process": "^4.4|^5.0|^6.0",
+ "symfony/var-dumper": "^4.4|^5.0|^6.0"
+ },
+ "suggest": {
+ "psr/log": "For using the console logger",
+ "symfony/event-dispatcher": "",
+ "symfony/lock": "",
+ "symfony/process": ""
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.4.47"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-06T11:30:55+00:00"
+ },
{
"name": "symfony/deprecation-contracts",
"version": "v2.5.4",
@@ -2142,6 +2305,173 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-27T09:58:17+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
@@ -2532,6 +2862,92 @@
},
"time": "2019-05-28T07:50:59+00:00"
},
+ {
+ "name": "symfony/string",
+ "version": "v5.4.47",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "136ca7d72f72b599f2631aca474a4f8e26719799"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799",
+ "reference": "136ca7d72f72b599f2631aca474a4f8e26719799",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php80": "~1.15"
+ },
+ "conflict": {
+ "symfony/translation-contracts": ">=3.0"
+ },
+ "require-dev": {
+ "symfony/error-handler": "^4.4|^5.0|^6.0",
+ "symfony/http-client": "^4.4|^5.0|^6.0",
+ "symfony/translation-contracts": "^1.1|^2",
+ "symfony/var-exporter": "^4.4|^5.0|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.4.47"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-10T20:33:58+00:00"
+ },
{
"name": "symfony/translation",
"version": "v5.4.45",
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/configs/schedule.php b/module/oc_telegram_shop/upload/oc_telegram_shop/configs/schedule.php
new file mode 100755
index 0000000..fe32190
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/configs/schedule.php
@@ -0,0 +1,17 @@
+add(\My\Task\Class::class)->everyFiveMinutes();
+
+// Example: Running a closure every hour
+// $scheduler->add(function () {
+// // Do something
+// }, 'my_closure_task')->everyHour();
+
+// Example: Custom cron expression
+// $scheduler->add(\My\Task\Class::class)->at('0 12 * * *');
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/console/ApplicationFactory.php b/module/oc_telegram_shop/upload/oc_telegram_shop/console/ApplicationFactory.php
new file mode 100755
index 0000000..355c862
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/console/ApplicationFactory.php
@@ -0,0 +1,34 @@
+withServiceProviders([
+ SettingsServiceProvider::class,
+ QueryBuilderServiceProvider::class,
+ AppServiceProvider::class,
+ CacheServiceProvider::class,
+ TelegramServiceProvider::class,
+ TeleCartPulseServiceProvider::class,
+ SchedulerServiceProvider::class,
+ ]);
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleListCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleListCommand.php
new file mode 100755
index 0000000..c02b5db
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleListCommand.php
@@ -0,0 +1,72 @@
+schedulerService = $schedulerService;
+ $this->container = $container;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $registry = new ScheduleJobRegistry($this->container);
+
+ // Load schedule config
+ $configFile = BP_BASE_PATH . '/configs/schedule.php';
+ if (file_exists($configFile)) {
+ $scheduler = $registry; // Variable name used in config file
+ require $configFile;
+ }
+
+ $jobs = $registry->getJobs();
+ $table = new Table($output);
+ $table->setHeaders(['Name / Class', 'Cron Expression', 'Last Run', 'Last Failure']);
+
+ foreach ($jobs as $job) {
+ $id = $job->getId();
+ $lastRun = $this->schedulerService->getLastRun($id);
+ $lastFailure = $this->schedulerService->getLastFailure($id);
+
+ $lastRunText = $lastRun ? date('Y-m-d H:i:s', $lastRun) : 'Never';
+
+ $lastFailureText = 'None';
+ if ($lastFailure) {
+ $lastFailureText = date('Y-m-d H:i:s', $lastFailure['time']) . "\n" . substr($lastFailure['message'], 0, 50);
+ }
+
+ $table->addRow([
+ $job->getName(),
+ // We don't have getExpression public yet on Job, assuming we might need to add it or it's not critical.
+ // Wait, Job class stores expression but doesn't expose it via public getter.
+ // I should add getExpression() to Job.php if I want to show it.
+ // For now, let's assume we add it.
+ method_exists($job, 'getExpression') ? $job->getExpression() : '???',
+ $lastRunText,
+ $lastFailureText
+ ]);
+ }
+
+ $table->render();
+
+ return Command::SUCCESS;
+ }
+}
+
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleRunCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleRunCommand.php
new file mode 100755
index 0000000..9b979f8
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleRunCommand.php
@@ -0,0 +1,61 @@
+scheduler = $scheduler;
+ $this->settings = $settings;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $mode = $this->settings->get('cron.mode', 'disabled');
+ if ($mode !== 'system') {
+ $output->writeln('Scheduler is disabled. Skipping CLI execution.');
+ return Command::SUCCESS;
+ }
+
+ $output->writeln('TeleCart Scheduler Running...');
+
+ $result = $this->scheduler->run();
+
+ // Print Executed
+ if (empty($result->executed)) {
+ $output->writeln('No tasks executed.');
+ } else {
+ foreach ($result->executed as $item) {
+ $output->writeln(sprintf('Executed: %s (%.4fs)', $item['name'], $item['duration']));
+ }
+ }
+
+ // Print Failed
+ foreach ($result->failed as $item) {
+ $output->writeln(sprintf('Failed: %s - %s', $item['name'], $item['error']));
+ }
+
+ // Print Skipped (verbose only)
+ if ($output->isVerbose()) {
+ foreach ($result->skipped as $item) {
+ $output->writeln(sprintf('Skipped: %s - %s', $item['name'], $item['reason']));
+ }
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/TeleCartCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/TeleCartCommand.php
new file mode 100755
index 0000000..b2b7b83
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/TeleCartCommand.php
@@ -0,0 +1,13 @@
+writeln('TeleCart Version: ' . module_version());
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/Job.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/Job.php
new file mode 100755
index 0000000..a94c33c
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/Job.php
@@ -0,0 +1,102 @@
+container = $container;
+ $this->action = $action;
+ $this->name = $name;
+ }
+
+ public function at(string $expression): self
+ {
+ $this->expression = $expression;
+ return $this;
+ }
+
+ public function everyMinute(): self
+ {
+ return $this->at('* * * * *');
+ }
+
+ public function everyFiveMinutes(): self
+ {
+ return $this->at('*/5 * * * *');
+ }
+
+ public function everyTenMinutes(): self
+ {
+ return $this->at('*/10 * * * *');
+ }
+
+ public function everyFifteenMinutes(): self
+ {
+ return $this->at('*/15 * * * *');
+ }
+
+ public function everyHour(): self
+ {
+ return $this->at('0 * * * *');
+ }
+
+ public function dailyAt(int $hour, int $minute = 0): self
+ {
+ return $this->at(sprintf('%d %d * * *', $minute, $hour));
+ }
+
+ public function isDue(): bool
+ {
+ return (new CronExpression($this->expression))->isDue();
+ }
+
+ public function run(): void
+ {
+ if (is_string($this->action) && class_exists($this->action)) {
+ /** @var TaskInterface $task */
+ $task = $this->container->get($this->action);
+ $task->execute();
+ } elseif (is_callable($this->action)) {
+ call_user_func($this->action);
+ } elseif ($this->action instanceof TaskInterface) {
+ $this->action->execute();
+ }
+ }
+
+ public function getName(): string
+ {
+ if ($this->name) {
+ return $this->name;
+ }
+
+ if (is_string($this->action)) {
+ return $this->action;
+ }
+
+ return 'Closure';
+ }
+
+ public function getId(): string
+ {
+ return md5($this->getName());
+ }
+
+ public function getExpression(): string
+ {
+ return $this->expression;
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/ScheduleJobRegistry.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/ScheduleJobRegistry.php
new file mode 100755
index 0000000..cd886be
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/ScheduleJobRegistry.php
@@ -0,0 +1,40 @@
+container = $container;
+ }
+
+ /**
+ * @param string|callable|TaskInterface $job
+ * @param string|null $name
+ * @return Job
+ */
+ public function add($job, ?string $name = null): Job
+ {
+ $newJob = new Job($this->container, $job, $name);
+ $this->jobs[] = $newJob;
+
+ return $newJob;
+ }
+
+ /**
+ * @return Job[]
+ */
+ public function getJobs(): array
+ {
+ return $this->jobs;
+ }
+}
+
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerResult.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerResult.php
new file mode 100755
index 0000000..0f26894
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerResult.php
@@ -0,0 +1,44 @@
+executed[] = [
+ 'name' => $name,
+ 'duration' => $duration,
+ ];
+ }
+
+ public function addFailed(string $name, string $error): void
+ {
+ $this->failed[] = [
+ 'name' => $name,
+ 'error' => $error,
+ ];
+ }
+
+ public function addSkipped(string $name, string $reason): void
+ {
+ $this->skipped[] = [
+ 'name' => $name,
+ 'reason' => $reason,
+ ];
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'executed' => $this->executed,
+ 'failed' => $this->failed,
+ 'skipped' => $this->skipped,
+ ];
+ }
+}
+
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php
new file mode 100755
index 0000000..de3d0cf
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php
@@ -0,0 +1,209 @@
+logger = $logger;
+ $this->cache = $cache;
+ $this->container = $container;
+ $this->settings = $settings;
+ }
+
+ public function run(): SchedulerResult
+ {
+ $result = new SchedulerResult();
+
+ $mode = $this->settings->get('cron.mode', 'disabled');
+ if ($mode === 'disabled') {
+ $result->addSkipped('Global', 'Scheduler is disabled');
+ return $result;
+ }
+
+ if ($this->isGlobalLocked()) {
+ $result->addSkipped('Global', 'Global scheduler lock active');
+ return $result;
+ }
+
+ $this->acquireGlobalLock();
+ // Since we want to run every 5 minutes, running it more frequently won't trigger jobs earlier than due,
+ // but locking might prevent overlap if previous run takes > 5 mins.
+ // However, updating global last run on every attempt might be useful for diagnostics,
+ // but strictly speaking, we only care if tasks were processed.
+ $this->updateGlobalLastRun();
+
+ try {
+ $scheduler = new ScheduleJobRegistry($this->container);
+
+ $configFile = BP_BASE_PATH . '/configs/schedule.php';
+ if (file_exists($configFile)) {
+ require $configFile;
+ } else {
+ $this->logger->warning('Scheduler config file not found: ' . $configFile);
+ }
+
+ foreach ($scheduler->getJobs() as $job) {
+ $this->processJob($job, $result);
+ }
+ } catch (Throwable $e) {
+ $this->logger->error('Scheduler run failed: ' . $e->getMessage(), ['exception' => $e]);
+ $result->addFailed('Scheduler', $e->getMessage());
+ } finally {
+ $this->releaseGlobalLock();
+ }
+
+ return $result;
+ }
+
+ private function processJob(Job $job, SchedulerResult $result): void
+ {
+ $name = $job->getName();
+ $id = $job->getId();
+
+ try {
+ // 1. Check if due by Cron expression
+ if (! $job->isDue()) {
+ $result->addSkipped($name, 'Not due');
+ return;
+ }
+
+ // 2. Check Last Run (Prevent running multiple times in the same minute)
+ if ($this->hasRanRecently($id)) {
+ $result->addSkipped($name, 'Already ran recently');
+ return;
+ }
+
+ // 3. Check Lock (Prevent parallel execution)
+ if ($this->isJobLocked($id)) {
+ $result->addSkipped($name, 'Job is locked (running)');
+ return;
+ }
+
+ // Lock and Run
+ $this->lockJob($id);
+ $startTime = microtime(true);
+
+ try {
+ $job->run();
+
+ $duration = microtime(true) - $startTime;
+ $this->updateLastRun($id);
+
+ $this->logger->info("Job executed: {$name}", ['duration' => $duration]);
+ $result->addExecuted($name, $duration);
+
+ } catch (Throwable $e) {
+ $this->updateLastFailure($id, $e->getMessage());
+ $this->logger->error("Job failed: {$name}", ['exception' => $e]);
+ $result->addFailed($name, $e->getMessage());
+ } finally {
+ $this->unlockJob($id);
+ }
+
+ } catch (Throwable $e) {
+ $this->logger->error("Error processing job {$name}: " . $e->getMessage());
+ $result->addFailed($name, 'Processing error: ' . $e->getMessage());
+ }
+ }
+
+ private function isGlobalLocked(): bool
+ {
+ return (bool) $this->cache->get(self::GLOBAL_LOCK_KEY);
+ }
+
+ private function acquireGlobalLock(): void
+ {
+ $this->cache->set(self::GLOBAL_LOCK_KEY, 1, self::GLOBAL_LOCK_TTL);
+ }
+
+ private function releaseGlobalLock(): void
+ {
+ $this->cache->delete(self::GLOBAL_LOCK_KEY);
+ }
+
+ private function isJobLocked(string $id): bool
+ {
+ return (bool) $this->cache->get("scheduler.lock.{$id}");
+ }
+
+ private function lockJob(string $id): void
+ {
+ // 30 minutes max execution time for a single job safe-guard
+ $this->cache->set("scheduler.lock.{$id}", 1, 1800);
+ }
+
+ private function unlockJob(string $id): void
+ {
+ $this->cache->delete("scheduler.lock.{$id}");
+ }
+
+ private function hasRanRecently(string $id): bool
+ {
+ $lastRun = $this->getLastRun($id);
+ if (! $lastRun) {
+ return false;
+ }
+
+ $lastRunDate = (new DateTime())->setTimestamp((int)$lastRun);
+ $now = new DateTime();
+
+ return $lastRunDate->format('Y-m-d H:i') === $now->format('Y-m-d H:i');
+ }
+
+ private function updateLastRun(string $id): void
+ {
+ $this->cache->set("scheduler.last_run.{$id}", time());
+ }
+
+ private function updateGlobalLastRun(): void
+ {
+ $this->cache->set("scheduler.global_last_run", time());
+ }
+
+ public function getGlobalLastRun(): ?int
+ {
+ $time = $this->cache->get("scheduler.global_last_run");
+ return $time ? (int)$time : null;
+ }
+
+ private function updateLastFailure(string $id, string $message): void
+ {
+ $this->cache->set("scheduler.last_failure.{$id}", time());
+ $this->cache->set("scheduler.last_failure_msg.{$id}", $message);
+ }
+
+ public function getLastRun(string $id): ?int
+ {
+ return $this->cache->get("scheduler.last_run.{$id}");
+ }
+
+ public function getLastFailure(string $id): ?array
+ {
+ $time = $this->cache->get("scheduler.last_failure.{$id}");
+ if (!$time) {
+ return null;
+ }
+
+ return [
+ 'time' => (int) $time,
+ 'message' => $this->cache->get("scheduler.last_failure_msg.{$id}"),
+ ];
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerServiceProvider.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerServiceProvider.php
new file mode 100755
index 0000000..f96806d
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerServiceProvider.php
@@ -0,0 +1,24 @@
+container->singleton(SchedulerService::class, function (Container $container) {
+ return new SchedulerService(
+ $container->get(LoggerInterface::class),
+ $container->get(CacheInterface::class),
+ $container,
+ $container->get(SettingsInterface::class)
+ );
+ });
+ }
+}
diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/TaskInterface.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/TaskInterface.php
new file mode 100755
index 0000000..6243f74
--- /dev/null
+++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/TaskInterface.php
@@ -0,0 +1,9 @@
+