assertFeatureEnabled(); $query = Automation::query(); TableEngine::create($query, $request->all()) ->initSearch(['name', 'code', 'title', 'body']) ->select(['*']) ->export($query); return $query; } public function catalog() { $this->assertFeatureEnabled(); return response()->json(app(AutomationCatalog::class)->toArray()); } public function preview(Request $request) { $this->assertFeatureEnabled(); $conditions = array_values((array) $request->input('conditions', [])); $count = app(AutomationEngine::class)->previewCount($conditions); return response()->json(['count' => $count]); } public function store(Request $request) { $this->assertFeatureEnabled(); $data = $this->validated($request); $data['created_by'] = auth()->id(); $row = Automation::create($data); return response()->json($row); } public function update(Request $request, $id) { $this->assertFeatureEnabled(); $row = Automation::query()->findOrFail($id); $data = $this->validated($request, (int) $row->id); $row->fill($data)->save(); return response()->json($row); } public function toggle($id) { $this->assertFeatureEnabled(); $row = Automation::query()->findOrFail($id); if (! $row->is_active) { $dup = $this->duplicateOf($row->logic_fingerprint, (int) $row->id, true); if ($dup) { throw new UnprocessableEntityHttpException( 'اتوماسیون مشابه فعال است: '.$dup->name ); } } $row->is_active = ! $row->is_active; $row->save(); return response()->json(['is_active' => $row->is_active]); } public function destroy($id) { $this->assertFeatureEnabled(); Automation::query()->findOrFail($id)->delete(); 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()) { throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.'); } } private function validated(Request $request, ?int $ignoreId = null): array { $name = trim((string) $request->input('name', '')); $title = trim((string) $request->input('title', '')); $body = trim((string) $request->input('body', '')); if ($name === '' || $title === '' || $body === '') { throw new UnprocessableEntityHttpException('نام، عنوان و متن الزامی است.'); } $triggerType = (string) $request->input('trigger_type', AutomationCatalog::TRIGGER_SCHEDULE); if (! in_array($triggerType, [ AutomationCatalog::TRIGGER_SCHEDULE, AutomationCatalog::TRIGGER_EVENT, AutomationCatalog::TRIGGER_PRICE, ], true)) { throw new UnprocessableEntityHttpException('نوع تریگر نامعتبر است.'); } $channels = array_values(array_intersect( (array) $request->input('channels', []), [AutomationCatalog::CHANNEL_SMS, AutomationCatalog::CHANNEL_IN_APP] )); if ($channels === []) { throw new UnprocessableEntityHttpException('حداقل یک کانال انتخاب کنید.'); } $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 = ! ( ( $triggerType === AutomationCatalog::TRIGGER_EVENT && (int) ($triggerConfigRaw['delay_hours'] ?? 0) === 0 ) || ( $triggerType === AutomationCatalog::TRIGGER_PRICE && $priceWindow === 'calendar_day' ) ); if ($needsChannelHour) { if (in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) && $smsHour === null) { throw new UnprocessableEntityHttpException('ساعت ارسال پیامک را مشخص کنید.'); } if (in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) && $inAppHour === null) { throw new UnprocessableEntityHttpException('ساعت ارسال اعلان را مشخص کنید.'); } } $conditions = array_values(array_filter( (array) $request->input('conditions', []), fn ($row) => is_array($row) && filled($row['field'] ?? null) )); $audienceMode = (string) $request->input('audience_mode', $conditions === [] ? 'all' : 'filter'); if ($audienceMode === 'all') { $conditions = []; } $catalog = app(AutomationCatalog::class); $allowed = $catalog->fieldKeys(); foreach ($conditions as $row) { if (! in_array($row['field'] ?? '', $allowed, true)) { throw new UnprocessableEntityHttpException('شرط نامعتبر است.'); } } $triggerConfig = (array) $request->input('trigger_config', []); $cooldownHours = max(0, (int) $request->input('cooldown_hours', 24)); if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) { $everyDays = max(1, (int) ($triggerConfig['every_days'] ?? 1)); $triggerConfig = ['every_days' => $everyDays]; // هر مشتری حداکثر یک‌بار در همین بازهٔ تکرار $cooldownHours = max($cooldownHours, $everyDays * 24); } elseif ($triggerType === AutomationCatalog::TRIGGER_EVENT) { $eventKeys = array_column(AutomationCatalog::EVENTS, 'key'); if (! in_array((string) ($triggerConfig['event'] ?? ''), $eventKeys, true)) { throw new UnprocessableEntityHttpException('رویداد را انتخاب کنید.'); } $triggerConfig = [ 'event' => (string) $triggerConfig['event'], 'delay_hours' => max(0, (int) ($triggerConfig['delay_hours'] ?? 0)), ]; } elseif ($triggerType === AutomationCatalog::TRIGGER_PRICE) { $step = (float) str_replace(',', '', (string) ($triggerConfig['step_toman'] ?? 0)); if ($step <= 0) { throw new UnprocessableEntityHttpException('رقم تغییر قیمت باید بزرگ‌تر از صفر باشد.'); } $dir = (string) ($triggerConfig['direction'] ?? 'both'); if (! in_array($dir, ['up', 'down', 'both'], true)) { $dir = 'both'; } $window = (string) ($triggerConfig['price_window'] ?? 'calendar_day'); if (! in_array($window, ['calendar_day', 'from_last'], true)) { $window = 'calendar_day'; } $triggerConfig = [ 'step_toman' => $step, 'direction' => $dir, 'product_id' => (int) ($triggerConfig['product_id'] ?? 0) ?: null, 'price_window' => $window, ]; // برای «در یک روز» معمولاً یک‌بار در روز کافی است if ($window === 'calendar_day') { $cooldownHours = max($cooldownHours, 20); } } $fingerprint = app(AutomationFingerprint::class)->make($triggerType, $triggerConfig, $conditions); $dup = $this->duplicateOf($fingerprint, $ignoreId, false); if ($dup) { throw new UnprocessableEntityHttpException( 'اتوماسیون شبیه وجود دارد: '.$dup->name.' — همان را ویرایش کنید.' ); } $starts = $request->input('starts_at') ?: null; $ends = $request->input('ends_at') ?: null; return [ 'name' => $name, 'code' => $this->uniqueCode($name, $ignoreId), 'is_active' => (bool) $request->input('is_active', true), 'starts_at' => $starts, 'ends_at' => $ends, '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, 'channels' => $channels, 'title' => mb_substr($title, 0, 191), 'body' => $body, 'cooldown_hours' => $cooldownHours, 'max_sends' => $request->filled('max_sends') ? max(1, (int) $request->input('max_sends')) : null, 'logic_fingerprint' => $fingerprint, ]; } private function duplicateOf(string $fingerprint, ?int $ignoreId, bool $activeOnly): ?Automation { $q = Automation::query()->where('logic_fingerprint', $fingerprint); if ($ignoreId) { $q->where('id', '!=', $ignoreId); } if ($activeOnly) { $q->where('is_active', true); } return $q->first(); } private function uniqueCode(string $name, ?int $ignoreId): string { $base = Str::slug($name); if ($base === '') { $base = 'auto'; } $code = $base; $i = 2; while ( Automation::query() ->where('code', $code) ->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId)) ->exists() ) { $code = $base.'-'.$i; $i++; } return mb_substr($code, 0, 64); } }