From 657c10d1cca3da51d0af4408ab87070b0500f30f Mon Sep 17 00:00:00 2001 From: adonis Date: Sun, 23 Aug 2026 14:16:00 +0330 Subject: [PATCH] added automation --- .../Commands/RunAutomationsCommand.php | 20 ++ app/Console/Kernel.php | 5 + .../Admin/AutomationController.php | 222 ++++++++++++ .../Controllers/Admin/WalletController.php | 5 + app/Http/Controllers/Auth/AuthController.php | 10 + .../Controllers/Customer/WalletController.php | 1 + .../Single/SingleOrderController.php | 2 + .../Controllers/Site/ReportController.php | 15 + app/Models/Automation.php | 70 ++++ app/Models/AutomationDelayedJob.php | 27 ++ app/Models/AutomationSend.php | 22 ++ app/Services/Automation/AutomationCatalog.php | 206 +++++++++++ .../Automation/AutomationConditionQuery.php | 166 +++++++++ app/Services/Automation/AutomationEngine.php | 327 ++++++++++++++++++ app/Services/Automation/AutomationEvents.php | 24 ++ .../Automation/AutomationFingerprint.php | 38 ++ app/Services/Automation/AutomationSender.php | 129 +++++++ ...08_23_000001_create_automations_tables.php | 73 ++++ routes/admin.php | 10 + 19 files changed, 1372 insertions(+) create mode 100644 app/Console/Commands/RunAutomationsCommand.php create mode 100644 app/Http/Controllers/Admin/AutomationController.php create mode 100644 app/Models/Automation.php create mode 100644 app/Models/AutomationDelayedJob.php create mode 100644 app/Models/AutomationSend.php create mode 100644 app/Services/Automation/AutomationCatalog.php create mode 100644 app/Services/Automation/AutomationConditionQuery.php create mode 100644 app/Services/Automation/AutomationEngine.php create mode 100644 app/Services/Automation/AutomationEvents.php create mode 100644 app/Services/Automation/AutomationFingerprint.php create mode 100644 app/Services/Automation/AutomationSender.php create mode 100644 database/migrations/2026_08_23_000001_create_automations_tables.php diff --git a/app/Console/Commands/RunAutomationsCommand.php b/app/Console/Commands/RunAutomationsCommand.php new file mode 100644 index 0000000..7fa522e --- /dev/null +++ b/app/Console/Commands/RunAutomationsCommand.php @@ -0,0 +1,20 @@ +runDue(); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 6e54352..2d4ea15 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -41,6 +41,11 @@ class Kernel extends ConsoleKernel ->dailyAt('03:40') ->name('pargar_requeue_wallet_docs_all') ->withoutOverlapping(110); + + $schedule->command('automations:run') + ->everyMinute() + ->name('automations_run') + ->withoutOverlapping(50); } protected function commands() diff --git a/app/Http/Controllers/Admin/AutomationController.php b/app/Http/Controllers/Admin/AutomationController.php new file mode 100644 index 0000000..60422f2 --- /dev/null +++ b/app/Http/Controllers/Admin/AutomationController.php @@ -0,0 +1,222 @@ +all()) + ->initSearch(['name', 'code', 'title', 'body']) + ->select(['*']) + ->export($query); + + return $query; + } + + public function catalog() + { + return response()->json(app(AutomationCatalog::class)->toArray()); + } + + public function preview(Request $request) + { + $conditions = array_values((array) $request->input('conditions', [])); + $count = app(AutomationEngine::class)->previewCount($conditions); + + return response()->json(['count' => $count]); + } + + public function store(Request $request) + { + $data = $this->validated($request); + $data['created_by'] = auth()->id(); + $row = Automation::create($data); + + return response()->json($row); + } + + public function update(Request $request, $id) + { + $row = Automation::query()->findOrFail($id); + $data = $this->validated($request, (int) $row->id); + $row->fill($data)->save(); + + return response()->json($row); + } + + public function toggle($id) + { + $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) + { + Automation::query()->findOrFail($id)->delete(); + + return response()->json(true); + } + + 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'); + $inAppHour = $request->input('in_app_send_hour'); + 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) + )); + $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', []); + if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) { + $triggerConfig = []; + } 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'; + } + $triggerConfig = [ + 'step_toman' => $step, + 'direction' => $dir, + 'product_id' => (int) ($triggerConfig['product_id'] ?? 0) ?: null, + ]; + } + + $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) ? (int) $smsHour : null, + 'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) ? (int) $inAppHour : null, + 'trigger_type' => $triggerType, + 'trigger_config' => $triggerConfig, + 'conditions' => $conditions, + 'channels' => $channels, + 'title' => mb_substr($title, 0, 191), + 'body' => $body, + 'cooldown_hours' => max(0, (int) $request->input('cooldown_hours', 24)), + '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); + } +} diff --git a/app/Http/Controllers/Admin/WalletController.php b/app/Http/Controllers/Admin/WalletController.php index dbb9cf0..6c6a006 100644 --- a/app/Http/Controllers/Admin/WalletController.php +++ b/app/Http/Controllers/Admin/WalletController.php @@ -212,6 +212,7 @@ class WalletController extends Controller } catch (\Throwable $e) { report($e); } + \App\Services\Automation\AutomationEvents::fire('wallet.rial_charge', (int) $customer->id); return response()->json(true); } @@ -324,6 +325,10 @@ class WalletController extends Controller report($e); } + if ((float) $data['value'] > 0) { + \App\Services\Automation\AutomationEvents::fire('wallet.rial_charge', (int) $customer->id); + } + $this->broadcastCustomerWalletAssets($customer); return response()->json(true); diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index 84a6104..4d3999e 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -32,6 +32,9 @@ class AuthController extends Controller $token = auth($data['provider'])->login($user); $user->last_seen_at = Carbon::now(); $user->save(); + if (($data['provider'] ?? '') === 'customer') { + \App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id); + } return $this->respondWithToken($token); } $credentials = $request->only(['mobile', 'password']); @@ -69,6 +72,7 @@ class AuthController extends Controller } $token = auth($provider)->login($user); $user->update(['last_seen_at' => Carbon::now()]); + \App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id); return $this->respondWithToken($token); } @@ -97,6 +101,9 @@ class AuthController extends Controller $user->update([ 'last_seen_at' => Carbon::now() ]); + if ($provider === 'customer') { + \App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id); + } return $this->respondWithToken($token); } @@ -151,6 +158,9 @@ class AuthController extends Controller $user->last_seen_at = Carbon::now(); $user->token_sms = null; $user->save(); + if (($data['provider'] ?? '') === 'customer') { + \App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id); + } return $this->respondWithToken($token); } throw new UnprocessableEntityHttpException('کد وارد شده اشتباه است'); diff --git a/app/Http/Controllers/Customer/WalletController.php b/app/Http/Controllers/Customer/WalletController.php index f06e3fc..017eacc 100644 --- a/app/Http/Controllers/Customer/WalletController.php +++ b/app/Http/Controllers/Customer/WalletController.php @@ -472,6 +472,7 @@ class WalletController extends Controller } catch (\Throwable $e) { report($e); } + \App\Services\Automation\AutomationEvents::fire('withdraw.request', (int) $customer->id); return response()->json($Withdraw); } diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index 0371074..8cd788a 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -955,6 +955,8 @@ class SingleOrderController extends Controller } catch (\Throwable $e) { report($e); } + $event = (int) ($data['direction'] ?? 0) === 2 ? 'order.sell_success' : 'order.buy_success'; + \App\Services\Automation\AutomationEvents::fire($event, (int) $customer->id); } private function assertProviderOrderAccepted(array &$data): void diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 9786da1..732451c 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -47,6 +47,11 @@ class ReportController extends Controller public function updatePrise() { app(\App\Services\PriceTriggerService::class)->evaluateActive(); + try { + app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove(); + } catch (\Throwable $e) { + report($e); + } return response()->json(); } @@ -126,10 +131,20 @@ class ReportController extends Controller if (app()->runningInConsole()) { app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload); + try { + app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload); + } catch (\Throwable $e) { + report($e); + } } else { dispatch(function () use ($pricePayload) { app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload); + try { + app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload); + } catch (\Throwable $e) { + report($e); + } })->afterResponse(); } } diff --git a/app/Models/Automation.php b/app/Models/Automation.php new file mode 100644 index 0000000..566ab5b --- /dev/null +++ b/app/Models/Automation.php @@ -0,0 +1,70 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'trigger_config' => 'array', + 'conditions' => 'array', + 'channels' => 'array', + 'last_anchor_price' => 'float', + 'sms_send_hour' => 'integer', + 'in_app_send_hour' => 'integer', + 'cooldown_hours' => 'integer', + 'max_sends' => 'integer', + 'sent_count' => 'integer', + 'failed_count' => 'integer', + 'last_sms_run_at' => 'datetime', + 'last_in_app_run_at' => 'datetime', + ]; + + public function sends(): HasMany + { + return $this->hasMany(AutomationSend::class); + } + + public function delayedJobs(): HasMany + { + return $this->hasMany(AutomationDelayedJob::class); + } + + public function usesChannel(string $channel): bool + { + return in_array($channel, $this->channels ?? [], true); + } +} diff --git a/app/Models/AutomationDelayedJob.php b/app/Models/AutomationDelayedJob.php new file mode 100644 index 0000000..90e1522 --- /dev/null +++ b/app/Models/AutomationDelayedJob.php @@ -0,0 +1,27 @@ + 'datetime', + 'processed_at' => 'datetime', + ]; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } +} diff --git a/app/Models/AutomationSend.php b/app/Models/AutomationSend.php new file mode 100644 index 0000000..35b7f23 --- /dev/null +++ b/app/Models/AutomationSend.php @@ -0,0 +1,22 @@ +belongsTo(Automation::class); + } +} diff --git a/app/Services/Automation/AutomationCatalog.php b/app/Services/Automation/AutomationCatalog.php new file mode 100644 index 0000000..95204ed --- /dev/null +++ b/app/Services/Automation/AutomationCatalog.php @@ -0,0 +1,206 @@ + 'customer.login', 'name' => 'ورود مشتری'], + ['key' => 'order.buy_success', 'name' => 'خرید موفق'], + ['key' => 'order.sell_success', 'name' => 'فروش موفق'], + ['key' => 'wallet.rial_charge', 'name' => 'شارژ ریالی کیف'], + ['key' => 'withdraw.request', 'name' => 'درخواست برداشت ریالی'], + ]; + + /** @return array */ + public function toArray(): array + { + return [ + 'triggers' => [ + ['key' => self::TRIGGER_SCHEDULE, 'name' => 'زمان‌دار (اسکن ساعتی)'], + ['key' => self::TRIGGER_EVENT, 'name' => 'بعد از رویداد'], + ['key' => self::TRIGGER_PRICE, 'name' => 'حرکت قیمت'], + ], + 'events' => self::EVENTS, + 'channels' => [ + ['key' => self::CHANNEL_SMS, 'name' => 'پیامک'], + ['key' => self::CHANNEL_IN_APP, 'name' => 'اعلان داخل اپ'], + ], + 'operators' => [ + ['key' => 'eq', 'name' => 'مساوی'], + ['key' => 'gt', 'name' => 'بزرگ‌تر'], + ['key' => 'gte', 'name' => 'بزرگ‌تر یا مساوی'], + ['key' => 'lt', 'name' => 'کوچک‌تر'], + ['key' => 'lte', 'name' => 'کوچک‌تر یا مساوی'], + ['key' => 'not', 'name' => 'نامساوی'], + ], + 'fields' => $this->fields(), + 'products' => Product::query() + ->orderBy('id') + ->get(['id', 'title']) + ->map(fn ($p) => ['id' => (int) $p->id, 'name' => (string) ($p->title ?: ('#'.$p->id))]) + ->values() + ->all(), + 'hours' => collect(range(0, 23))->map(fn ($h) => [ + 'key' => $h, + 'name' => str_pad((string) $h, 2, '0', STR_PAD_LEFT).':00', + ])->all(), + ]; + } + + /** @return list> */ + public function fields(): array + { + return [ + [ + 'key' => 'never_login', + 'group' => 'حضور', + 'name' => 'هرگز وارد اپ نشده', + 'inputs' => [], + ], + [ + 'key' => 'last_seen_older_days', + 'group' => 'حضور', + 'name' => 'آخرین بازدید قدیمی‌تر از N روز', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'last_seen_within_days', + 'group' => 'حضور', + 'name' => 'آخرین بازدید در N روز اخیر', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'is_legal', + 'group' => 'مشتری', + 'name' => 'وضعیت احراز', + 'inputs' => [ + ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [ + ['key' => 0, 'name' => 'احراز نشده'], + ['key' => 1, 'name' => 'احراز خودکار'], + ['key' => 2, 'name' => 'تأیید ادمین'], + ]], + ], + ], + [ + 'key' => 'active', + 'group' => 'مشتری', + 'name' => 'حساب فعال', + 'inputs' => [ + ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [ + ['key' => 1, 'name' => 'فعال'], + ['key' => 0, 'name' => 'غیرفعال'], + ]], + ], + ], + [ + 'key' => 'registered_older_days', + 'group' => 'مشتری', + 'name' => 'ثبت‌نام قدیمی‌تر از N روز', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'registered_within_days', + 'group' => 'مشتری', + 'name' => 'ثبت‌نام در N روز اخیر', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'orders_count', + 'group' => 'مشتری', + 'name' => 'تعداد سفارش', + 'inputs' => [ + ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], + ['key' => 'value', 'type' => 'number', 'label' => 'عدد'], + ], + ], + [ + 'key' => 'wallet_cash', + 'group' => 'کیف', + 'name' => 'موجودی ریال کیف اصلی', + 'inputs' => [ + ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], + ['key' => 'value', 'type' => 'number', 'label' => 'ریال'], + ], + ], + [ + 'key' => 'wallet_gold', + 'group' => 'کیف', + 'name' => 'موجودی طلا کیف اصلی', + 'inputs' => [ + ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], + ['key' => 'value', 'type' => 'number', 'label' => 'گرم'], + ], + ], + [ + 'key' => 'never_ordered', + 'group' => 'معامله', + 'name' => 'هرگز معامله موفق نداشته', + 'inputs' => [], + ], + [ + 'key' => 'has_buy_within_days', + 'group' => 'معامله', + 'name' => 'خرید موفق در N روز اخیر', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'has_sell_within_days', + 'group' => 'معامله', + 'name' => 'فروش موفق در N روز اخیر', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'last_order_older_days', + 'group' => 'معامله', + 'name' => 'آخرین معامله موفق قدیمی‌تر از N روز', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'has_deposit_within_days', + 'group' => 'واریز', + 'name' => 'شارژ ریالی در N روز اخیر', + 'inputs' => [ + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], + ], + ], + [ + 'key' => 'has_withdraw_pending', + 'group' => 'برداشت', + 'name' => 'برداشت در انتظار دارد', + 'inputs' => [], + ], + ]; + } + + public function fieldKeys(): array + { + return array_column($this->fields(), 'key'); + } +} diff --git a/app/Services/Automation/AutomationConditionQuery.php b/app/Services/Automation/AutomationConditionQuery.php new file mode 100644 index 0000000..fc45665 --- /dev/null +++ b/app/Services/Automation/AutomationConditionQuery.php @@ -0,0 +1,166 @@ +where('type', 2) + ->where('is_archive', false); + } + + public function apply(Builder $query, array $conditions): Builder + { + $rows = array_values(array_filter($conditions, fn ($r) => is_array($r) && ! empty($r['field']))); + if ($rows === []) { + return $query; + } + + return $query->where(function (Builder $outer) use ($rows) { + foreach ($rows as $i => $row) { + $or = $i > 0 && (($row['cr'] ?? 'and') === 'or'); + $this->applyOne($outer, $row, $or); + } + }); + } + + public function matchesCustomer(Customer $customer, array $conditions): bool + { + return $this->apply($this->baseQuery()->where('id', $customer->id), $conditions)->exists(); + } + + private function applyOne(Builder $q, array $row, bool $or): void + { + $field = (string) $row['field']; + $op = (string) ($row['op'] ?? 'eq'); + $value = $row['value'] ?? null; + if (is_string($value)) { + $value = str_replace(',', '', $value); + } + $days = max(0, (int) $value); + + match ($field) { + 'never_login' => $this->nullCol($q, 'last_seen_at', $or), + 'last_seen_older_days' => $this->where($q, $or, function (Builder $inner) use ($days) { + $cutoff = now()->subDays($days); + $inner->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $cutoff); + }), + 'last_seen_within_days' => $this->cmp($q, 'last_seen_at', 'gte', now()->subDays($days), $or), + 'is_legal' => $this->cmp($q, 'is_legal', 'eq', (int) $value, $or), + 'active' => $this->cmp($q, 'active', 'eq', (int) $value, $or), + 'registered_older_days' => $this->cmp($q, 'created_at', 'lt', now()->subDays($days), $or), + 'registered_within_days' => $this->cmp($q, 'created_at', 'gte', now()->subDays($days), $or), + 'orders_count' => $this->cmp($q, 'orders_count', $op, (int) $value, $or), + 'wallet_cash' => $this->wallet($q, 'cash', $op, (float) $value, $or), + 'wallet_gold' => $this->wallet($q, 'gold', $op, (float) $value, $or), + 'never_ordered' => $this->orderExists($q, null, null, $or, true), + 'has_buy_within_days' => $this->orderExists($q, 1, $days, $or, false), + 'has_sell_within_days' => $this->orderExists($q, 2, $days, $or, false), + 'last_order_older_days' => $this->lastOrderOlder($q, $days, $or), + 'has_deposit_within_days' => $this->depositExists($q, $days, $or), + 'has_withdraw_pending' => $this->withdrawPending($q, $or), + default => null, + }; + } + + private function where(Builder $q, bool $or, callable $fn): void + { + $method = $or ? 'orWhere' : 'where'; + $q->{$method}($fn); + } + + private function nullCol(Builder $q, string $col, bool $or): void + { + $method = $or ? 'orWhereNull' : 'whereNull'; + $q->{$method}($col); + } + + private function cmp(Builder $q, string $col, string $op, mixed $value, bool $or): void + { + $sqlOp = match ($op) { + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'not' => '<>', + default => '=', + }; + $method = $or ? 'orWhere' : 'where'; + $q->{$method}($col, $sqlOp, $value); + } + + private function wallet(Builder $q, string $col, string $op, float $value, bool $or): void + { + $sqlOp = match ($op) { + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'not' => '<>', + default => '=', + }; + $expr = "(select w.{$col} from wallets w where w.owner = customers.id and w.fund_id is null and w.product_id is null order by w.id asc limit 1)"; + $method = $or ? 'orWhereRaw' : 'whereRaw'; + $q->{$method}("coalesce({$expr}, 0) {$sqlOp} ?", [$value]); + } + + private function orderExists(Builder $q, ?int $direction, ?int $days, bool $or, bool $notExists): void + { + $method = $or + ? ($notExists ? 'orWhereNotExists' : 'orWhereExists') + : ($notExists ? 'whereNotExists' : 'whereExists'); + $q->{$method}(function ($sub) use ($direction, $days) { + $sub->from('orders') + ->whereColumn('orders.customer_id', 'customers.id') + ->where('orders.sts', 2); + if ($direction) { + $sub->where('orders.direction', $direction); + } + if ($days !== null) { + $sub->where('orders.created_at', '>=', now()->subDays($days)); + } + }); + } + + private function lastOrderOlder(Builder $q, int $days, bool $or): void + { + $cutoff = now()->subDays($days); + $this->where($q, $or, function (Builder $inner) use ($cutoff) { + $inner->whereNotExists(function ($sub) { + $sub->from('orders') + ->whereColumn('orders.customer_id', 'customers.id') + ->where('orders.sts', 2); + })->orWhereRaw( + '(select max(orders.created_at) from orders where orders.customer_id = customers.id and orders.sts = 2) < ?', + [$cutoff] + ); + }); + } + + private function depositExists(Builder $q, int $days, bool $or): void + { + $method = $or ? 'orWhereExists' : 'whereExists'; + $q->{$method}(function ($sub) use ($days) { + $sub->from('withdraws') + ->whereColumn('withdraws.customer_id', 'customers.id') + ->where('withdraws.cash', '>', 0) + ->whereIn('withdraws.sts', [2, 4]) + ->where('withdraws.created_at', '>=', now()->subDays($days)); + }); + } + + private function withdrawPending(Builder $q, bool $or): void + { + $method = $or ? 'orWhereExists' : 'whereExists'; + $q->{$method}(function ($sub) { + $sub->from('withdraws') + ->whereColumn('withdraws.customer_id', 'customers.id') + ->where('withdraws.sts', 1); + }); + } +} diff --git a/app/Services/Automation/AutomationEngine.php b/app/Services/Automation/AutomationEngine.php new file mode 100644 index 0000000..0a34713 --- /dev/null +++ b/app/Services/Automation/AutomationEngine.php @@ -0,0 +1,327 @@ +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; + } +} diff --git a/app/Services/Automation/AutomationEvents.php b/app/Services/Automation/AutomationEvents.php new file mode 100644 index 0000000..5d80f2e --- /dev/null +++ b/app/Services/Automation/AutomationEvents.php @@ -0,0 +1,24 @@ +onCustomerEvent($event, $customerId); + } catch (\Throwable $e) { + Log::warning('Automation event failed', [ + 'event' => $event, + 'customer_id' => $customerId, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Automation/AutomationFingerprint.php b/app/Services/Automation/AutomationFingerprint.php new file mode 100644 index 0000000..7324fdc --- /dev/null +++ b/app/Services/Automation/AutomationFingerprint.php @@ -0,0 +1,38 @@ + (string) ($triggerConfig['event'] ?? ''), + 'delay_hours' => (int) ($triggerConfig['delay_hours'] ?? 0), + 'step_toman' => (float) ($triggerConfig['step_toman'] ?? 0), + 'direction' => (string) ($triggerConfig['direction'] ?? ''), + 'product_id' => (int) ($triggerConfig['product_id'] ?? 0), + ]; + + $norm = []; + foreach ($conditions as $row) { + if (! is_array($row) || empty($row['field'])) { + continue; + } + $norm[] = [ + 'field' => (string) $row['field'], + 'op' => (string) ($row['op'] ?? ''), + 'value' => $row['value'] ?? null, + ]; + } + usort($norm, fn ($a, $b) => strcmp($a['field'].$a['op'], $b['field'].$b['op'])); + + $payload = [ + 't' => $triggerType, + 'cfg' => $cfg, + 'c' => $norm, + ]; + + return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/app/Services/Automation/AutomationSender.php b/app/Services/Automation/AutomationSender.php new file mode 100644 index 0000000..f04bb9e --- /dev/null +++ b/app/Services/Automation/AutomationSender.php @@ -0,0 +1,129 @@ +maySend($auto, $customer, $channel)) { + return false; + } + + $title = $this->render((string) $auto->title, $customer); + $body = $this->render((string) $auto->body, $customer); + + try { + if ($channel === AutomationCatalog::CHANNEL_SMS) { + $mobile = trim((string) $customer->mobile); + if ($mobile === '') { + throw new \RuntimeException('موبایل خالی است'); + } + $text = trim($title."\n".$body); + $result = $this->sms->sendOrThrow($mobile, $text, 'automation'); + if (! $result->sent) { + throw new \RuntimeException($result->errorMessage ?: 'ارسال پیامک ناموفق'); + } + Message::create([ + 'sms' => $text, + 'receiver' => $mobile, + 'platforms' => ($result->provider ?: 'sms').':'.$mobile, + ]); + } else { + $row = $this->notify->notifyCustomer((int) $customer->id, $title, $body, 'info'); + if (! $row) { + throw new \RuntimeException('اعلان ساخته نشد'); + } + } + + $this->log($auto, $customer, $channel, 'sent'); + $auto->increment('sent_count'); + + return true; + } catch (SmsBlockedException $e) { + $this->log($auto, $customer, $channel, 'failed', 'اعتبار پیامک کافی نیست یا مسدود است'); + $auto->increment('failed_count'); + Log::warning('Automation SMS blocked', ['automation_id' => $auto->id]); + + return false; + } catch (\Throwable $e) { + $this->log($auto, $customer, $channel, 'failed', $e->getMessage()); + $auto->increment('failed_count'); + + return false; + } + } + + public function maySend(Automation $auto, Customer $customer, string $channel): bool + { + if (! $auto->usesChannel($channel)) { + return false; + } + + $max = $auto->max_sends; + if ($max !== null && (int) $max > 0) { + $count = AutomationSend::query() + ->where('automation_id', $auto->id) + ->where('customer_id', $customer->id) + ->where('channel', $channel) + ->where('status', 'sent') + ->count(); + if ($count >= (int) $max) { + return false; + } + } + + $cooldown = max(0, (int) $auto->cooldown_hours); + if ($cooldown > 0) { + $recent = AutomationSend::query() + ->where('automation_id', $auto->id) + ->where('customer_id', $customer->id) + ->where('channel', $channel) + ->where('status', 'sent') + ->where('created_at', '>=', now()->subHours($cooldown)) + ->exists(); + if ($recent) { + return false; + } + } + + return true; + } + + private function render(string $text, Customer $customer): string + { + $name = trim((string) ($customer->name ?: trim(($customer->fname ?? '').' '.($customer->lname ?? '')))); + + return strtr($text, [ + '{{name}}' => $name, + '{{fname}}' => (string) ($customer->fname ?? ''), + '{{mobile}}' => (string) ($customer->mobile ?? ''), + ]); + } + + private function log(Automation $auto, Customer $customer, string $channel, string $status, ?string $error = null): void + { + AutomationSend::create([ + 'automation_id' => $auto->id, + 'customer_id' => $customer->id, + 'channel' => $channel, + 'status' => $status, + 'error' => $error, + ]); + } +} diff --git a/database/migrations/2026_08_23_000001_create_automations_tables.php b/database/migrations/2026_08_23_000001_create_automations_tables.php new file mode 100644 index 0000000..ac7e05c --- /dev/null +++ b/database/migrations/2026_08_23_000001_create_automations_tables.php @@ -0,0 +1,73 @@ +id(); + $table->string('name', 191); + $table->string('code', 64)->unique(); + $table->boolean('is_active')->default(true); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->unsignedTinyInteger('sms_send_hour')->nullable(); + $table->unsignedTinyInteger('in_app_send_hour')->nullable(); + $table->string('trigger_type', 16); // schedule|event|price + $table->json('trigger_config')->nullable(); + $table->decimal('last_anchor_price', 18, 2)->nullable(); + $table->json('conditions')->nullable(); + $table->json('channels'); // ["sms","in_app"] + $table->string('title', 191); + $table->text('body'); + $table->unsignedInteger('cooldown_hours')->default(24); + $table->unsignedInteger('max_sends')->nullable(); + $table->string('logic_fingerprint', 64)->index(); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->timestamp('last_sms_run_at')->nullable(); + $table->timestamp('last_in_app_run_at')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + if (! Schema::hasTable('automation_sends')) { + Schema::create('automation_sends', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('automation_id')->index(); + $table->unsignedBigInteger('customer_id')->index(); + $table->string('channel', 16); + $table->string('status', 16); // sent|failed + $table->text('error')->nullable(); + $table->timestamps(); + $table->index(['automation_id', 'customer_id', 'channel']); + }); + } + + if (! Schema::hasTable('automation_delayed_jobs')) { + Schema::create('automation_delayed_jobs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('automation_id')->index(); + $table->unsignedBigInteger('customer_id')->index(); + $table->string('channel', 16); + $table->timestamp('run_at')->index(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('automation_delayed_jobs'); + Schema::dropIfExists('automation_sends'); + Schema::dropIfExists('automations'); + } +}; diff --git a/routes/admin.php b/routes/admin.php index fb556c1..e3e455e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -13,6 +13,7 @@ use \App\Http\Controllers\Admin\DashboardController; use \App\Http\Controllers\Single\SingleOrderController; use App\Http\Controllers\Admin\PriceTriggerController as AdminPriceTriggerController; use App\Http\Controllers\Admin\AppNotificationController as AdminAppNotificationController; +use App\Http\Controllers\Admin\AutomationController as AdminAutomationController; use App\Http\Controllers\Finance\FinanceController; use App\Http\Controllers\Admin\ProductController; use App\Http\Controllers\Admin\TahesabController; @@ -61,6 +62,15 @@ Route::middleware('auth:user')->group(function (){ Route::get('app_notifications/{id}/readers', [AdminAppNotificationController::class, 'readers']); Route::get('app_notifications/{id}/recipients', [AdminAppNotificationController::class, 'recipients']); Route::delete('app_notifications/{id}', [AdminAppNotificationController::class, 'destroy']); + + //------------Automations + Route::post('automations/table', [AdminAutomationController::class, 'table']); + Route::get('automations/catalog', [AdminAutomationController::class, 'catalog']); + Route::post('automations/preview', [AdminAutomationController::class, 'preview']); + Route::post('automations', [AdminAutomationController::class, 'store']); + Route::put('automations/{id}', [AdminAutomationController::class, 'update']); + Route::put('automations/{id}/toggle', [AdminAutomationController::class, 'toggle']); + Route::delete('automations/{id}', [AdminAutomationController::class, 'destroy']); //------------ConditionalOrder Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']); Route::post('conditional_orders', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'store']);