Отправляем Webhook из Laravel в VtigerCRM

Отправляем Webhook из Laravel в VtigerCRM

Самая частая задача, которая стоит при разработке разного рода порталов или интернет-магазинов - это интеграция со сторонними системами, такими как CRM или ERP. И чаще всего для быстрого обновления данных приходится обновлять хуки. Например, клиент оформил заказ и данные по нему необходимо быстро отправить в CRM, чтобы в ней создалась сделка, поставились задачи сотрудникам, отправилось оповещение и прочее. В этой статье я расскажу о типовом примере интеграции, мы отправим простые данные таким способом, чтобы в дальнейшем его можно было проще всего масштабировать. Также мы обсудим подводные камни, которые могут возникнуть при интеграции и как их безболезненно решить.

Самая частая задача, которая стоит при разработке разного рода порталов или интернет-магазинов - это интеграция со сторонними системами, такими как CRM или ERP. И чаще всего для быстрого обновления данных приходится обновлять хуки. Например, клиент оформил заказ и данные по нему необходимо быстро отправить в CRM, чтобы в ней создалась сделка, поставились задачи сотрудникам, отправилось оповещение и прочее. В этой статье я расскажу о типовом примере интеграции, мы отправим простые данные таким способом, чтобы в дальнейшем его можно было проще всего масштабировать. Также мы обсудим подводные камни, которые могут возникнуть при интеграции и как их безболезненно решить.

Итак, представим себе, что у нас имеется какой-либо сайт на Laravel 9 с простой формой обратной связи. После того как пользователь заполняет поля, нажимает кнопку отправить, нам нужно сохранить данные в базу данных сайта и отправить их в Vtiger CRM, создав там лид. Забегая вперёд, я сразу скажу, что решать мы будем задачу немного нестандартным способом. Т.е. попробуем представить себе, что мы создаём не только лид, а запускаем какой-то сложный бизнес-процесс. Иными словами, стандартный API, который предоставляет нам Vtiger CRM не подойдёт. Нужно будет создавать какой-то кастомный хук.

Давайте посмотрим, что у нас имеется в Laravel. Вёрстку самой формы я прикладывать не буду, при необходимости весь код вы можете посмотреть на моём гитхабе - https://github.com/semelyanov86/laravel-test/commit/9dedaf4629aab1e3dcaf5e2503cbcb36b0de2f2e.

Во-первых, наша структура базы лидов выглядит следующим образом:

 

return new class extends Migration {

    public function up()

    {

        Schema::create('leads', function (Blueprint $table) {

            $table->id();

 

            $table->string('first_name', 190);

            $table->string('last_name', 190);

            $table->string('email', 100);

            $table->string('phone', 100);

            $table->text('description')->nullable();

 

            $table->timestamps();

        });

    }

 

    public function down()

    {

        Schema::dropIfExists('leads');

    }

};

 

Laravel синтегрирован с очередью сообщений RabbitMQ стандартным образом, установлены также пакеты lorisleiva/laravel-actions и spatie/data-transfer-object.

Модель Lead выглядит так:

 

namespace App\Models;

 

use Illuminate\Database\Eloquent\Model;

 

class Lead extends Model

{

    protected $fillable = [

        'first_name',

        'last_name',

        'email',

        'phone',

        'description',

    ];

}

 

Итак, у нас есть перечень полей, создадим в Vtiger всё необходимое для отправки в него данных о новом лиде.

Vtiger предоставляет нам удобный инструмент под названием ShortURL, позволяющий быстро создать ссылку для приёма и обработки данных со сторонних сайтов. Для её создания и регистрации, напишем скрипт. Я люблю создавать специальную папку scripts и хранить в ней все скрипты.

Создадим файл scripts/create_leads_webhook.php со следующим содержимым:

 

<?php

chdir('../');

require_once 'include/utils/utils.php';

require_once 'include/utils/VtlibUtils.php';

require_once 'modules/Vtiger/helpers/ShortURL.php';

global $adb;

$adb = PearDatabase::getInstance();

$options = array(

    'handler_path' => 'modules/Leads/handlers/LeadCreateTaskHandler.php',

    'handler_class' => 'Leads_CreateTask_Handler',

    'handler_function' => 'createLead',

    'handler_data' => array()

);

$trackURL = Vtiger_ShortURL_Helper::generateURL($options);

var_dump($trackURL);

 

