From 65973d2d79a8c6bfbfc367b56a2b83e465fa2e32 Mon Sep 17 00:00:00 2001 From: Nikita Kiselev Date: Sat, 6 Dec 2025 23:47:33 +0300 Subject: [PATCH] feat: add scheduler module --- Makefile | 5 +- cli | 2 + .../controller/extension/module/tgshop.php | 4 +- .../controller/extension/tgshop/handle.php | 2 +- module/oc_telegram_shop/upload/cli.php | 91 +++- .../upload/oc_telegram_shop/composer.json | 5 +- .../upload/oc_telegram_shop/composer.lock | 418 +++++++++++++++++- .../oc_telegram_shop/configs/schedule.php | 17 + .../console/ApplicationFactory.php | 34 ++ .../console/Commands/ScheduleListCommand.php | 72 +++ .../console/Commands/ScheduleRunCommand.php | 61 +++ .../console/Commands/TeleCartCommand.php | 13 + .../console/Commands/VersionCommand.php | 19 + .../framework/Scheduler/Job.php | 102 +++++ .../Scheduler/ScheduleJobRegistry.php | 40 ++ .../framework/Scheduler/SchedulerResult.php | 44 ++ .../framework/Scheduler/SchedulerService.php | 209 +++++++++ .../Scheduler/SchedulerServiceProvider.php | 24 + .../framework/Scheduler/TaskInterface.php | 9 + 19 files changed, 1158 insertions(+), 13 deletions(-) create mode 100755 cli mode change 100644 => 100755 module/oc_telegram_shop/upload/cli.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/configs/schedule.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/console/ApplicationFactory.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleListCommand.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/ScheduleRunCommand.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/TeleCartCommand.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/console/Commands/VersionCommand.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/Job.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/ScheduleJobRegistry.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerResult.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerServiceProvider.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/TaskInterface.php 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 @@ +