Files
boursecu_server/app/Services/Automation/AutomationEngine.php
T
mohmdsdgandCursor 995f6984ed feat(automations): add run-now test endpoint and sends log table
Enable immediate automation test runs without waiting for the scheduled hour, expose send history for debugging, and return all SuperAdmin overlay keys on refresh instead of only enable_automation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:46:13 +03:30

469 lines
15 KiB
PHP

<?php
namespace App\Services\Automation;
use App\Models\Automation;
use App\Models\AutomationDelayedJob;
use App\Models\Customer;
use App\Models\Product;
use App\Services\ConfigService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class AutomationEngine
{
public function __construct(
private readonly AutomationConditionQuery $conditions,
private readonly AutomationSender $sender,
) {
}
public function runDue(): void
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$this->runSchedule();
$this->processDelayedJobs();
}
public function onCustomerEvent(string $event, int $customerId): void
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$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
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$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();
}
/**
* اجرای فوری برای تست ادمین — بدون محدودیت ساعت، بدون touch کردن last_sms_run_at.
*
* @return array{audience: int, sent: int, failed: int, skipped: int}
*/
public function runNow(Automation $auto, bool $force = true): array
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.');
}
if (! $auto->is_active) {
throw new UnprocessableEntityHttpException('اتوماسیون غیرفعال است.');
}
if (! $this->inWindow($auto)) {
throw new UnprocessableEntityHttpException('اتوماسیون خارج از بازهٔ شروع/پایان است.');
}
$stats = ['audience' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0];
$audienceQuery = $this->conditions->apply(
$this->conditions->baseQuery(),
$auto->conditions ?? []
);
$stats['audience'] = (int) $audienceQuery->count();
foreach ($this->channels($auto) as $channel) {
$this->conditions
->apply($this->conditions->baseQuery(), $auto->conditions ?? [])
->select(['id', 'name', 'fname', 'lname', 'mobile', 'type', 'is_archive'])
->chunkById(80, function ($customers) use ($auto, $channel, $force, &$stats) {
foreach ($customers as $customer) {
if (! $force && ! $this->sender->maySend($auto, $customer, $channel)) {
$stats['skipped']++;
continue;
}
if ($this->sender->send($auto, $customer, $channel, $force)) {
$stats['sent']++;
} else {
$stats['failed']++;
}
}
});
}
return $stats;
}
private function tryPriceFire(Automation $auto, array $payload): void
{
if (! $this->inWindow($auto)) {
return;
}
$current = $this->currentPrice($auto, $payload);
if ($current <= 0) {
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]);
$auto->last_anchor_price = $current;
return;
}
$delta = $current - (float) $anchor;
if (! $this->priceDirectionMet($dir, $delta, $step)) {
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 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()
->where('is_active', true)
->where('trigger_type', AutomationCatalog::TRIGGER_SCHEDULE)
->get();
foreach ($rows as $auto) {
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->scheduleDue($auto, $channel, $everyDays)) {
continue;
}
$this->sendAudienceNow($auto, $channel);
$this->markHourRun($auto, $channel);
}
}
}
/** آیا از آخرین اجرای موفق این کانال، حداقل 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()
->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->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<string> */
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;
}
}