Запустим его из браузера и на экране у нас появится непосредственно сама ссылка, которая нас и интересует (в моём случае ссылка выглядит так: http://vtiger.test:8000/shorturl.php?id=6303d18dec9038.15461100). Сохраним её себе, она понадобится нам на будущее.

Теперь нам нужно написать обработчик, который будет создавать лиды. Создадим его в папке с модулем modules/Leads/handlers/LeadCreateTaskHandler.php :

 

class Leads_CreateTask_Handler

{

 

    public function createLead($data): void

    {

        global $current_user;

        $current_user->id = 1;

        $dataRequest = json_decode(file_get_contents('php://input'), true);

        $response = new Vtiger_Response();

        $response->setEmitType(Vtiger_Response::$EMIT_JSON);

        $leadModel = Vtiger_Record_Model::getCleanInstance('Leads');

        $leadModel->set('mode', 'create');

        $leadModel->set('lastname', $dataRequest['last_name']);

        $leadModel->set('firstname', $dataRequest['first_name']);

        $leadModel->set('email', $dataRequest['email']);

        $leadModel->set('description', $dataRequest['description']);

        $leadModel->set('phone', $dataRequest['phone']);

        $leadModel->set('assigned_user_id', 1);

        $leadModel->set('designation', $dataRequest['portal_id']);

        $leadModel->save();

 

        $response->setResult(['success' => true]);

 

        $response->emit();

    }

}

 

В нашем примере мы просто создаём лида, заполняем модель данными, которые нам придут из Laravel и сохраняем его. Конечно, мы пропустили одну важную вещь - найти перед этим запись и если она есть в системе, обновить её, а не создавать новую. Для защиты от дубликатов. Но в этой статье мы рассматриваем только общий принцип. Детали реализации за вами.

Стоит отметить одно служебное поле, которое я назвал designation. В нём я буду хранить id лида, который нам пришёл от Laravel, чтобы в будущем можно было искать дубликатов по этому полю и связывать лидов между системами.

Теперь переходим к наиболее важной части - отправки данных из Laravel.

Пока мы не потеряли сгенерированный адрес url, сохраним его в конфигурационном файле. Для этого в файл config/services.php внесём новый ключ: 

 

'lead_webhook' => env('LEAD_WEBHOOK'),

 

И в файл окружения .env добавим само значение:

 

LEAD_WEBHOOK=http://vtiger.test:8000/shorturl.php?id=6303d18dec9038.15461100

 

Итак, приступаем к самому процессу сохранения и отправки хука.

Во-первых, саму форму нам нужно не забыть провалидировать. Создаём Request в файле app/Http/Requests/StoreLeadRequest.php

 

<?php

declare(strict_types=1);

 

namespace App\Http\Requests;

 

use Illuminate\Foundation\Http\FormRequest;

 

final class StoreLeadRequest extends FormRequest

{

    public function rules(): array

    {

        return [

            'first_name' => ['required', 'min:3', 'max:190'],

            'last_name' => ['required', 'min:3', 'max:190'],

            'email' => ['required', 'email'],

            'phone' => ['nullable', 'alpha_num'],

            'description' => ['nullable', 'string']

        ];

    }

 

    public function authorize(): bool

    {

        return true;

    }

}

 

Чтобы нормально обработать Request, нам нужно создать DTO, который мы затем перебросим в Action из контроллера. Давайте сделаем это. Создаём файл app/Transfers/LeadTransfer.php :

 

<?php

 

declare(strict_types=1);

 

namespace App\Transfers;

 

final class LeadTransfer extends \Spatie\DataTransferObject\DataTransferObject

{

    public ?int $id;

 

    public string $first_name;

 

    public string $last_name;

 

    public string $email;

 

    public string $description;

 

    public string $phone;

}

 

Теперь напишем Action, в котором и реализуем простой процесс сохранения (файл app/Actions/StoreLeadAction.php):

 

<?php

 

declare(strict_types=1);

 

namespace App\Actions;

 

use App\Jobs\VtigerWebhookJob;

use App\Models\Lead;

use App\Transfers\LeadTransfer;

use Lorisleiva\Actions\Concerns\AsAction;

 

final class StoreLeadAction

{

    use AsAction;

 

    public function handle(LeadTransfer $transfer): LeadTransfer

    {

        $leadModel = Lead::create($transfer->toArray());

        $transfer->id = $leadModel->id;

        VtigerWebhookJob::dispatch($transfer);

        return $transfer;

    }

}

 

В этом классе простая процедура реализации. Мы преобразуем пришедший к нам DTO в массив, создаём лида, записываем обратно в DTO полученный ID из базы данных и вызываем очередь, которую мы напишем чуть позже.

Да, в нашем случае нам понадобится работать именно с очередью. Мы никогда не знаем, какой ответ нам придёт из CRM и стабильный ли канал связи между серверами. Поэтому очень важно организовать интеграцию между системами в асинхронном режиме.

Теперь соединяем всё воедино в самом контроллере (файл app/Http/Controllers/LeadsController.php):

 

<?php

declare(strict_types=1);

 

namespace App\Http\Controllers;

 

use App\Actions\StoreLeadAction;

use App\Http\Requests\StoreLeadRequest;

use App\Transfers\LeadTransfer;

 

class LeadsController extends Controller

{

    public function create(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory

    {

        return view('leads.create');

    }

 

    public function store(StoreLeadRequest $request, StoreLeadAction $action): \Illuminate\Http\RedirectResponse

    {

        $dto = new LeadTransfer($request->validated());

        $result = $action->handle($dto);

        return redirect()->back()->with('message', 'Lead created with id: ' . $result->id);

    }

}

 

Теперь остаётся только создать сам Job, который и будет заниматься отправкой хука. Полный файл (app/Jobs/VtigerWebhookJob.php) приложен ниже, далее я объясню построчно каждое действие:

 

<?php

declare(strict_types=1);

 

namespace App\Jobs;

 

use App\Transfers\LeadTransfer;

use Illuminate\Bus\Queueable;

use Illuminate\Contracts\Queue\ShouldQueue;

use Illuminate\Foundation\Bus\Dispatchable;

use Illuminate\Queue\InteractsWithQueue;

use Illuminate\Queue\SerializesModels;

use Illuminate\Support\Facades\Http;

use Illuminate\Support\Facades\Log;

 

final class VtigerWebhookJob implements ShouldQueue

{

    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

 

    public int $tries = 0;

 

    public function __construct(

        protected LeadTransfer $leadTransfer

    )

    {

    }

 

    public function retryUntil(): \Illuminate\Support\Carbon

    {

        return now()->addDay();

    }

 

    public function handle(): void

    {

        $data = $this->leadTransfer->toArray();

        $data['portal_id'] = $this->leadTransfer->id;

        unset($data['id']);

        $response = Http::timeout(5)->post(config('services.lead_webhook'), $data);

        if ($response->failed()) {

            $this->release(

                now()->addMinutes(15 * $this->attempts())

            );

        }

    }

 

    public function failed(\Exception $e): void

    {

        Log::error('Failed to send Lead to Vtiger with id: ' . $this->leadTransfer->id . ' Error:' . $e->getMessage());

    }

}

 

При отправке данных мы используем стандартный клиент от Laravel:

 

Http::timeout(5)->post(config('services.lead_webhook'), $data)

 

Как видите, я специально установил принудительный таймаут, чтобы сайт не ждал ответа слишком долго от Vtiger. Я всегда рекомендую устанавливать таймаут, когда вы делаете запрос на сторонний сервер или систему.

После того как мы отправили запрос, мы получили ответ. Его нам теперь нужно тщательно проанализировать. Если код ответа отличается от 2xx, нам нужно осуществить отправку данных повторно несколько раз, пока она не попадёт в таблицу failed. Но вместо того, чтобы повторять отправку данных какое-то неограниченное количество раз, мы поставим лимит в 24 часа.

В первую очередь, давайте поймаем ответ и отправим задачу обратно в очередь с экспоненциальным алгоритмом:

 

        if ($response->failed()) {

            $this->release(

                now()->addMinutes(15 * $this->attempts())

            );

        }

 

Метод attemts возвращает нам число, в котором сказано, сколько раз была запущена задача. При первом запуске нам вернётся число 1.

В нашем случае, при первом неудачном старте мы запустим задачу в следующий раз через 15 минут. При втором - через 30 минут. При третьем - через 45. И так далее.

Но мы же договорились, что необходимо отправлять данные только в течение 24 часов, верно? Мы могли бы сделать сложный расчёт в минутах и повторять только определённое число раз... Но есть способ гораздо проще:

 

    public function retryUntil(): \Illuminate\Support\Carbon

    {

        return now()->addDay();

    }

 

С методом retryUntil мы говорим Laravel установить срок действия задачи в 24 часа. Срок действия задачи рассчитывается с момента первого запуска и не обновляется при повторном запуске. То, что и нужно.

Но вы наверное уже знаете, что Laravel разрешает нам только одну попытку запуска по-умолчанию. Нужно переписать это поведение:

 

    public int $tries = 0;

 

После этого Laravel будет запускать задачу неограниченное число раз.

Если в течение 24 часов данные в Vtiger так и не отправились, то что-то произошло сверхъестественное и нужно уведомить администратора, отправив ему сообщение. Если у вас установлена хорошая система логирования, то достаточно простой функции:

 

    public function failed(\Exception $e): void

    {

        Log::error('Failed to send Lead to Vtiger with id: ' . $this->leadTransfer->id . ' Error:' . $e->getMessage());

    }

 

Когда задача будет помечена Laravel как неудавшаяся, сработает метод failed. В нём мы отправляем данные в лог, откуда они попадут в систему логирования, а она, в свою очередь, уведомит администратора.

Таким образом, мы создали очень простую и масштабируемую систему отправки лидов из Laravel в Vtiger. На стороне CRM мы использовали кастомный webhook, а не стандартный API. А на стороне Laravel данные отправляем при помощи очереди с периодическим повтором в случае неудачи. Если отправка в течение 24 часов так и не произойдёт, мы уведомим администратора.