From 995f6984edb3b898284891e89dbbd4729d6c3829 Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 24 Aug 2026 21:46:13 +0330 Subject: [PATCH] feat(automations): add run-now test endpoint and sends log table Enable immediate automation test runs without waiting for the scheduled hour, expose send history for debugging, and return all SuperAdmin overlay keys on refresh instead of only enable_automation. Co-authored-by: Cursor --- ...uperAdminProgramSettingsRefreshCommand.php | 9 ++-- .../Admin/AutomationController.php | 44 +++++++++++++++++ .../Controllers/Admin/SettingsController.php | 11 +++-- app/Services/Automation/AutomationEngine.php | 48 +++++++++++++++++++ app/Services/Automation/AutomationSender.php | 10 ++-- .../SuperAdminProgramSettingsOverlay.php | 20 ++++++-- routes/admin.php | 2 + 7 files changed, 127 insertions(+), 17 deletions(-) diff --git a/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php b/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php index 37fd494..772c262 100644 --- a/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php +++ b/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php @@ -10,7 +10,7 @@ class SuperAdminProgramSettingsRefreshCommand extends Command protected $signature = 'superadmin:refresh-program-settings {--clear-only : فقط کش last-good را پاک کن}'; - protected $description = 'کش overlay تنظیمات سوپرادمین را پاک/تازه می‌کند (مثلاً بعد از تغییر enable_automation)'; + protected $description = 'کش overlay تنظیمات سوپرادمین را پاک/تازه می‌کند'; public function handle(): int { @@ -30,11 +30,10 @@ class SuperAdminProgramSettingsRefreshCommand extends Command return self::FAILURE; } + $items = is_array($result['items'] ?? null) ? $result['items'] : []; $this->info('fetched '.(int) ($result['count'] ?? 0).' setting(s) from SuperAdmin'); - if (($result['enable_automation'] ?? null) !== null) { - $this->line('enable_automation='.$result['enable_automation']); - } else { - $this->warn('enable_automation missing in SuperAdmin response'); + foreach ($items as $key => $value) { + $this->line(" {$key}={$value}"); } return self::SUCCESS; diff --git a/app/Http/Controllers/Admin/AutomationController.php b/app/Http/Controllers/Admin/AutomationController.php index 3274197..4dd50f7 100644 --- a/app/Http/Controllers/Admin/AutomationController.php +++ b/app/Http/Controllers/Admin/AutomationController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\DB\Te\TableEngine; use App\Http\Controllers\Controller; use App\Models\Automation; +use App\Models\AutomationSend; use App\Services\Automation\AutomationCatalog; use App\Services\Automation\AutomationEngine; use App\Services\Automation\AutomationFingerprint; @@ -89,6 +90,49 @@ class AutomationController extends Controller return response()->json(true); } + /** اجرای فوری برای تست — بدون انتظار ساعت cron */ + public function runNow(Request $request, $id) + { + $this->assertFeatureEnabled(); + $row = Automation::query()->findOrFail($id); + $force = $request->boolean('force', true); + $stats = app(AutomationEngine::class)->runNow($row, $force); + + return response()->json([ + 'message' => sprintf( + 'مخاطب %d — ارسال موفق %d، ناموفق %d، ردشده %d', + $stats['audience'], + $stats['sent'], + $stats['failed'], + $stats['skipped'] + ), + ...$stats, + ]); + } + + public function sendsTable(Request $request, $id) + { + $this->assertFeatureEnabled(); + Automation::query()->findOrFail($id); + + $query = AutomationSend::query() + ->where('automation_id', (int) $id) + ->leftJoin('customers', 'customers.id', '=', 'automation_sends.customer_id') + ->select([ + 'automation_sends.*', + 'customers.name as customer_name', + 'customers.mobile as customer_mobile', + ]) + ->orderByDesc('automation_sends.id'); + + TableEngine::create($query, $request->all()) + ->initSearch(['customers.name', 'customers.mobile', 'automation_sends.status', 'automation_sends.error']) + ->select(['automation_sends.*', 'customers.name as customer_name', 'customers.mobile as customer_mobile']) + ->export($query); + + return $query; + } + private function assertFeatureEnabled(): void { if (! app(ConfigService::class)->isAutomationEnabled()) { diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 007dfcc..4e2e119 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -80,7 +80,7 @@ class SettingsController extends Controller /** * پاک کردن کش overlay و خواندن دوباره از سوپرادمین - * (سوییچ‌هایی مثل enable_automation بعد از تغییر در پنل سوپرادمین). + * (همهٔ کلیدهای can_edit=0 مالک سوپرادمین — دسترسی مشتری به قابلیت‌ها). */ public function refreshSuperAdminOverlay() { @@ -91,14 +91,17 @@ class SettingsController extends Controller 'message' => 'ارتباط با سوپرادمین برقرار نشد؛ کش پاک شد و در درخواست بعدی دوباره تلاش می‌شود', 'ok' => false, 'count' => 0, + 'items' => [], ], 502); } + $count = (int) ($result['count'] ?? 0); + return response()->json([ - 'message' => 'تنظیمات پیشرفته از سوپرادمین همگام شد', + 'message' => "همگام شد ({$count} کلید دسترسی از سوپرادمین)", 'ok' => true, - 'count' => (int) ($result['count'] ?? 0), - 'enable_automation' => $result['enable_automation'] ?? null, + 'count' => $count, + 'items' => $result['items'] ?? [], 'source' => $result['source'] ?? 'superadmin', ]); } diff --git a/app/Services/Automation/AutomationEngine.php b/app/Services/Automation/AutomationEngine.php index 16ae220..f709783 100644 --- a/app/Services/Automation/AutomationEngine.php +++ b/app/Services/Automation/AutomationEngine.php @@ -9,6 +9,7 @@ use App\Models\Product; use App\Services\ConfigService; use Carbon\Carbon; use Illuminate\Support\Facades\Log; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; class AutomationEngine { @@ -87,6 +88,53 @@ class AutomationEngine return $this->conditions->apply($this->conditions->baseQuery(), $conditions)->count(); } + /** + * اجرای فوری برای تست ادمین — بدون محدودیت ساعت، بدون touch کردن last_sms_run_at. + * + * @return array{audience: int, sent: int, failed: int, skipped: int} + */ + public function runNow(Automation $auto, bool $force = true): array + { + if (! app(ConfigService::class)->isAutomationEnabled()) { + throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.'); + } + if (! $auto->is_active) { + throw new UnprocessableEntityHttpException('اتوماسیون غیرفعال است.'); + } + if (! $this->inWindow($auto)) { + throw new UnprocessableEntityHttpException('اتوماسیون خارج از بازهٔ شروع/پایان است.'); + } + + $stats = ['audience' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0]; + $audienceQuery = $this->conditions->apply( + $this->conditions->baseQuery(), + $auto->conditions ?? [] + ); + $stats['audience'] = (int) $audienceQuery->count(); + + foreach ($this->channels($auto) as $channel) { + $this->conditions + ->apply($this->conditions->baseQuery(), $auto->conditions ?? []) + ->select(['id', 'name', 'fname', 'lname', 'mobile', 'type', 'is_archive']) + ->chunkById(80, function ($customers) use ($auto, $channel, $force, &$stats) { + foreach ($customers as $customer) { + if (! $force && ! $this->sender->maySend($auto, $customer, $channel)) { + $stats['skipped']++; + + continue; + } + if ($this->sender->send($auto, $customer, $channel, $force)) { + $stats['sent']++; + } else { + $stats['failed']++; + } + } + }); + } + + return $stats; + } + private function tryPriceFire(Automation $auto, array $payload): void { if (! $this->inWindow($auto)) { diff --git a/app/Services/Automation/AutomationSender.php b/app/Services/Automation/AutomationSender.php index 79048a8..2b7f450 100644 --- a/app/Services/Automation/AutomationSender.php +++ b/app/Services/Automation/AutomationSender.php @@ -21,9 +21,9 @@ class AutomationSender ) { } - public function send(Automation $auto, Customer $customer, string $channel): bool + public function send(Automation $auto, Customer $customer, string $channel, bool $force = false): bool { - if (! $this->maySend($auto, $customer, $channel)) { + if (! $this->maySend($auto, $customer, $channel, $force)) { return false; } @@ -71,12 +71,16 @@ class AutomationSender } } - public function maySend(Automation $auto, Customer $customer, string $channel): bool + public function maySend(Automation $auto, Customer $customer, string $channel, bool $force = false): bool { if (! $auto->usesChannel($channel)) { return false; } + if ($force) { + return true; + } + $max = $auto->max_sends; if ($max !== null && (int) $max > 0) { $count = AutomationSend::query() diff --git a/app/Services/SuperAdminProgramSettingsOverlay.php b/app/Services/SuperAdminProgramSettingsOverlay.php index ffebdcd..32062ad 100644 --- a/app/Services/SuperAdminProgramSettingsOverlay.php +++ b/app/Services/SuperAdminProgramSettingsOverlay.php @@ -86,7 +86,7 @@ class SuperAdminProgramSettingsOverlay /** * کش را پاک می‌کند و دوباره از سوپرادمین می‌خواند. * - * @return array{ok: bool, count: int, enable_automation: string|null, source: string} + * @return array{ok: bool, count: int, items: array, source: string} */ public function refreshFromSuperAdmin(): array { @@ -96,17 +96,27 @@ class SuperAdminProgramSettingsOverlay return [ 'ok' => false, 'count' => 0, - 'enable_automation' => null, + 'items' => [], 'source' => 'unavailable', ]; } - $auto = $map['enable_automation']['value'] ?? null; + $items = []; + foreach ($map as $key => $row) { + if (! is_array($row)) { + continue; + } + $value = $row['value'] ?? null; + if ($value === null || $value === '') { + continue; + } + $items[(string) $key] = (string) $value; + } return [ 'ok' => true, - 'count' => count($map), - 'enable_automation' => $auto === null || $auto === '' ? null : (string) $auto, + 'count' => count($items), + 'items' => $items, 'source' => 'superadmin', ]; } diff --git a/routes/admin.php b/routes/admin.php index e3a38c1..bcb758a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -71,6 +71,8 @@ Route::middleware('auth:user')->group(function (){ Route::post('automations', [AdminAutomationController::class, 'store']); Route::put('automations/{id}', [AdminAutomationController::class, 'update']); Route::put('automations/{id}/toggle', [AdminAutomationController::class, 'toggle']); + Route::post('automations/{id}/run-now', [AdminAutomationController::class, 'runNow']); + Route::post('automations/{id}/sends/table', [AdminAutomationController::class, 'sendsTable']); Route::delete('automations/{id}', [AdminAutomationController::class, 'destroy']); //------------ConditionalOrder Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']);