runSchedule(); $this->processDelayedJobs(); } public function onCustomerEvent(string $event, int $customerId): void { $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 { $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; } $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; } $cfg = $auto->trigger_config ?? []; $step = (float) ($cfg['step_toman'] ?? 0); if ($step <= 0) { return; } $delta = $current - (float) $anchor; $dir = (string) ($cfg['direction'] ?? 'both'); $moved = match ($dir) { 'up' => $delta >= $step, 'down' => $delta <= -$step, default => abs($delta) >= $step, }; if (! $moved) { 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 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; } foreach ($this->channels($auto) as $channel) { if (! $this->hourMatches($auto, $channel)) { continue; } if ($this->alreadyRanThisHour($auto, $channel)) { continue; } $this->sendAudienceNow($auto, $channel); $this->markHourRun($auto, $channel); } } } 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->hourMatches($auto, $channel) && $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; } }