Files
boursecu_server/app/Services/Automation/AutomationSender.php
T
mohmdsdgandCursor 4562d00bd3 fix(automations): log skipped sends with cooldown/max reason
Schedule runs that hit cooldown or max-send limits now write a skipped row to automation_sends so admins can see why no SMS was sent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:31:27 +03:30

197 lines
6.9 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Automation;
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;
use Illuminate\Support\Facades\Log;
class AutomationSender
{
public function __construct(
private readonly SmsGatewayService $sms,
private readonly CustomerAppNotifyService $notify,
private readonly ConfigService $config,
) {
}
public function send(Automation $auto, Customer $customer, string $channel, bool $force = false): bool
{
$skipReason = $this->skipReason($auto, $customer, $channel, $force);
if ($skipReason !== null) {
$this->log($auto, $customer, $channel, 'skipped', $skipReason);
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 = $this->ensureAppNameFooter(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 $force = false): bool
{
return $this->skipReason($auto, $customer, $channel, $force) === null;
}
private function skipReason(Automation $auto, Customer $customer, string $channel, bool $force = false): ?string
{
if (! $auto->usesChannel($channel)) {
return 'کانال غیرفعال';
}
if ($force) {
return null;
}
$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 'حداکثر ارسال برای این مشتری';
}
}
$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 "فاصلهٔ حداقل {$cooldown} ساعت از آخرین ارسال";
}
}
return null;
}
private function ensureAppNameFooter(string $message): string
{
$appName = $this->resolveAppDisplayName();
$trimmed = rtrim($message);
if ($appName === '') {
return $trimmed;
}
if ($this->messageContainsAppName($trimmed, $appName)) {
return $trimmed;
}
return $trimmed === '' ? $appName : ($trimmed."\n".$appName);
}
private function resolveAppDisplayName(): string
{
foreach (['name_fa', 'name', 'name_en'] as $key) {
$value = trim($this->config->getString($key));
if ($value !== '') {
return $value;
}
}
return '';
}
private function messageContainsAppName(string $message, string $appName): bool
{
$lines = preg_split('/\R/u', $message) ?: [];
foreach ($lines as $line) {
if (trim((string) $line) === $appName) {
return true;
}
}
return false;
}
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 ?? '');
$appName = $this->resolveAppDisplayName();
// سازگاری با قالب قدیمی {{name}}
$out = strtr($text, [
'{{name}}' => $name,
'{{fname}}' => $fname,
'{{mobile}}' => $mobile,
'{{app_name}}' => $appName,
]);
// الگوی پیامک/تنظیمات: %نام ، ٪نام ، % name ، %name%
// نام_کوچک قبل از نام — وگرنه %نام داخل %نام_کوچک زودتر match می‌شود
$out = preg_replace('/[%٪]\s*(?:fname|نام_کوچک|نام‌کوچک)[%٪]?/iu', $fname, $out) ?? $out;
$out = preg_replace('/[%٪]\s*(?:app_name|نام_اپ|نام‌اپ)[%٪]?/iu', $appName, $out) ?? $out;
$out = preg_replace('/[%٪]\s*(?:name|نام)(?![_‌]?کوچک)(?![_‌]?اپ)[%٪]?/iu', $name, $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
{
AutomationSend::create([
'automation_id' => $auto->id,
'customer_id' => $customer->id,
'channel' => $channel,
'status' => $status,
'error' => $error,
]);
}
}