2026-08-23 14:16:00 +03:30
|
|
|
<?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');
|
2026-08-23 17:13:30 +03:30
|
|
|
$triggerConfigRaw = (array) $request->input('trigger_config', []);
|
|
|
|
|
$priceWindow = (string) ($triggerConfigRaw['price_window'] ?? 'calendar_day');
|
|
|
|
|
$needsChannelHour = ! (
|
|
|
|
|
(
|
|
|
|
|
$triggerType === AutomationCatalog::TRIGGER_EVENT
|
|
|
|
|
&& (int) ($triggerConfigRaw['delay_hours'] ?? 0) === 0
|
|
|
|
|
)
|
|
|
|
|
|| (
|
|
|
|
|
$triggerType === AutomationCatalog::TRIGGER_PRICE
|
|
|
|
|
&& $priceWindow === 'calendar_day'
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
if ($needsChannelHour) {
|
|
|
|
|
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('ساعت ارسال اعلان را مشخص کنید.');
|
|
|
|
|
}
|
2026-08-23 14:16:00 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$conditions = array_values(array_filter(
|
|
|
|
|
(array) $request->input('conditions', []),
|
|
|
|
|
fn ($row) => is_array($row) && filled($row['field'] ?? null)
|
|
|
|
|
));
|
2026-08-23 17:13:30 +03:30
|
|
|
$audienceMode = (string) $request->input('audience_mode', $conditions === [] ? 'all' : 'filter');
|
|
|
|
|
if ($audienceMode === 'all') {
|
|
|
|
|
$conditions = [];
|
|
|
|
|
}
|
2026-08-23 14:16:00 +03:30
|
|
|
$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', []);
|
2026-08-23 17:13:30 +03:30
|
|
|
$cooldownHours = max(0, (int) $request->input('cooldown_hours', 24));
|
2026-08-23 14:16:00 +03:30
|
|
|
if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) {
|
2026-08-23 17:13:30 +03:30
|
|
|
$everyDays = max(1, (int) ($triggerConfig['every_days'] ?? 1));
|
|
|
|
|
$triggerConfig = ['every_days' => $everyDays];
|
|
|
|
|
// هر مشتری حداکثر یکبار در همین بازهٔ تکرار
|
|
|
|
|
$cooldownHours = max($cooldownHours, $everyDays * 24);
|
2026-08-23 14:16:00 +03:30
|
|
|
} 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';
|
|
|
|
|
}
|
2026-08-23 17:13:30 +03:30
|
|
|
$window = (string) ($triggerConfig['price_window'] ?? 'calendar_day');
|
|
|
|
|
if (! in_array($window, ['calendar_day', 'from_last'], true)) {
|
|
|
|
|
$window = 'calendar_day';
|
|
|
|
|
}
|
2026-08-23 14:16:00 +03:30
|
|
|
$triggerConfig = [
|
|
|
|
|
'step_toman' => $step,
|
|
|
|
|
'direction' => $dir,
|
|
|
|
|
'product_id' => (int) ($triggerConfig['product_id'] ?? 0) ?: null,
|
2026-08-23 17:13:30 +03:30
|
|
|
'price_window' => $window,
|
2026-08-23 14:16:00 +03:30
|
|
|
];
|
2026-08-23 17:13:30 +03:30
|
|
|
// برای «در یک روز» معمولاً یکبار در روز کافی است
|
|
|
|
|
if ($window === 'calendar_day') {
|
|
|
|
|
$cooldownHours = max($cooldownHours, 20);
|
|
|
|
|
}
|
2026-08-23 14:16:00 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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,
|
2026-08-23 17:13:30 +03:30
|
|
|
'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true)
|
|
|
|
|
? ($smsHour === null ? null : (int) $smsHour)
|
|
|
|
|
: null,
|
|
|
|
|
'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true)
|
|
|
|
|
? ($inAppHour === null ? null : (int) $inAppHour)
|
|
|
|
|
: null,
|
2026-08-23 14:16:00 +03:30
|
|
|
'trigger_type' => $triggerType,
|
|
|
|
|
'trigger_config' => $triggerConfig,
|
|
|
|
|
'conditions' => $conditions,
|
|
|
|
|
'channels' => $channels,
|
|
|
|
|
'title' => mb_substr($title, 0, 191),
|
|
|
|
|
'body' => $body,
|
2026-08-23 17:13:30 +03:30
|
|
|
'cooldown_hours' => $cooldownHours,
|
2026-08-23 14:16:00 +03:30
|
|
|
'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);
|
|
|
|
|
}
|
|
|
|
|
}
|