added automation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user