isAutomationEnabled()) { return; } $this->runSchedule(); $this->processDelayedJobs(); } public function onCustomerEvent(string $event, int $customerId): void { if (! app(ConfigService::class)->isAutomationEnabled()) { return; } $customer = Customer::query()->find($customerId); if (! $customer || (int) $customer->type !== 2 || $customer->is_archive) { return; } $rows = Automation::query() ->where('is_active', true) ->where('trigger_type', AutomationCatalog::TRIGGER_EVENT) ->get(); foreach ($rows as $auto) { if (! $this->inWindow($auto)) { continue; } $cfg = $auto->trigger_config ?? []; if (($cfg['event'] ?? '') !== $event) { continue; } if (! $this->conditions->matchesCustomer($customer, $auto->conditions ?? [])) { continue; } $this->enqueueCustomer($auto, $customer, (int) ($cfg['delay_hours'] ?? 0)); } } public function evaluatePriceMove(array $payload = []): void { if (! app(ConfigService::class)->isAutomationEnabled()) { return; } $rows = Automation::query() ->where('is_active', true) ->where('trigger_type', AutomationCatalog::TRIGGER_PRICE) ->get(); if ($rows->isEmpty()) { return; } foreach ($rows as $auto) { try { $this->tryPriceFire($auto, $payload); } catch (\Throwable $e) { Log::warning('Automation price evaluate failed', [ 'id' => $auto->id, 'message' => $e->getMessage(), ]); } } } public function previewCount(array $conditions): int { return $this->conditions->apply($this->conditions->baseQuery(), $conditions)->count(); } private function tryPriceFire(Automation $auto, array $payload): void { if (! $this->inWindow($auto)) { return; } $current = $this->currentPrice($auto, $payload); if ($current <= 0) { return; } $cfg = $auto->trigger_config ?? []; $step = (float) ($cfg['step_toman'] ?? 0); if ($step <= 0) { return; } $window = (string) ($cfg['price_window'] ?? 'calendar_day'); $dir = (string) ($cfg['direction'] ?? 'both'); if ($window === 'calendar_day') { $this->tryPriceFireCalendarDay($auto, $current, $step, $dir, $cfg); return; } $this->tryPriceFireFromLast($auto, $current, $step, $dir); } /** تغییر از شروع امروز تا الان؛ حداکثر یک‌بار در روز */ private function tryPriceFireCalendarDay( Automation $auto, float $current, float $step, string $dir, array $cfg ): void { $today = now('Asia/Tehran')->toDateString(); $dayKey = (string) ($cfg['day_key'] ?? ''); $dayOpen = (float) ($cfg['day_open'] ?? 0); $dayFired = (bool) ($cfg['day_fired'] ?? false); if ($dayKey !== $today || $dayOpen <= 0) { $cfg['day_key'] = $today; $cfg['day_open'] = $current; $cfg['day_fired'] = false; Automation::query()->where('id', $auto->id)->update([ 'trigger_config' => $cfg, 'last_anchor_price' => $current, ]); $auto->trigger_config = $cfg; $auto->last_anchor_price = $current; return; } if ($dayFired) { return; } $delta = $current - $dayOpen; if (! $this->priceDirectionMet($dir, $delta, $step)) { return; } // برای اعلان قیمت روزانه معمولاً سر ضرب به همه می‌رود $this->enqueueAudience($auto, 0); $cfg['day_fired'] = true; Automation::query()->where('id', $auto->id)->update([ 'trigger_config' => $cfg, 'last_anchor_price' => $current, ]); $auto->trigger_config = $cfg; $auto->last_anchor_price = $current; } /** پله‌ای از آخرین ارسال */ private function tryPriceFireFromLast(Automation $auto, float $current, float $step, string $dir): void { $anchor = $auto->last_anchor_price; if ($anchor === null || (float) $anchor <= 0) { Automation::query()->where('id', $auto->id)->update(['last_anchor_price' => $current]); $auto->last_anchor_price = $current; return; } $delta = $current - (float) $anchor; if (! $this->priceDirectionMet($dir, $delta, $step)) { return; } $anyHour = false; foreach ($this->channels($auto) as $channel) { if ($this->hourMatches($auto, $channel)) { $anyHour = true; break; } } if (! $anyHour) { return; } $this->enqueueAudience($auto, 0); Automation::query()->where('id', $auto->id)->update(['last_anchor_price' => $current]); $auto->last_anchor_price = $current; } private function priceDirectionMet(string $dir, float $delta, float $step): bool { return match ($dir) { 'up' => $delta >= $step, 'down' => $delta <= -$step, default => abs($delta) >= $step, }; } private function runSchedule(): void { $rows = Automation::query() ->where('is_active', true) ->where('trigger_type', AutomationCatalog::TRIGGER_SCHEDULE) ->get(); foreach ($rows as $auto) { if (! $this->inWindow($auto)) { continue; } $everyDays = max(1, (int) (($auto->trigger_config ?? [])['every_days'] ?? 1)); foreach ($this->channels($auto) as $channel) { if (! $this->hourMatches($auto, $channel)) { continue; } if (! $this->scheduleDue($auto, $channel, $everyDays)) { continue; } $this->sendAudienceNow($auto, $channel); $this->markHourRun($auto, $channel); } } } /** آیا از آخرین اجرای موفق این کانال، حداقل N روز گذشته؟ */ private function scheduleDue(Automation $auto, string $channel, int $everyDays): bool { $at = $channel === AutomationCatalog::CHANNEL_SMS ? $auto->last_sms_run_at : $auto->last_in_app_run_at; if (! $at) { return true; } $lastDay = $at->copy()->timezone('Asia/Tehran')->startOfDay(); $today = now('Asia/Tehran')->startOfDay(); return $lastDay->diffInDays($today) >= $everyDays; } private function processDelayedJobs(): void { $jobs = AutomationDelayedJob::query() ->whereNull('processed_at') ->where('run_at', '<=', now()) ->orderBy('id') ->limit(200) ->get(); foreach ($jobs as $job) { $auto = Automation::query()->find($job->automation_id); $customer = Customer::query()->find($job->customer_id); $job->processed_at = now(); $job->save(); if (! $auto || ! $auto->is_active || ! $this->inWindow($auto) || ! $customer) { continue; } if (! $this->conditions->matchesCustomer($customer, $auto->conditions ?? [])) { continue; } $this->sender->send($auto, $customer, $job->channel); } } private function sendAudienceNow(Automation $auto, string $channel): void { $this->conditions ->apply($this->conditions->baseQuery(), $auto->conditions ?? []) ->select(['id', 'name', 'fname', 'lname', 'mobile', 'type', 'is_archive']) ->chunkById(80, function ($customers) use ($auto, $channel) { foreach ($customers as $customer) { $this->sender->send($auto, $customer, $channel); } }); } private function enqueueAudience(Automation $auto, int $delayHours): void { $this->conditions ->apply($this->conditions->baseQuery(), $auto->conditions ?? []) ->select(['id']) ->chunkById(200, function ($customers) use ($auto, $delayHours) { foreach ($customers as $customer) { $this->enqueueCustomer($auto, $customer, $delayHours); } }); } private function enqueueCustomer(Automation $auto, Customer $customer, int $delayHours): void { $after = now()->addHours(max(0, $delayHours)); foreach ($this->channels($auto) as $channel) { $exists = AutomationDelayedJob::query() ->where('automation_id', $auto->id) ->where('customer_id', $customer->id) ->where('channel', $channel) ->whereNull('processed_at') ->exists(); if ($exists) { continue; } if ($delayHours === 0 && $this->sender->maySend($auto, $customer, $channel)) { // سر ضرب: بلافاصله، بدون انتظار ساعت کانال $this->sender->send($auto, $customer, $channel); continue; } AutomationDelayedJob::create([ 'automation_id' => $auto->id, 'customer_id' => $customer->id, 'channel' => $channel, 'run_at' => $this->snapToHour($auto, $channel, $after), ]); } } private function inWindow(Automation $auto): bool { $now = now(); if ($auto->starts_at && $now->lt($auto->starts_at)) { return false; } if ($auto->ends_at && $now->gt($auto->ends_at)) { return false; } return true; } 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) { return true; } return (int) now('Asia/Tehran')->hour === (int) $h; } private function alreadyRanThisHour(Automation $auto, string $channel): bool { $at = $channel === AutomationCatalog::CHANNEL_SMS ? $auto->last_sms_run_at : $auto->last_in_app_run_at; if (! $at) { return false; } return $at->copy()->timezone('Asia/Tehran')->format('Y-m-d H') === now('Asia/Tehran')->format('Y-m-d H'); } private function markHourRun(Automation $auto, string $channel): void { $col = $channel === AutomationCatalog::CHANNEL_SMS ? 'last_sms_run_at' : 'last_in_app_run_at'; $now = now(); Automation::query()->where('id', $auto->id)->update([$col => $now]); $auto->{$col} = $now; } 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) { return $after; } $local = $after->copy()->timezone('Asia/Tehran'); if ((int) $local->hour === (int) $h) { return $after; } $target = $local->copy()->setTime((int) $h, 0, 0); if ($target->lt($local)) { $target->addDay(); } return $target; } /** @return list */ private function channels(Automation $auto): array { $out = []; foreach ([AutomationCatalog::CHANNEL_SMS, AutomationCatalog::CHANNEL_IN_APP] as $ch) { if ($auto->usesChannel($ch)) { $out[] = $ch; } } return $out; } private function currentPrice(Automation $auto, array $payload): float { $board = (float) ($payload['price'] ?? 0); $productId = (int) (($auto->trigger_config ?? [])['product_id'] ?? 0); if ($productId <= 0) { return $board; } $product = Product::query()->find($productId); if (! $product) { return $board; } $enriched = (float) (($product->price ?? 0) + ($product->price_profit ?? 0)); return $enriched > 0 ? $enriched : $board; } }