Files
boursecu_server/app/Http/Controllers/Admin/AutomationController.php
T

274 lines
10 KiB
PHP

<?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 App\Services\ConfigService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class AutomationController extends Controller
{
public function table(Request $request)
{
$this->assertFeatureEnabled();
$query = Automation::query();
TableEngine::create($query, $request->all())
->initSearch(['name', 'code', 'title', 'body'])
->select(['*'])
->export($query);
return $query;
}
public function catalog()
{
$this->assertFeatureEnabled();
return response()->json(app(AutomationCatalog::class)->toArray());
}
public function preview(Request $request)
{
$this->assertFeatureEnabled();
$conditions = array_values((array) $request->input('conditions', []));
$count = app(AutomationEngine::class)->previewCount($conditions);
return response()->json(['count' => $count]);
}
public function store(Request $request)
{
$this->assertFeatureEnabled();
$data = $this->validated($request);
$data['created_by'] = auth()->id();
$row = Automation::create($data);
return response()->json($row);
}
public function update(Request $request, $id)
{
$this->assertFeatureEnabled();
$row = Automation::query()->findOrFail($id);
$data = $this->validated($request, (int) $row->id);
$row->fill($data)->save();
return response()->json($row);
}
public function toggle($id)
{
$this->assertFeatureEnabled();
$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)
{
$this->assertFeatureEnabled();
Automation::query()->findOrFail($id)->delete();
return response()->json(true);
}
private function assertFeatureEnabled(): void
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.');
}
}
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');
$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('ساعت ارسال اعلان را مشخص کنید.');
}
}
$conditions = array_values(array_filter(
(array) $request->input('conditions', []),
fn ($row) => is_array($row) && filled($row['field'] ?? null)
));
$audienceMode = (string) $request->input('audience_mode', $conditions === [] ? 'all' : 'filter');
if ($audienceMode === 'all') {
$conditions = [];
}
$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', []);
$cooldownHours = max(0, (int) $request->input('cooldown_hours', 24));
if ($triggerType === AutomationCatalog::TRIGGER_SCHEDULE) {
$everyDays = max(1, (int) ($triggerConfig['every_days'] ?? 1));
$triggerConfig = ['every_days' => $everyDays];
// هر مشتری حداکثر یک‌بار در همین بازهٔ تکرار
$cooldownHours = max($cooldownHours, $everyDays * 24);
} 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';
}
$window = (string) ($triggerConfig['price_window'] ?? 'calendar_day');
if (! in_array($window, ['calendar_day', 'from_last'], true)) {
$window = 'calendar_day';
}
$triggerConfig = [
'step_toman' => $step,
'direction' => $dir,
'product_id' => (int) ($triggerConfig['product_id'] ?? 0) ?: null,
'price_window' => $window,
];
// برای «در یک روز» معمولاً یک‌بار در روز کافی است
if ($window === 'calendar_day') {
$cooldownHours = max($cooldownHours, 20);
}
}
$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)
? ($smsHour === null ? null : (int) $smsHour)
: null,
'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true)
? ($inAppHour === null ? null : (int) $inAppHour)
: null,
'trigger_type' => $triggerType,
'trigger_config' => $triggerConfig,
'conditions' => $conditions,
'channels' => $channels,
'title' => mb_substr($title, 0, 191),
'body' => $body,
'cooldown_hours' => $cooldownHours,
'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);
}
}