Files
boursecu_server/app/Services/Automation/AutomationEngine.php
T
2026-08-23 17:13:30 +03:30

411 lines
13 KiB
PHP

<?php
namespace App\Services\Automation;
use App\Models\Automation;
use App\Models\AutomationDelayedJob;
use App\Models\Customer;
use App\Models\Product;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class AutomationEngine
{
public function __construct(
private readonly AutomationConditionQuery $conditions,
private readonly AutomationSender $sender,
) {
}
public function runDue(): void
{
$this->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;
}
$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;
}
}