added automation

This commit is contained in:
2026-08-23 14:16:00 +03:30
parent 0c0b424a69
commit 657c10d1cc
19 changed files with 1372 additions and 0 deletions
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\Automation\AutomationEngine;
use Illuminate\Console\Command;
class RunAutomationsCommand extends Command
{
protected $signature = 'automations:run';
protected $description = 'اجرای اتوماسیون‌های زمان‌دار و صف تأخیر';
public function handle(AutomationEngine $engine): int
{
$engine->runDue();
return self::SUCCESS;
}
}
+5
View File
@@ -41,6 +41,11 @@ class Kernel extends ConsoleKernel
->dailyAt('03:40')
->name('pargar_requeue_wallet_docs_all')
->withoutOverlapping(110);
$schedule->command('automations:run')
->everyMinute()
->name('automations_run')
->withoutOverlapping(50);
}
protected function commands()
@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers\Admin;
use App\DB\Te\TableEngine;
use App\Http\Controllers\Controller;
use App\Models\Automation;
use App\Services\Automation\AutomationCatalog;
use App\Services\Automation\AutomationEngine;
use App\Services\Automation\AutomationFingerprint;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class AutomationController extends Controller
{
public function table(Request $request)
{
$query = Automation::query();
TableEngine::create($query, $request->all())
->initSearch(['name', 'code', 'title', 'body'])
->select(['*'])
->export($query);
return $query;
}
public function catalog()
{
return response()->json(app(AutomationCatalog::class)->toArray());
}
public function preview(Request $request)
{
$conditions = array_values((array) $request->input('conditions', []));
$count = app(AutomationEngine::class)->previewCount($conditions);
return response()->json(['count' => $count]);
}
public function store(Request $request)
{
$data = $this->validated($request);
$data['created_by'] = auth()->id();
$row = Automation::create($data);
return response()->json($row);
}
public function update(Request $request, $id)
{
$row = Automation::query()->findOrFail($id);
$data = $this->validated($request, (int) $row->id);
$row->fill($data)->save();
return response()->json($row);
}
public function toggle($id)
{
$row = Automation::query()->findOrFail($id);
if (! $row->is_active) {
$dup = $this->duplicateOf($row->logic_fingerprint, (int) $row->id, true);
if ($dup) {
throw new UnprocessableEntityHttpException(
'اتوماسیون مشابه فعال است: '.$dup->name
);
}
}
$row->is_active = ! $row->is_active;
$row->save();
return response()->json(['is_active' => $row->is_active]);
}
public function destroy($id)
{
Automation::query()->findOrFail($id)->delete();
return response()->json(true);
}
private function validated(Request $request, ?int $ignoreId = null): array
{
$name = trim((string) $request->input('name', ''));
$title = trim((string) $request->input('title', ''));
$body = trim((string) $request->input('body', ''));
if ($name === '' || $title === '' || $body === '') {
throw new UnprocessableEntityHttpException('نام، عنوان و متن الزامی است.');
}
$triggerType = (string) $request->input('trigger_type', AutomationCatalog::TRIGGER_SCHEDULE);
if (! in_array($triggerType, [
AutomationCatalog::TRIGGER_SCHEDULE,
AutomationCatalog::TRIGGER_EVENT,
AutomationCatalog::TRIGGER_PRICE,
], true)) {
throw new UnprocessableEntityHttpException('نوع تریگر نامعتبر است.');
}
$channels = array_values(array_intersect(
(array) $request->input('channels', []),
[AutomationCatalog::CHANNEL_SMS, AutomationCatalog::CHANNEL_IN_APP]
));
if ($channels === []) {
throw new UnprocessableEntityHttpException('حداقل یک کانال انتخاب کنید.');
}
$smsHour = $request->input('sms_send_hour');
$inAppHour = $request->input('in_app_send_hour');
if (in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) && $smsHour === null) {
throw new UnprocessableEntityHttpException('ساعت ارسال پیامک را مشخص کنید.');
}
if (in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) && $inAppHour === null) {
throw new UnprocessableEntityHttpException('ساعت ارسال اعلان را مشخص کنید.');
}
$conditions = array_values(array_filter(
(array) $request->input('conditions', []),
fn ($row) => is_array($row) && filled($row['field'] ?? null)
));
$catalog = app(AutomationCatalog::class);
$allowed = $catalog->fieldKeys();
foreach ($conditions as $row) {
if (! in_array($row['field'] ?? '', $allowed, true)) {
throw new UnprocessableEntityHttpException('شرط نامعتبر است.');
}
}
$triggerConfig = (array) $request->input('trigger_config', []);
if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) {
$triggerConfig = [];
} elseif ($triggerType === AutomationCatalog::TRIGGER_EVENT) {
$eventKeys = array_column(AutomationCatalog::EVENTS, 'key');
if (! in_array((string) ($triggerConfig['event'] ?? ''), $eventKeys, true)) {
throw new UnprocessableEntityHttpException('رویداد را انتخاب کنید.');
}
$triggerConfig = [
'event' => (string) $triggerConfig['event'],
'delay_hours' => max(0, (int) ($triggerConfig['delay_hours'] ?? 0)),
];
} elseif ($triggerType === AutomationCatalog::TRIGGER_PRICE) {
$step = (float) str_replace(',', '', (string) ($triggerConfig['step_toman'] ?? 0));
if ($step <= 0) {
throw new UnprocessableEntityHttpException('رقم تغییر قیمت باید بزرگ‌تر از صفر باشد.');
}
$dir = (string) ($triggerConfig['direction'] ?? 'both');
if (! in_array($dir, ['up', 'down', 'both'], true)) {
$dir = 'both';
}
$triggerConfig = [
'step_toman' => $step,
'direction' => $dir,
'product_id' => (int) ($triggerConfig['product_id'] ?? 0) ?: null,
];
}
$fingerprint = app(AutomationFingerprint::class)->make($triggerType, $triggerConfig, $conditions);
$dup = $this->duplicateOf($fingerprint, $ignoreId, false);
if ($dup) {
throw new UnprocessableEntityHttpException(
'اتوماسیون شبیه وجود دارد: '.$dup->name.' — همان را ویرایش کنید.'
);
}
$starts = $request->input('starts_at') ?: null;
$ends = $request->input('ends_at') ?: null;
return [
'name' => $name,
'code' => $this->uniqueCode($name, $ignoreId),
'is_active' => (bool) $request->input('is_active', true),
'starts_at' => $starts,
'ends_at' => $ends,
'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true) ? (int) $smsHour : null,
'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true) ? (int) $inAppHour : null,
'trigger_type' => $triggerType,
'trigger_config' => $triggerConfig,
'conditions' => $conditions,
'channels' => $channels,
'title' => mb_substr($title, 0, 191),
'body' => $body,
'cooldown_hours' => max(0, (int) $request->input('cooldown_hours', 24)),
'max_sends' => $request->filled('max_sends') ? max(1, (int) $request->input('max_sends')) : null,
'logic_fingerprint' => $fingerprint,
];
}
private function duplicateOf(string $fingerprint, ?int $ignoreId, bool $activeOnly): ?Automation
{
$q = Automation::query()->where('logic_fingerprint', $fingerprint);
if ($ignoreId) {
$q->where('id', '!=', $ignoreId);
}
if ($activeOnly) {
$q->where('is_active', true);
}
return $q->first();
}
private function uniqueCode(string $name, ?int $ignoreId): string
{
$base = Str::slug($name);
if ($base === '') {
$base = 'auto';
}
$code = $base;
$i = 2;
while (
Automation::query()
->where('code', $code)
->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))
->exists()
) {
$code = $base.'-'.$i;
$i++;
}
return mb_substr($code, 0, 64);
}
}
@@ -212,6 +212,7 @@ class WalletController extends Controller
} catch (\Throwable $e) {
report($e);
}
\App\Services\Automation\AutomationEvents::fire('wallet.rial_charge', (int) $customer->id);
return response()->json(true);
}
@@ -324,6 +325,10 @@ class WalletController extends Controller
report($e);
}
if ((float) $data['value'] > 0) {
\App\Services\Automation\AutomationEvents::fire('wallet.rial_charge', (int) $customer->id);
}
$this->broadcastCustomerWalletAssets($customer);
return response()->json(true);
@@ -32,6 +32,9 @@ class AuthController extends Controller
$token = auth($data['provider'])->login($user);
$user->last_seen_at = Carbon::now();
$user->save();
if (($data['provider'] ?? '') === 'customer') {
\App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id);
}
return $this->respondWithToken($token);
}
$credentials = $request->only(['mobile', 'password']);
@@ -69,6 +72,7 @@ class AuthController extends Controller
}
$token = auth($provider)->login($user);
$user->update(['last_seen_at' => Carbon::now()]);
\App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id);
return $this->respondWithToken($token);
}
@@ -97,6 +101,9 @@ class AuthController extends Controller
$user->update([
'last_seen_at' => Carbon::now()
]);
if ($provider === 'customer') {
\App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id);
}
return $this->respondWithToken($token);
}
@@ -151,6 +158,9 @@ class AuthController extends Controller
$user->last_seen_at = Carbon::now();
$user->token_sms = null;
$user->save();
if (($data['provider'] ?? '') === 'customer') {
\App\Services\Automation\AutomationEvents::fire('customer.login', (int) $user->id);
}
return $this->respondWithToken($token);
}
throw new UnprocessableEntityHttpException('کد وارد شده اشتباه است');
@@ -472,6 +472,7 @@ class WalletController extends Controller
} catch (\Throwable $e) {
report($e);
}
\App\Services\Automation\AutomationEvents::fire('withdraw.request', (int) $customer->id);
return response()->json($Withdraw);
}
@@ -955,6 +955,8 @@ class SingleOrderController extends Controller
} catch (\Throwable $e) {
report($e);
}
$event = (int) ($data['direction'] ?? 0) === 2 ? 'order.sell_success' : 'order.buy_success';
\App\Services\Automation\AutomationEvents::fire($event, (int) $customer->id);
}
private function assertProviderOrderAccepted(array &$data): void
@@ -47,6 +47,11 @@ class ReportController extends Controller
public function updatePrise()
{
app(\App\Services\PriceTriggerService::class)->evaluateActive();
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove();
} catch (\Throwable $e) {
report($e);
}
return response()->json();
}
@@ -126,10 +131,20 @@ class ReportController extends Controller
if (app()->runningInConsole()) {
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload);
} catch (\Throwable $e) {
report($e);
}
} else {
dispatch(function () use ($pricePayload) {
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload);
} catch (\Throwable $e) {
report($e);
}
})->afterResponse();
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Automation extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'code',
'is_active',
'starts_at',
'ends_at',
'sms_send_hour',
'in_app_send_hour',
'trigger_type',
'trigger_config',
'last_anchor_price',
'conditions',
'channels',
'title',
'body',
'cooldown_hours',
'max_sends',
'logic_fingerprint',
'sent_count',
'failed_count',
'last_sms_run_at',
'last_in_app_run_at',
'created_by',
];
protected $casts = [
'is_active' => 'boolean',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'trigger_config' => 'array',
'conditions' => 'array',
'channels' => 'array',
'last_anchor_price' => 'float',
'sms_send_hour' => 'integer',
'in_app_send_hour' => 'integer',
'cooldown_hours' => 'integer',
'max_sends' => 'integer',
'sent_count' => 'integer',
'failed_count' => 'integer',
'last_sms_run_at' => 'datetime',
'last_in_app_run_at' => 'datetime',
];
public function sends(): HasMany
{
return $this->hasMany(AutomationSend::class);
}
public function delayedJobs(): HasMany
{
return $this->hasMany(AutomationDelayedJob::class);
}
public function usesChannel(string $channel): bool
{
return in_array($channel, $this->channels ?? [], true);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutomationDelayedJob extends Model
{
protected $fillable = [
'automation_id',
'customer_id',
'channel',
'run_at',
'processed_at',
];
protected $casts = [
'run_at' => 'datetime',
'processed_at' => 'datetime',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutomationSend extends Model
{
protected $fillable = [
'automation_id',
'customer_id',
'channel',
'status',
'error',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Automation;
use App\Models\Product;
class AutomationCatalog
{
public const TRIGGER_SCHEDULE = 'schedule';
public const TRIGGER_EVENT = 'event';
public const TRIGGER_PRICE = 'price';
public const CHANNEL_SMS = 'sms';
public const CHANNEL_IN_APP = 'in_app';
public const EVENTS = [
['key' => 'customer.login', 'name' => 'ورود مشتری'],
['key' => 'order.buy_success', 'name' => 'خرید موفق'],
['key' => 'order.sell_success', 'name' => 'فروش موفق'],
['key' => 'wallet.rial_charge', 'name' => 'شارژ ریالی کیف'],
['key' => 'withdraw.request', 'name' => 'درخواست برداشت ریالی'],
];
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'triggers' => [
['key' => self::TRIGGER_SCHEDULE, 'name' => 'زمان‌دار (اسکن ساعتی)'],
['key' => self::TRIGGER_EVENT, 'name' => 'بعد از رویداد'],
['key' => self::TRIGGER_PRICE, 'name' => 'حرکت قیمت'],
],
'events' => self::EVENTS,
'channels' => [
['key' => self::CHANNEL_SMS, 'name' => 'پیامک'],
['key' => self::CHANNEL_IN_APP, 'name' => 'اعلان داخل اپ'],
],
'operators' => [
['key' => 'eq', 'name' => 'مساوی'],
['key' => 'gt', 'name' => 'بزرگ‌تر'],
['key' => 'gte', 'name' => 'بزرگ‌تر یا مساوی'],
['key' => 'lt', 'name' => 'کوچک‌تر'],
['key' => 'lte', 'name' => 'کوچک‌تر یا مساوی'],
['key' => 'not', 'name' => 'نامساوی'],
],
'fields' => $this->fields(),
'products' => Product::query()
->orderBy('id')
->get(['id', 'title'])
->map(fn ($p) => ['id' => (int) $p->id, 'name' => (string) ($p->title ?: ('#'.$p->id))])
->values()
->all(),
'hours' => collect(range(0, 23))->map(fn ($h) => [
'key' => $h,
'name' => str_pad((string) $h, 2, '0', STR_PAD_LEFT).':00',
])->all(),
];
}
/** @return list<array<string, mixed>> */
public function fields(): array
{
return [
[
'key' => 'never_login',
'group' => 'حضور',
'name' => 'هرگز وارد اپ نشده',
'inputs' => [],
],
[
'key' => 'last_seen_older_days',
'group' => 'حضور',
'name' => 'آخرین بازدید قدیمی‌تر از N روز',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'last_seen_within_days',
'group' => 'حضور',
'name' => 'آخرین بازدید در N روز اخیر',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'is_legal',
'group' => 'مشتری',
'name' => 'وضعیت احراز',
'inputs' => [
['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [
['key' => 0, 'name' => 'احراز نشده'],
['key' => 1, 'name' => 'احراز خودکار'],
['key' => 2, 'name' => 'تأیید ادمین'],
]],
],
],
[
'key' => 'active',
'group' => 'مشتری',
'name' => 'حساب فعال',
'inputs' => [
['key' => 'value', 'type' => 'select', 'label' => 'وضعیت', 'options' => [
['key' => 1, 'name' => 'فعال'],
['key' => 0, 'name' => 'غیرفعال'],
]],
],
],
[
'key' => 'registered_older_days',
'group' => 'مشتری',
'name' => 'ثبت‌نام قدیمی‌تر از N روز',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'registered_within_days',
'group' => 'مشتری',
'name' => 'ثبت‌نام در N روز اخیر',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'orders_count',
'group' => 'مشتری',
'name' => 'تعداد سفارش',
'inputs' => [
['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'],
['key' => 'value', 'type' => 'number', 'label' => 'عدد'],
],
],
[
'key' => 'wallet_cash',
'group' => 'کیف',
'name' => 'موجودی ریال کیف اصلی',
'inputs' => [
['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'],
['key' => 'value', 'type' => 'number', 'label' => 'ریال'],
],
],
[
'key' => 'wallet_gold',
'group' => 'کیف',
'name' => 'موجودی طلا کیف اصلی',
'inputs' => [
['key' => 'op', 'type' => 'operator', 'label' => 'عملگر'],
['key' => 'value', 'type' => 'number', 'label' => 'گرم'],
],
],
[
'key' => 'never_ordered',
'group' => 'معامله',
'name' => 'هرگز معامله موفق نداشته',
'inputs' => [],
],
[
'key' => 'has_buy_within_days',
'group' => 'معامله',
'name' => 'خرید موفق در N روز اخیر',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'has_sell_within_days',
'group' => 'معامله',
'name' => 'فروش موفق در N روز اخیر',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'last_order_older_days',
'group' => 'معامله',
'name' => 'آخرین معامله موفق قدیمی‌تر از N روز',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'has_deposit_within_days',
'group' => 'واریز',
'name' => 'شارژ ریالی در N روز اخیر',
'inputs' => [
['key' => 'value', 'type' => 'number', 'label' => 'تعداد روز'],
],
],
[
'key' => 'has_withdraw_pending',
'group' => 'برداشت',
'name' => 'برداشت در انتظار دارد',
'inputs' => [],
],
];
}
public function fieldKeys(): array
{
return array_column($this->fields(), 'key');
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Automation;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;
class AutomationConditionQuery
{
public function baseQuery(): Builder
{
return Customer::query()
->where('type', 2)
->where('is_archive', false);
}
public function apply(Builder $query, array $conditions): Builder
{
$rows = array_values(array_filter($conditions, fn ($r) => is_array($r) && ! empty($r['field'])));
if ($rows === []) {
return $query;
}
return $query->where(function (Builder $outer) use ($rows) {
foreach ($rows as $i => $row) {
$or = $i > 0 && (($row['cr'] ?? 'and') === 'or');
$this->applyOne($outer, $row, $or);
}
});
}
public function matchesCustomer(Customer $customer, array $conditions): bool
{
return $this->apply($this->baseQuery()->where('id', $customer->id), $conditions)->exists();
}
private function applyOne(Builder $q, array $row, bool $or): void
{
$field = (string) $row['field'];
$op = (string) ($row['op'] ?? 'eq');
$value = $row['value'] ?? null;
if (is_string($value)) {
$value = str_replace(',', '', $value);
}
$days = max(0, (int) $value);
match ($field) {
'never_login' => $this->nullCol($q, 'last_seen_at', $or),
'last_seen_older_days' => $this->where($q, $or, function (Builder $inner) use ($days) {
$cutoff = now()->subDays($days);
$inner->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $cutoff);
}),
'last_seen_within_days' => $this->cmp($q, 'last_seen_at', 'gte', now()->subDays($days), $or),
'is_legal' => $this->cmp($q, 'is_legal', 'eq', (int) $value, $or),
'active' => $this->cmp($q, 'active', 'eq', (int) $value, $or),
'registered_older_days' => $this->cmp($q, 'created_at', 'lt', now()->subDays($days), $or),
'registered_within_days' => $this->cmp($q, 'created_at', 'gte', now()->subDays($days), $or),
'orders_count' => $this->cmp($q, 'orders_count', $op, (int) $value, $or),
'wallet_cash' => $this->wallet($q, 'cash', $op, (float) $value, $or),
'wallet_gold' => $this->wallet($q, 'gold', $op, (float) $value, $or),
'never_ordered' => $this->orderExists($q, null, null, $or, true),
'has_buy_within_days' => $this->orderExists($q, 1, $days, $or, false),
'has_sell_within_days' => $this->orderExists($q, 2, $days, $or, false),
'last_order_older_days' => $this->lastOrderOlder($q, $days, $or),
'has_deposit_within_days' => $this->depositExists($q, $days, $or),
'has_withdraw_pending' => $this->withdrawPending($q, $or),
default => null,
};
}
private function where(Builder $q, bool $or, callable $fn): void
{
$method = $or ? 'orWhere' : 'where';
$q->{$method}($fn);
}
private function nullCol(Builder $q, string $col, bool $or): void
{
$method = $or ? 'orWhereNull' : 'whereNull';
$q->{$method}($col);
}
private function cmp(Builder $q, string $col, string $op, mixed $value, bool $or): void
{
$sqlOp = match ($op) {
'gt' => '>',
'gte' => '>=',
'lt' => '<',
'lte' => '<=',
'not' => '<>',
default => '=',
};
$method = $or ? 'orWhere' : 'where';
$q->{$method}($col, $sqlOp, $value);
}
private function wallet(Builder $q, string $col, string $op, float $value, bool $or): void
{
$sqlOp = match ($op) {
'gt' => '>',
'gte' => '>=',
'lt' => '<',
'lte' => '<=',
'not' => '<>',
default => '=',
};
$expr = "(select w.{$col} from wallets w where w.owner = customers.id and w.fund_id is null and w.product_id is null order by w.id asc limit 1)";
$method = $or ? 'orWhereRaw' : 'whereRaw';
$q->{$method}("coalesce({$expr}, 0) {$sqlOp} ?", [$value]);
}
private function orderExists(Builder $q, ?int $direction, ?int $days, bool $or, bool $notExists): void
{
$method = $or
? ($notExists ? 'orWhereNotExists' : 'orWhereExists')
: ($notExists ? 'whereNotExists' : 'whereExists');
$q->{$method}(function ($sub) use ($direction, $days) {
$sub->from('orders')
->whereColumn('orders.customer_id', 'customers.id')
->where('orders.sts', 2);
if ($direction) {
$sub->where('orders.direction', $direction);
}
if ($days !== null) {
$sub->where('orders.created_at', '>=', now()->subDays($days));
}
});
}
private function lastOrderOlder(Builder $q, int $days, bool $or): void
{
$cutoff = now()->subDays($days);
$this->where($q, $or, function (Builder $inner) use ($cutoff) {
$inner->whereNotExists(function ($sub) {
$sub->from('orders')
->whereColumn('orders.customer_id', 'customers.id')
->where('orders.sts', 2);
})->orWhereRaw(
'(select max(orders.created_at) from orders where orders.customer_id = customers.id and orders.sts = 2) < ?',
[$cutoff]
);
});
}
private function depositExists(Builder $q, int $days, bool $or): void
{
$method = $or ? 'orWhereExists' : 'whereExists';
$q->{$method}(function ($sub) use ($days) {
$sub->from('withdraws')
->whereColumn('withdraws.customer_id', 'customers.id')
->where('withdraws.cash', '>', 0)
->whereIn('withdraws.sts', [2, 4])
->where('withdraws.created_at', '>=', now()->subDays($days));
});
}
private function withdrawPending(Builder $q, bool $or): void
{
$method = $or ? 'orWhereExists' : 'whereExists';
$q->{$method}(function ($sub) {
$sub->from('withdraws')
->whereColumn('withdraws.customer_id', 'customers.id')
->where('withdraws.sts', 1);
});
}
}
@@ -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;
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Automation;
use Illuminate\Support\Facades\Log;
class AutomationEvents
{
public static function fire(string $event, int $customerId): void
{
if ($customerId <= 0 || $event === '') {
return;
}
try {
app(AutomationEngine::class)->onCustomerEvent($event, $customerId);
} catch (\Throwable $e) {
Log::warning('Automation event failed', [
'event' => $event,
'customer_id' => $customerId,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Automation;
class AutomationFingerprint
{
public function make(string $triggerType, array $triggerConfig, array $conditions): string
{
$cfg = [
'event' => (string) ($triggerConfig['event'] ?? ''),
'delay_hours' => (int) ($triggerConfig['delay_hours'] ?? 0),
'step_toman' => (float) ($triggerConfig['step_toman'] ?? 0),
'direction' => (string) ($triggerConfig['direction'] ?? ''),
'product_id' => (int) ($triggerConfig['product_id'] ?? 0),
];
$norm = [];
foreach ($conditions as $row) {
if (! is_array($row) || empty($row['field'])) {
continue;
}
$norm[] = [
'field' => (string) $row['field'],
'op' => (string) ($row['op'] ?? ''),
'value' => $row['value'] ?? null,
];
}
usort($norm, fn ($a, $b) => strcmp($a['field'].$a['op'], $b['field'].$b['op']));
$payload = [
't' => $triggerType,
'cfg' => $cfg,
'c' => $norm,
];
return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Services\Automation;
use App\Models\Automation;
use App\Models\AutomationSend;
use App\Models\Customer;
use App\Models\Message;
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,
) {
}
public function send(Automation $auto, Customer $customer, string $channel): bool
{
if (! $this->maySend($auto, $customer, $channel)) {
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 = 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
{
if (! $auto->usesChannel($channel)) {
return false;
}
$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 false;
}
}
$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 false;
}
}
return true;
}
private function render(string $text, Customer $customer): string
{
$name = trim((string) ($customer->name ?: trim(($customer->fname ?? '').' '.($customer->lname ?? ''))));
return strtr($text, [
'{{name}}' => $name,
'{{fname}}' => (string) ($customer->fname ?? ''),
'{{mobile}}' => (string) ($customer->mobile ?? ''),
]);
}
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,
]);
}
}
@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('automations')) {
Schema::create('automations', function (Blueprint $table) {
$table->id();
$table->string('name', 191);
$table->string('code', 64)->unique();
$table->boolean('is_active')->default(true);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->unsignedTinyInteger('sms_send_hour')->nullable();
$table->unsignedTinyInteger('in_app_send_hour')->nullable();
$table->string('trigger_type', 16); // schedule|event|price
$table->json('trigger_config')->nullable();
$table->decimal('last_anchor_price', 18, 2)->nullable();
$table->json('conditions')->nullable();
$table->json('channels'); // ["sms","in_app"]
$table->string('title', 191);
$table->text('body');
$table->unsignedInteger('cooldown_hours')->default(24);
$table->unsignedInteger('max_sends')->nullable();
$table->string('logic_fingerprint', 64)->index();
$table->unsignedInteger('sent_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->timestamp('last_sms_run_at')->nullable();
$table->timestamp('last_in_app_run_at')->nullable();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->timestamps();
$table->softDeletes();
});
}
if (! Schema::hasTable('automation_sends')) {
Schema::create('automation_sends', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('automation_id')->index();
$table->unsignedBigInteger('customer_id')->index();
$table->string('channel', 16);
$table->string('status', 16); // sent|failed
$table->text('error')->nullable();
$table->timestamps();
$table->index(['automation_id', 'customer_id', 'channel']);
});
}
if (! Schema::hasTable('automation_delayed_jobs')) {
Schema::create('automation_delayed_jobs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('automation_id')->index();
$table->unsignedBigInteger('customer_id')->index();
$table->string('channel', 16);
$table->timestamp('run_at')->index();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('automation_delayed_jobs');
Schema::dropIfExists('automation_sends');
Schema::dropIfExists('automations');
}
};
+10
View File
@@ -13,6 +13,7 @@ use \App\Http\Controllers\Admin\DashboardController;
use \App\Http\Controllers\Single\SingleOrderController;
use App\Http\Controllers\Admin\PriceTriggerController as AdminPriceTriggerController;
use App\Http\Controllers\Admin\AppNotificationController as AdminAppNotificationController;
use App\Http\Controllers\Admin\AutomationController as AdminAutomationController;
use App\Http\Controllers\Finance\FinanceController;
use App\Http\Controllers\Admin\ProductController;
use App\Http\Controllers\Admin\TahesabController;
@@ -61,6 +62,15 @@ Route::middleware('auth:user')->group(function (){
Route::get('app_notifications/{id}/readers', [AdminAppNotificationController::class, 'readers']);
Route::get('app_notifications/{id}/recipients', [AdminAppNotificationController::class, 'recipients']);
Route::delete('app_notifications/{id}', [AdminAppNotificationController::class, 'destroy']);
//------------Automations
Route::post('automations/table', [AdminAutomationController::class, 'table']);
Route::get('automations/catalog', [AdminAutomationController::class, 'catalog']);
Route::post('automations/preview', [AdminAutomationController::class, 'preview']);
Route::post('automations', [AdminAutomationController::class, 'store']);
Route::put('automations/{id}', [AdminAutomationController::class, 'update']);
Route::put('automations/{id}/toggle', [AdminAutomationController::class, 'toggle']);
Route::delete('automations/{id}', [AdminAutomationController::class, 'destroy']);
//------------ConditionalOrder
Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']);
Route::post('conditional_orders', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'store']);