Install per-module schedule:run cron from artisan, store sms/in_app send minutes, and match scheduled triggers at exact HH:mm Tehran time. Co-authored-by: Cursor <cursoragent@cursor.com>
479 lines
16 KiB
PHP
479 lines
16 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
|
|
{
|
|
[$hour, $minute] = $this->scheduledTime($auto, $channel);
|
|
if ($hour === null) {
|
|
return true;
|
|
}
|
|
|
|
$now = now('Asia/Tehran');
|
|
|
|
return (int) $now->hour === (int) $hour
|
|
&& (int) $now->minute === (int) ($minute ?? 0);
|
|
}
|
|
|
|
/** @return array{0: int|null, 1: int|null} */
|
|
private function scheduledTime(Automation $auto, string $channel): array
|
|
{
|
|
if ($channel === AutomationCatalog::CHANNEL_SMS) {
|
|
return [$auto->sms_send_hour, $auto->sms_send_minute];
|
|
}
|
|
|
|
return [$auto->in_app_send_hour, $auto->in_app_send_minute];
|
|
}
|
|
|
|
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
|
|
{
|
|
[$hour, $minute] = $this->scheduledTime($auto, $channel);
|
|
if ($hour === null) {
|
|
return $after;
|
|
}
|
|
$local = $after->copy()->timezone('Asia/Tehran');
|
|
$targetMinute = (int) ($minute ?? 0);
|
|
if ((int) $local->hour === (int) $hour && (int) $local->minute === $targetMinute) {
|
|
return $after;
|
|
}
|
|
$target = $local->copy()->setTime((int) $hour, $targetMinute, 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;
|
|
}
|
|
}
|