Files
boursecu_server/app/Http/Controllers/Admin/AutomationController.php
T
mohmdsdgandCursor 995f6984ed feat(automations): add run-now test endpoint and sends log table
Enable immediate automation test runs without waiting for the scheduled hour, expose send history for debugging, and return all SuperAdmin overlay keys on refresh instead of only enable_automation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:46:13 +03:30

318 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\DB\Te\TableEngine;
use App\Http\Controllers\Controller;
use App\Models\Automation;
use App\Models\AutomationSend;
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);
}
/** اجرای فوری برای تست — بدون انتظار ساعت cron */
public function runNow(Request $request, $id)
{
$this->assertFeatureEnabled();
$row = Automation::query()->findOrFail($id);
$force = $request->boolean('force', true);
$stats = app(AutomationEngine::class)->runNow($row, $force);
return response()->json([
'message' => sprintf(
'مخاطب %d — ارسال موفق %d، ناموفق %d، ردشده %d',
$stats['audience'],
$stats['sent'],
$stats['failed'],
$stats['skipped']
),
...$stats,
]);
}
public function sendsTable(Request $request, $id)
{
$this->assertFeatureEnabled();
Automation::query()->findOrFail($id);
$query = AutomationSend::query()
->where('automation_id', (int) $id)
->leftJoin('customers', 'customers.id', '=', 'automation_sends.customer_id')
->select([
'automation_sends.*',
'customers.name as customer_name',
'customers.mobile as customer_mobile',
])
->orderByDesc('automation_sends.id');
TableEngine::create($query, $request->all())
->initSearch(['customers.name', 'customers.mobile', 'automation_sends.status', 'automation_sends.error'])
->select(['automation_sends.*', 'customers.name as customer_name', 'customers.mobile as customer_mobile'])
->export($query);
return $query;
}
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);
}
}