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>
This commit is contained in:
@@ -10,7 +10,7 @@ class SuperAdminProgramSettingsRefreshCommand extends Command
|
|||||||
protected $signature = 'superadmin:refresh-program-settings
|
protected $signature = 'superadmin:refresh-program-settings
|
||||||
{--clear-only : فقط کش last-good را پاک کن}';
|
{--clear-only : فقط کش last-good را پاک کن}';
|
||||||
|
|
||||||
protected $description = 'کش overlay تنظیمات سوپرادمین را پاک/تازه میکند (مثلاً بعد از تغییر enable_automation)';
|
protected $description = 'کش overlay تنظیمات سوپرادمین را پاک/تازه میکند';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
@@ -30,11 +30,10 @@ class SuperAdminProgramSettingsRefreshCommand extends Command
|
|||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$items = is_array($result['items'] ?? null) ? $result['items'] : [];
|
||||||
$this->info('fetched '.(int) ($result['count'] ?? 0).' setting(s) from SuperAdmin');
|
$this->info('fetched '.(int) ($result['count'] ?? 0).' setting(s) from SuperAdmin');
|
||||||
if (($result['enable_automation'] ?? null) !== null) {
|
foreach ($items as $key => $value) {
|
||||||
$this->line('enable_automation='.$result['enable_automation']);
|
$this->line(" {$key}={$value}");
|
||||||
} else {
|
|
||||||
$this->warn('enable_automation missing in SuperAdmin response');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
use App\DB\Te\TableEngine;
|
use App\DB\Te\TableEngine;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Automation;
|
use App\Models\Automation;
|
||||||
|
use App\Models\AutomationSend;
|
||||||
use App\Services\Automation\AutomationCatalog;
|
use App\Services\Automation\AutomationCatalog;
|
||||||
use App\Services\Automation\AutomationEngine;
|
use App\Services\Automation\AutomationEngine;
|
||||||
use App\Services\Automation\AutomationFingerprint;
|
use App\Services\Automation\AutomationFingerprint;
|
||||||
@@ -89,6 +90,49 @@ class AutomationController extends Controller
|
|||||||
return response()->json(true);
|
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
|
private function assertFeatureEnabled(): void
|
||||||
{
|
{
|
||||||
if (! app(ConfigService::class)->isAutomationEnabled()) {
|
if (! app(ConfigService::class)->isAutomationEnabled()) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class SettingsController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* پاک کردن کش overlay و خواندن دوباره از سوپرادمین
|
* پاک کردن کش overlay و خواندن دوباره از سوپرادمین
|
||||||
* (سوییچهایی مثل enable_automation بعد از تغییر در پنل سوپرادمین).
|
* (همهٔ کلیدهای can_edit=0 مالک سوپرادمین — دسترسی مشتری به قابلیتها).
|
||||||
*/
|
*/
|
||||||
public function refreshSuperAdminOverlay()
|
public function refreshSuperAdminOverlay()
|
||||||
{
|
{
|
||||||
@@ -91,14 +91,17 @@ class SettingsController extends Controller
|
|||||||
'message' => 'ارتباط با سوپرادمین برقرار نشد؛ کش پاک شد و در درخواست بعدی دوباره تلاش میشود',
|
'message' => 'ارتباط با سوپرادمین برقرار نشد؛ کش پاک شد و در درخواست بعدی دوباره تلاش میشود',
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
|
'items' => [],
|
||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$count = (int) ($result['count'] ?? 0);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'تنظیمات پیشرفته از سوپرادمین همگام شد',
|
'message' => "همگام شد ({$count} کلید دسترسی از سوپرادمین)",
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'count' => (int) ($result['count'] ?? 0),
|
'count' => $count,
|
||||||
'enable_automation' => $result['enable_automation'] ?? null,
|
'items' => $result['items'] ?? [],
|
||||||
'source' => $result['source'] ?? 'superadmin',
|
'source' => $result['source'] ?? 'superadmin',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Product;
|
|||||||
use App\Services\ConfigService;
|
use App\Services\ConfigService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
class AutomationEngine
|
class AutomationEngine
|
||||||
{
|
{
|
||||||
@@ -87,6 +88,53 @@ class AutomationEngine
|
|||||||
return $this->conditions->apply($this->conditions->baseQuery(), $conditions)->count();
|
return $this->conditions->apply($this->conditions->baseQuery(), $conditions)->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اجرای فوری برای تست ادمین — بدون محدودیت ساعت، بدون touch کردن last_sms_run_at.
|
||||||
|
*
|
||||||
|
* @return array{audience: int, sent: int, failed: int, skipped: int}
|
||||||
|
*/
|
||||||
|
public function runNow(Automation $auto, bool $force = true): array
|
||||||
|
{
|
||||||
|
if (! app(ConfigService::class)->isAutomationEnabled()) {
|
||||||
|
throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.');
|
||||||
|
}
|
||||||
|
if (! $auto->is_active) {
|
||||||
|
throw new UnprocessableEntityHttpException('اتوماسیون غیرفعال است.');
|
||||||
|
}
|
||||||
|
if (! $this->inWindow($auto)) {
|
||||||
|
throw new UnprocessableEntityHttpException('اتوماسیون خارج از بازهٔ شروع/پایان است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = ['audience' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0];
|
||||||
|
$audienceQuery = $this->conditions->apply(
|
||||||
|
$this->conditions->baseQuery(),
|
||||||
|
$auto->conditions ?? []
|
||||||
|
);
|
||||||
|
$stats['audience'] = (int) $audienceQuery->count();
|
||||||
|
|
||||||
|
foreach ($this->channels($auto) as $channel) {
|
||||||
|
$this->conditions
|
||||||
|
->apply($this->conditions->baseQuery(), $auto->conditions ?? [])
|
||||||
|
->select(['id', 'name', 'fname', 'lname', 'mobile', 'type', 'is_archive'])
|
||||||
|
->chunkById(80, function ($customers) use ($auto, $channel, $force, &$stats) {
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (! $force && ! $this->sender->maySend($auto, $customer, $channel)) {
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->sender->send($auto, $customer, $channel, $force)) {
|
||||||
|
$stats['sent']++;
|
||||||
|
} else {
|
||||||
|
$stats['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
private function tryPriceFire(Automation $auto, array $payload): void
|
private function tryPriceFire(Automation $auto, array $payload): void
|
||||||
{
|
{
|
||||||
if (! $this->inWindow($auto)) {
|
if (! $this->inWindow($auto)) {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class AutomationSender
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(Automation $auto, Customer $customer, string $channel): bool
|
public function send(Automation $auto, Customer $customer, string $channel, bool $force = false): bool
|
||||||
{
|
{
|
||||||
if (! $this->maySend($auto, $customer, $channel)) {
|
if (! $this->maySend($auto, $customer, $channel, $force)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +71,16 @@ class AutomationSender
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function maySend(Automation $auto, Customer $customer, string $channel): bool
|
public function maySend(Automation $auto, Customer $customer, string $channel, bool $force = false): bool
|
||||||
{
|
{
|
||||||
if (! $auto->usesChannel($channel)) {
|
if (! $auto->usesChannel($channel)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$max = $auto->max_sends;
|
$max = $auto->max_sends;
|
||||||
if ($max !== null && (int) $max > 0) {
|
if ($max !== null && (int) $max > 0) {
|
||||||
$count = AutomationSend::query()
|
$count = AutomationSend::query()
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class SuperAdminProgramSettingsOverlay
|
|||||||
/**
|
/**
|
||||||
* کش را پاک میکند و دوباره از سوپرادمین میخواند.
|
* کش را پاک میکند و دوباره از سوپرادمین میخواند.
|
||||||
*
|
*
|
||||||
* @return array{ok: bool, count: int, enable_automation: string|null, source: string}
|
* @return array{ok: bool, count: int, items: array<string, string>, source: string}
|
||||||
*/
|
*/
|
||||||
public function refreshFromSuperAdmin(): array
|
public function refreshFromSuperAdmin(): array
|
||||||
{
|
{
|
||||||
@@ -96,17 +96,27 @@ class SuperAdminProgramSettingsOverlay
|
|||||||
return [
|
return [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'enable_automation' => null,
|
'items' => [],
|
||||||
'source' => 'unavailable',
|
'source' => 'unavailable',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$auto = $map['enable_automation']['value'] ?? null;
|
$items = [];
|
||||||
|
foreach ($map as $key => $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $row['value'] ?? null;
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[(string) $key] = (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'count' => count($map),
|
'count' => count($items),
|
||||||
'enable_automation' => $auto === null || $auto === '' ? null : (string) $auto,
|
'items' => $items,
|
||||||
'source' => 'superadmin',
|
'source' => 'superadmin',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ Route::middleware('auth:user')->group(function (){
|
|||||||
Route::post('automations', [AdminAutomationController::class, 'store']);
|
Route::post('automations', [AdminAutomationController::class, 'store']);
|
||||||
Route::put('automations/{id}', [AdminAutomationController::class, 'update']);
|
Route::put('automations/{id}', [AdminAutomationController::class, 'update']);
|
||||||
Route::put('automations/{id}/toggle', [AdminAutomationController::class, 'toggle']);
|
Route::put('automations/{id}/toggle', [AdminAutomationController::class, 'toggle']);
|
||||||
|
Route::post('automations/{id}/run-now', [AdminAutomationController::class, 'runNow']);
|
||||||
|
Route::post('automations/{id}/sends/table', [AdminAutomationController::class, 'sendsTable']);
|
||||||
Route::delete('automations/{id}', [AdminAutomationController::class, 'destroy']);
|
Route::delete('automations/{id}', [AdminAutomationController::class, 'destroy']);
|
||||||
//------------ConditionalOrder
|
//------------ConditionalOrder
|
||||||
Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']);
|
Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']);
|
||||||
|
|||||||
Reference in New Issue
Block a user