From 1c8ab4b72543fab2df74ee8f51c9c22c3457cd90 Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 24 Aug 2026 22:13:06 +0330 Subject: [PATCH] feat(schedule): add schedule:bootstrap and minute-precise automation runs Install per-module schedule:run cron from artisan, store sms/in_app send minutes, and match scheduled triggers at exact HH:mm Tehran time. Co-authored-by: Cursor --- .../Commands/ScheduleBootstrapCommand.php | 121 ++++++++++++++++++ app/Console/Kernel.php | 1 + .../Admin/AutomationController.php | 8 ++ app/Models/Automation.php | 4 + app/Services/Automation/AutomationEngine.php | 32 +++-- ..._24_220000_add_automation_send_minutes.php | 40 ++++++ scripts/crontab-prices.example.sh | 25 +++- 7 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 app/Console/Commands/ScheduleBootstrapCommand.php create mode 100644 database/migrations/2026_08_24_220000_add_automation_send_minutes.php diff --git a/app/Console/Commands/ScheduleBootstrapCommand.php b/app/Console/Commands/ScheduleBootstrapCommand.php new file mode 100644 index 0000000..9a33469 --- /dev/null +++ b/app/Console/Commands/ScheduleBootstrapCommand.php @@ -0,0 +1,121 @@ +option('run-only'); + $installOnly = (bool) $this->option('install-only'); + + if ($runOnly && $installOnly) { + $this->error('فقط یکی از --run-only یا --install-only را بزنید.'); + + return self::FAILURE; + } + + if (! $runOnly && ! $this->installCronLine()) { + return self::FAILURE; + } + + if ($installOnly) { + return self::SUCCESS; + } + + $this->info('==> php artisan schedule:run'); + + return $this->call('schedule:run'); + } + + private function installCronLine(): bool + { + $php = trim((string) ($this->option('php') ?: PHP_BINARY)); + $artisan = base_path('artisan'); + $marker = $artisan.' schedule:run'; + $line = "* * * * * {$php} {$artisan} schedule:run >> /dev/null 2>&1"; + + $existing = $this->currentCrontab(); + if ($this->crontabHasLine($existing, $marker)) { + $this->info("cron از قبل نصب است: {$artisan}"); + + return true; + } + + if ($this->option('dry-run')) { + $this->line($line); + + return true; + } + + $payload = trim($existing); + if ($payload !== '') { + $payload .= "\n"; + } + $payload .= $line."\n"; + + $tmp = tempnam(sys_get_temp_dir(), 'khaneghi-crontab-'); + if ($tmp === false) { + $this->error('ساخت فایل موقت crontab ناموفق بود.'); + + return false; + } + + file_put_contents($tmp, $payload); + exec('crontab '.escapeshellarg($tmp).' 2>&1', $output, $code); + @unlink($tmp); + + if ($code !== 0) { + $this->error('نصب crontab ناموفق بود.'); + foreach ($output as $row) { + $this->line($row); + } + + return false; + } + + $this->info("cron نصب شد:\n{$line}"); + + return true; + } + + private function currentCrontab(): string + { + exec('crontab -l 2>/dev/null', $lines, $code); + + if ($code !== 0) { + return ''; + } + + return implode("\n", $lines); + } + + private function crontabHasLine(string $crontab, string $marker): bool + { + if ($crontab === '') { + return false; + } + + foreach (preg_split('/\R/u', $crontab) ?: [] as $row) { + $row = trim((string) $row); + if ($row === '' || str_starts_with($row, '#')) { + continue; + } + if (str_contains($row, $marker)) { + return true; + } + } + + return false; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2d4ea15..db2fb5c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel { // تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات) // نیاز: * * * * * php artisan schedule:run + // یا یک‌بار: php artisan schedule:bootstrap $schedule->command('prices:tick-loop --iterations=6 --interval=10') ->everyMinute() ->name('prices_tick_10s') diff --git a/app/Http/Controllers/Admin/AutomationController.php b/app/Http/Controllers/Admin/AutomationController.php index 4dd50f7..ab3e0f0 100644 --- a/app/Http/Controllers/Admin/AutomationController.php +++ b/app/Http/Controllers/Admin/AutomationController.php @@ -167,7 +167,9 @@ class AutomationController extends Controller } $smsHour = $request->input('sms_send_hour'); + $smsMinute = $request->input('sms_send_minute'); $inAppHour = $request->input('in_app_send_hour'); + $inAppMinute = $request->input('in_app_send_minute'); $triggerConfigRaw = (array) $request->input('trigger_config', []); $priceWindow = (string) ($triggerConfigRaw['price_window'] ?? 'calendar_day'); $needsChannelHour = ! ( @@ -266,9 +268,15 @@ class AutomationController extends Controller 'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) ? ($smsHour === null ? null : (int) $smsHour) : null, + 'sms_send_minute' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) + ? ($smsHour === null ? null : max(0, min(59, (int) ($smsMinute ?? 0)))) + : null, 'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) ? ($inAppHour === null ? null : (int) $inAppHour) : null, + 'in_app_send_minute' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) + ? ($inAppHour === null ? null : max(0, min(59, (int) ($inAppMinute ?? 0)))) + : null, 'trigger_type' => $triggerType, 'trigger_config' => $triggerConfig, 'conditions' => $conditions, diff --git a/app/Models/Automation.php b/app/Models/Automation.php index 566ab5b..1ef1b58 100644 --- a/app/Models/Automation.php +++ b/app/Models/Automation.php @@ -17,7 +17,9 @@ class Automation extends Model 'starts_at', 'ends_at', 'sms_send_hour', + 'sms_send_minute', 'in_app_send_hour', + 'in_app_send_minute', 'trigger_type', 'trigger_config', 'last_anchor_price', @@ -44,7 +46,9 @@ class Automation extends Model 'channels' => 'array', 'last_anchor_price' => 'float', 'sms_send_hour' => 'integer', + 'sms_send_minute' => 'integer', 'in_app_send_hour' => 'integer', + 'in_app_send_minute' => 'integer', 'cooldown_hours' => 'integer', 'max_sends' => 'integer', 'sent_count' => 'integer', diff --git a/app/Services/Automation/AutomationEngine.php b/app/Services/Automation/AutomationEngine.php index f709783..329e1cf 100644 --- a/app/Services/Automation/AutomationEngine.php +++ b/app/Services/Automation/AutomationEngine.php @@ -384,14 +384,25 @@ class AutomationEngine private function hourMatches(Automation $auto, string $channel): bool { - $h = $channel === AutomationCatalog::CHANNEL_SMS - ? $auto->sms_send_hour - : $auto->in_app_send_hour; - if ($h === null) { + [$hour, $minute] = $this->scheduledTime($auto, $channel); + if ($hour === null) { return true; } - return (int) now('Asia/Tehran')->hour === (int) $h; + $now = now('Asia/Tehran'); + + return (int) $now->hour === (int) $hour + && (int) $now->minute === (int) ($minute ?? 0); + } + + /** @return array{0: int|null, 1: int|null} */ + private function scheduledTime(Automation $auto, string $channel): array + { + if ($channel === AutomationCatalog::CHANNEL_SMS) { + return [$auto->sms_send_hour, $auto->sms_send_minute]; + } + + return [$auto->in_app_send_hour, $auto->in_app_send_minute]; } private function alreadyRanThisHour(Automation $auto, string $channel): bool @@ -419,17 +430,16 @@ class AutomationEngine private function snapToHour(Automation $auto, string $channel, Carbon $after): Carbon { - $h = $channel === AutomationCatalog::CHANNEL_SMS - ? $auto->sms_send_hour - : $auto->in_app_send_hour; - if ($h === null) { + [$hour, $minute] = $this->scheduledTime($auto, $channel); + if ($hour === null) { return $after; } $local = $after->copy()->timezone('Asia/Tehran'); - if ((int) $local->hour === (int) $h) { + $targetMinute = (int) ($minute ?? 0); + if ((int) $local->hour === (int) $hour && (int) $local->minute === $targetMinute) { return $after; } - $target = $local->copy()->setTime((int) $h, 0, 0); + $target = $local->copy()->setTime((int) $hour, $targetMinute, 0); if ($target->lt($local)) { $target->addDay(); } diff --git a/database/migrations/2026_08_24_220000_add_automation_send_minutes.php b/database/migrations/2026_08_24_220000_add_automation_send_minutes.php new file mode 100644 index 0000000..b0fe420 --- /dev/null +++ b/database/migrations/2026_08_24_220000_add_automation_send_minutes.php @@ -0,0 +1,40 @@ +unsignedTinyInteger('sms_send_minute')->nullable()->after('sms_send_hour'); + } + if (! Schema::hasColumn('automations', 'in_app_send_minute')) { + $table->unsignedTinyInteger('in_app_send_minute')->nullable()->after('in_app_send_hour'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('automations')) { + return; + } + + Schema::table('automations', function (Blueprint $table) { + if (Schema::hasColumn('automations', 'sms_send_minute')) { + $table->dropColumn('sms_send_minute'); + } + if (Schema::hasColumn('automations', 'in_app_send_minute')) { + $table->dropColumn('in_app_send_minute'); + } + }); + } +}; diff --git a/scripts/crontab-prices.example.sh b/scripts/crontab-prices.example.sh index 3bddda1..3055b4a 100644 --- a/scripts/crontab-prices.example.sh +++ b/scripts/crontab-prices.example.sh @@ -1,14 +1,27 @@ #!/bin/sh -# نصب کران قیمت — یکی از دو روش را انتخاب کنید. -# مسیر را با مسیر واقعی سرور عوض کنید. +# نصب کران — برای هر ماژول/tenant یک‌بار از داخل همان مسیر سرور: +# cd /opt/test/server && php artisan schedule:bootstrap +# +# این دستور: +# 1) اگر خط cron این ماژول نبود، اضافه می‌کند +# 2) یک‌بار schedule:run را اجرا می‌کند +# +# فقط نصب cron: +# php artisan schedule:bootstrap --install-only +# +# فقط یک‌بار اجرا: +# php artisan schedule:bootstrap --run-only +# +# دیدن خط بدون نصب: +# php artisan schedule:bootstrap --dry-run APP_DIR="/opt/rahimigold/server" -# ─── روش ۱ (پیشنهادی): schedule:run هر دقیقه ─── -# prices:tick-loop داخل Kernel هر ۱۰ ثانیه ۶ بار اجرا می‌شود. +# ─── روش ۱ (پیشنهادی): schedule:bootstrap ─── +# cd $APP_DIR && php artisan schedule:bootstrap + +# ─── روش ۲: دستی crontab ─── # crontab -e: # * * * * * cd $APP_DIR && php artisan schedule:run >> /dev/null 2>&1 - -# ─── روش ۲: مستقیم هر ۱۰ ثانیه (بدون schedule:run) ─── # * * * * * cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 # * * * * * sleep 10 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 # * * * * * sleep 20 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1