diff --git a/app/Http/Controllers/Admin/AutomationController.php b/app/Http/Controllers/Admin/AutomationController.php index 60422f2..5590a86 100644 --- a/app/Http/Controllers/Admin/AutomationController.php +++ b/app/Http/Controllers/Admin/AutomationController.php @@ -108,17 +108,35 @@ class AutomationController extends Controller $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('ساعت ارسال اعلان را مشخص کنید.'); + $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) { @@ -128,8 +146,12 @@ class AutomationController extends Controller } $triggerConfig = (array) $request->input('trigger_config', []); + $cooldownHours = max(0, (int) $request->input('cooldown_hours', 24)); if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) { - $triggerConfig = []; + $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)) { @@ -148,11 +170,20 @@ class AutomationController extends Controller 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); @@ -172,15 +203,19 @@ class AutomationController extends Controller '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, + 'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) + ? ($smsHour === null ? null : (int) $smsHour) + : null, + 'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) + ? ($inAppHour === null ? null : (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)), + 'cooldown_hours' => $cooldownHours, 'max_sends' => $request->filled('max_sends') ? max(1, (int) $request->input('max_sends')) : null, 'logic_fingerprint' => $fingerprint, ]; diff --git a/app/Services/Automation/AutomationCatalog.php b/app/Services/Automation/AutomationCatalog.php index 95204ed..6707fef 100644 --- a/app/Services/Automation/AutomationCatalog.php +++ b/app/Services/Automation/AutomationCatalog.php @@ -17,7 +17,7 @@ class AutomationCatalog public const CHANNEL_IN_APP = 'in_app'; public const EVENTS = [ - ['key' => 'customer.login', 'name' => 'ورود مشتری'], + ['key' => 'customer.login', 'name' => 'ورود کاربر'], ['key' => 'order.buy_success', 'name' => 'خرید موفق'], ['key' => 'order.sell_success', 'name' => 'فروش موفق'], ['key' => 'wallet.rial_charge', 'name' => 'شارژ ریالی کیف'], @@ -29,10 +29,18 @@ class AutomationCatalog { return [ 'triggers' => [ - ['key' => self::TRIGGER_SCHEDULE, 'name' => 'زمان‌دار (اسکن ساعتی)'], - ['key' => self::TRIGGER_EVENT, 'name' => 'بعد از رویداد'], + ['key' => self::TRIGGER_SCHEDULE, 'name' => 'زمان‌دار (هر N روز)'], + ['key' => self::TRIGGER_EVENT, 'name' => 'رویداد (سر ضرب / تأخیر)'], ['key' => self::TRIGGER_PRICE, 'name' => 'حرکت قیمت'], ], + 'price_windows' => [ + ['key' => 'calendar_day', 'name' => 'در یک روز (از شروع امروز)'], + ['key' => 'from_last', 'name' => 'از آخرین ارسال (پله‌ای)'], + ], + 'audience_modes' => [ + ['key' => 'all', 'name' => 'همه کاربران خانگی'], + ['key' => 'filter', 'name' => 'با فیلتر شرط'], + ], 'events' => self::EVENTS, 'channels' => [ ['key' => self::CHANNEL_SMS, 'name' => 'پیامک'], @@ -66,13 +74,13 @@ class AutomationCatalog return [ [ 'key' => 'never_login', - 'group' => 'حضور', + 'group' => 'ورود و بازدید', 'name' => 'هرگز وارد اپ نشده', 'inputs' => [], ], [ 'key' => 'last_seen_older_days', - 'group' => 'حضور', + 'group' => 'ورود و بازدید', 'name' => 'آخرین بازدید قدیمی‌تر از N روز', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -80,7 +88,7 @@ class AutomationCatalog ], [ 'key' => 'last_seen_within_days', - 'group' => 'حضور', + 'group' => 'ورود و بازدید', 'name' => 'آخرین بازدید در N روز اخیر', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -88,10 +96,10 @@ class AutomationCatalog ], [ 'key' => 'is_legal', - 'group' => 'مشتری', - 'name' => 'وضعیت احراز', + 'group' => 'حساب کاربر', + 'name' => 'وضعیت احراز هویت', 'inputs' => [ - ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [ + ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت احراز', 'options' => [ ['key' => 0, 'name' => 'احراز نشده'], ['key' => 1, 'name' => 'احراز خودکار'], ['key' => 2, 'name' => 'تأیید ادمین'], @@ -100,10 +108,10 @@ class AutomationCatalog ], [ 'key' => 'active', - 'group' => 'مشتری', - 'name' => 'حساب فعال', + 'group' => 'حساب کاربر', + 'name' => 'حساب فعال / غیرفعال', 'inputs' => [ - ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [ + ['key' => 'value', 'type' => 'select', 'label' => 'وضعیت حساب', 'options' => [ ['key' => 1, 'name' => 'فعال'], ['key' => 0, 'name' => 'غیرفعال'], ]], @@ -111,7 +119,7 @@ class AutomationCatalog ], [ 'key' => 'registered_older_days', - 'group' => 'مشتری', + 'group' => 'حساب کاربر', 'name' => 'ثبت‌نام قدیمی‌تر از N روز', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -119,7 +127,7 @@ class AutomationCatalog ], [ 'key' => 'registered_within_days', - 'group' => 'مشتری', + 'group' => 'حساب کاربر', 'name' => 'ثبت‌نام در N روز اخیر', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -127,40 +135,40 @@ class AutomationCatalog ], [ 'key' => 'orders_count', - 'group' => 'مشتری', + 'group' => 'حساب کاربر', 'name' => 'تعداد سفارش', 'inputs' => [ - ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], - ['key' => 'value', 'type' => 'number', 'label' => 'عدد'], + ['key' => 'op', 'type' => 'operator', 'label' => 'مقایسه'], + ['key' => 'value', 'type' => 'number', 'label' => 'تعداد'], ], ], [ 'key' => 'wallet_cash', - 'group' => 'کیف', + 'group' => 'کیف پول', 'name' => 'موجودی ریال کیف اصلی', 'inputs' => [ - ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], - ['key' => 'value', 'type' => 'number', 'label' => 'ریال'], + ['key' => 'op', 'type' => 'operator', 'label' => 'مقایسه'], + ['key' => 'value', 'type' => 'number', 'label' => 'مبلغ (ریال)'], ], ], [ 'key' => 'wallet_gold', - 'group' => 'کیف', + 'group' => 'کیف پول', 'name' => 'موجودی طلا کیف اصلی', 'inputs' => [ - ['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'], - ['key' => 'value', 'type' => 'number', 'label' => 'گرم'], + ['key' => 'op', 'type' => 'operator', 'label' => 'مقایسه'], + ['key' => 'value', 'type' => 'number', 'label' => 'مقدار (گرم)'], ], ], [ 'key' => 'never_ordered', - 'group' => 'معامله', + 'group' => 'خرید و فروش', 'name' => 'هرگز معامله موفق نداشته', 'inputs' => [], ], [ 'key' => 'has_buy_within_days', - 'group' => 'معامله', + 'group' => 'خرید و فروش', 'name' => 'خرید موفق در N روز اخیر', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -168,7 +176,7 @@ class AutomationCatalog ], [ 'key' => 'has_sell_within_days', - 'group' => 'معامله', + 'group' => 'خرید و فروش', 'name' => 'فروش موفق در N روز اخیر', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -176,7 +184,7 @@ class AutomationCatalog ], [ 'key' => 'last_order_older_days', - 'group' => 'معامله', + 'group' => 'خرید و فروش', 'name' => 'آخرین معامله موفق قدیمی‌تر از N روز', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], @@ -184,7 +192,7 @@ class AutomationCatalog ], [ 'key' => 'has_deposit_within_days', - 'group' => 'واریز', + 'group' => 'شارژ کیف', 'name' => 'شارژ ریالی در N روز اخیر', 'inputs' => [ ['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'], diff --git a/app/Services/Automation/AutomationEngine.php b/app/Services/Automation/AutomationEngine.php index 0a34713..ccbd96e 100644 --- a/app/Services/Automation/AutomationEngine.php +++ b/app/Services/Automation/AutomationEngine.php @@ -88,6 +88,74 @@ class AutomationEngine 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]); @@ -96,20 +164,8 @@ class AutomationEngine 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) { + if (! $this->priceDirectionMet($dir, $delta, $step)) { return; } @@ -129,6 +185,15 @@ class AutomationEngine $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() @@ -140,11 +205,12 @@ class AutomationEngine 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->alreadyRanThisHour($auto, $channel)) { + if (! $this->scheduleDue($auto, $channel, $everyDays)) { continue; } $this->sendAudienceNow($auto, $channel); @@ -153,6 +219,22 @@ class AutomationEngine } } + /** آیا از آخرین اجرای موفق این کانال، حداقل 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() @@ -215,7 +297,8 @@ class AutomationEngine if ($exists) { continue; } - if ($delayHours === 0 && $this->hourMatches($auto, $channel) && $this->sender->maySend($auto, $customer, $channel)) { + if ($delayHours === 0 && $this->sender->maySend($auto, $customer, $channel)) { + // سر ضرب: بلافاصله، بدون انتظار ساعت کانال $this->sender->send($auto, $customer, $channel); continue; } diff --git a/app/Services/Automation/AutomationFingerprint.php b/app/Services/Automation/AutomationFingerprint.php index 7324fdc..e5c1e08 100644 --- a/app/Services/Automation/AutomationFingerprint.php +++ b/app/Services/Automation/AutomationFingerprint.php @@ -9,9 +9,11 @@ class AutomationFingerprint $cfg = [ 'event' => (string) ($triggerConfig['event'] ?? ''), 'delay_hours' => (int) ($triggerConfig['delay_hours'] ?? 0), + 'every_days' => (int) ($triggerConfig['every_days'] ?? 1), 'step_toman' => (float) ($triggerConfig['step_toman'] ?? 0), 'direction' => (string) ($triggerConfig['direction'] ?? ''), 'product_id' => (int) ($triggerConfig['product_id'] ?? 0), + 'price_window' => (string) ($triggerConfig['price_window'] ?? 'calendar_day'), ]; $norm = []; diff --git a/app/Services/Automation/AutomationSender.php b/app/Services/Automation/AutomationSender.php index f04bb9e..79048a8 100644 --- a/app/Services/Automation/AutomationSender.php +++ b/app/Services/Automation/AutomationSender.php @@ -6,6 +6,7 @@ use App\Models\Automation; use App\Models\AutomationSend; use App\Models\Customer; use App\Models\Message; +use App\Services\ConfigService; use App\Services\CustomerAppNotifyService; use App\Services\SmsBilling\SmsGatewayService; use App\Exceptions\SmsBlockedException; @@ -16,6 +17,7 @@ class AutomationSender public function __construct( private readonly SmsGatewayService $sms, private readonly CustomerAppNotifyService $notify, + private readonly ConfigService $config, ) { } @@ -34,7 +36,7 @@ class AutomationSender if ($mobile === '') { throw new \RuntimeException('موبایل خالی است'); } - $text = trim($title."\n".$body); + $text = $this->ensureAppNameFooter(trim($title."\n".$body)); $result = $this->sms->sendOrThrow($mobile, $text, 'automation'); if (! $result->sent) { throw new \RuntimeException($result->errorMessage ?: 'ارسال پیامک ناموفق'); @@ -105,15 +107,42 @@ class AutomationSender return true; } + private function ensureAppNameFooter(string $message): string + { + $appName = trim($this->config->getString('name_fa')); + $trimmed = rtrim($message); + if ($appName === '') { + return $trimmed; + } + + $lines = preg_split('/\R/u', $trimmed) ?: []; + $last = trim((string) end($lines)); + if ($last === $appName) { + return $trimmed; + } + + return $trimmed === '' ? $appName : ($trimmed."\n".$appName); + } + private function render(string $text, Customer $customer): string { $name = trim((string) ($customer->name ?: trim(($customer->fname ?? '').' '.($customer->lname ?? '')))); + $fname = (string) ($customer->fname ?? ''); + $mobile = (string) ($customer->mobile ?? ''); - return strtr($text, [ + // سازگاری با قالب قدیمی {{name}} + $out = strtr($text, [ '{{name}}' => $name, - '{{fname}}' => (string) ($customer->fname ?? ''), - '{{mobile}}' => (string) ($customer->mobile ?? ''), + '{{fname}}' => $fname, + '{{mobile}}' => $mobile, ]); + + // الگوی پیامک/تنظیمات: %نام ، ٪نام ، % name ، %name% + $out = preg_replace('/[%٪]\s*(?:name|نام)[%٪]?/iu', $name, $out) ?? $out; + $out = preg_replace('/[%٪]\s*(?:fname|نام_کوچک|نام‌کوچک)[%٪]?/iu', $fname, $out) ?? $out; + $out = preg_replace('/[%٪]\s*(?:mobile|موبایل)[%٪]?/iu', $mobile, $out) ?? $out; + + return $out; } private function log(Automation $auto, Customer $customer, string $channel, string $status, ?string $error = null): void