added automation
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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) {
|
||||
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 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;
|
||||
}
|
||||
foreach ($this->channels($auto) as $channel) {
|
||||
if (! $this->hourMatches($auto, $channel)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->alreadyRanThisHour($auto, $channel)) {
|
||||
continue;
|
||||
}
|
||||
$this->sendAudienceNow($auto, $channel);
|
||||
$this->markHourRun($auto, $channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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->hourMatches($auto, $channel) && $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user