Есть множество различных подходов к построению современных веб-приложений. Тем более это актуально в последние годы бурного развития фреймворков и библиотек как для бэкенда так и для фронтэнда. Перед выполнением проекта требуется осознанно подойти к выбору технологического стека, на котором оно будет построено. В этой статье мы расскажем про выбор инструментов для реализации CRM-системы.
Действительно, какой смысл разрабатывать что-то своё, если на рынке уже есть готовые решения? Это извечный вопрос, ответ на который не так однозначен и очевиден.
Лидером среди готовых решений по праву является Битрикс24. Читайте нашу статью на эту тему: Добро пожаловать в новую цифровую эпоху вместе с Битрикс24.
Первоначальная стоимость вложений во внедрение готового продукта намного ниже чем в разработку своего собственного. Но дальнейшая работа с таким продуктом может с лихвой «перебить» сэкономленные средства, если:
Также в случае готового решения нужно понимать, что его разработчики стремятся к универсальности, стараясь покрыть как можно больше запросов на функционал от пользователей системы. Это делает продукт громоздким и увеличивает время на его изучение.
С индивидуальной разработкой CRM всё наоборот. Вы получаете продукт, который тесно интегрирован именно с вашими процессами, не имеет никакого «лишнего» функционала. Работа будет доставлять радость. Клиенты будут довольны быстрым и качественным обслуживанием. Но, ничего не получится если:
Своя "рубашка" ближе к телу, но ухаживать за ней придётся самостоятельно. Принимая решение разрабатывать свою CRM-систему, нужно быть готовым к тому, что её придётся поддерживать, обновлять и развивать за свой счёт. Финансирование "по остаточному принципу" здесь точно не подойдёт. Система станет вашим незаменимым сотрудником, которому тоже нужна "зарплата".
В нашем случае была и остаётся глубокая погружённость в отрасль и достаточно хорошая экспертиза для создания действительно качественного решения по автоматизации.
Требуется создать CRM-систему, которая покрывала бы все потребности сети косметологических салонов по работе с собственниками, сотрудниками и клиентами. Тезисно и в очень простом варианте задачу можно сформулировать пунктами:
Как видно из заголовка мы остановились на связке Laravel + Nuxt.js. Нам было важно построить независимый отдельный API чтобы его можно было использовать на фронтэнде, в мобильном приложении и в виджете записи на сайте или ещё где-то. Основывать выбор инструментов для разработки можно на следующих принципах:
Истина находится где-то на стыке всех четырёх утверждений, пропущенных через призму доступных ресурсов и обозначенных ограничений. Если сильно увлечься новыми крутыми фреймворками, не имея запаса по срокам и бюджету, легко можно выйти за рамки взаимовыгодного сотрудничества с заказчиком. Будет сложно доказать что, к примеру, из-за использования сыроватого но классного инструмента пришлось немного отвлечься на рефакторинг и нарушить согласованный дедлайн. Ну и дальше сами знаете, взаимообмен любезностями и подпорченные отношения с бизнесом. А хотели как лучше.
Поэтому очень важна согласованная тонкая настройка по каждому из пунктов.
Вернёмся к нашему проекту по созданию CRM. Взвесив все за и против, мы выбрали:
Структурно у нас всё просто:
Фронтэнд, виджет и приложения будут общаться с API посредством GET / PUT / POST / DELETE запросов, получая и отправляя данные.
По железу у нас есть виртуальный выделенный сервер с 4гб оперативной памяти. На нём установлено веб-окружение от 1С Битрикс. Создаём новый сайт api._DOMAIN_.com по модели kernel для нашего API на Laravel. Все необходимые PHP модули уже есть, ничего дополнительно ставить не придётся. Единственный нюанс с веб окружением от Битрикса состоит в том, что в корневой директории сайта должна находиться папка bitrix, иначе в консольном меню данный сайт отобразится с ошибкой. Но это не проблема, оставляем папку пустой и едем дальше.
С нашим фронтендом ситуация чуть сложнее и одновременно проще. Для создания crm._DOMAIN_.com будем использовать только Nginx, где настроим проксирование на запущенное node js приложение, предварительно скомпилированное и задеплоеное в продакшн.
Конфиг Nginx достаточно простой:
server {
listen 443 http2;
server_name crm._DOMAIN_.com www.crm._DOMAIN_.com;
access_log /var/log/nginx/crm_access.log main;
error_log /var/log/nginx/crm_error.log warn;
server_name_in_redirect off;
include bx/conf/ssl_options.conf;
ssl_certificate /_PATH_TO_CERT_/cert.pem;
ssl_certificate_key /_PATH_TO_CERT_/privkey.pem;
ssl_trusted_certificate /_PATH_TO_CERT_/fullchain.pem;
proxy_set_header X-Forwarded-Proto https;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Вообще мы любим фреймворк CakePHP, выпустили на нём в продакшн немало проектов, от простых админок до нагруженных сайтов и веб-приложений. Прошли весь путь версий, от 1 до 4 (на момент написания статьи). И очень довольны. Фреймворк развивается, у него очень хорошая концепция, близкая нам по духу :) и достаточно «чистые» и понятные conventions.
Но не Кейком единым. Пришло время попробовать в продакшне фреймворк из мэйнстримовых. И знаете что? Laravel по-своему хорош, есть приятные фичи, хорошо развита сущностная составляющая, ORM весьма и весьма удобен.
Прежде чем приступить к непосредственному написанию API, следует спроектировать структуру БД и endpoints. В Laravel есть простой и удобный механизм миграций, с помощью которого можно создавать и модифицировать таблицы в БД. Это удобно, поскольку вы централизованно храните структуру БД и историю её изменений и можете «развернуть» проект в любой момент с чистого листа. Например, файл миграции для создания таблицы для хранения записей клиентов:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateRecordsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('records', function (Blueprint $table) {
$table->bigIncrements('id');
$table->integer('org_id');
$table->integer('client_id');
$table->integer('user_id');
$table->dateTime('date');
$table->decimal('summ', 9, 3);
$table->decimal('summ_minus', 9, 3);
$table->string('pay_type');
$table->boolean('payed')->default(false);
$table->timestamps();
$table->index('org_id');
$table->index('client_id');
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('records');
}
}
Файлы для указания правил роутинга находятся в папке /routes проекта. Здесь мы оставили пустым web.php, поскольку используем только API правила. А живут они в файле api.php. Роуты удобно группируются. Например, для работы с записями клиентов на процедуры нам понадобится следующая группа правил:
// Records
Route::group(['prefix' => 'records'], function () {
Route::get('/{org}/{from}/{to}', 'RecordController@index')->middleware('auth:api');
Route::post('/make/{org}', 'RecordController@make')->middleware('auth:api');
Route::post('/edit', 'RecordController@edit')->middleware('auth:api');
Route::get('/clients', 'RecordController@clients')->middleware('auth:api');
Route::post('/add/{org}', 'RecordController@add')->middleware('auth:api');
Route::delete('/{record}', 'RecordController@destroy')->middleware('auth:api');
});
Для доступа к прописанным эндпоинтам мы используем middleware, где реализуем логику по проверке доступа. Авторизация пользователей происходит по стандарту JWT (Json Web Token). При работе с API самое то.
Когда приходит запрос к API, проверяется наличие и актуальность токена доступа. Далее, в зависимости от роли пользователя и endpoint’а, происходит уточнение его роли и возможность доступа к запрашиваемому действию при помощи механизма policies. Сам файл /app/Policies/UserPolicy.php очень простой:
<?php
namespace App\Policies;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
/**
* Create a new policy instance.
*
* @return void
*/
public function __construct()
{
//
}
public function index(User $user)
{
return $user->isAdmin();
}
public function owner(User $user)
{
return $user->isOwner();
}
public function worker(User $user)
{
return $user->isWorker();
}
}
Здесь происходит проверка на соответствие трём ролям: супер-админ, собственник и сотрудник. Функции-обёртки обращаются к соответствующим методам класса User.
Не можем не поделиться тем, насколько красивый ORM в Laravel, и как здорово реализуются связи «Многие-ко-многим» с дополнительной информацией. Например, когда собственник салона создаёт сотрудника-мастера, у него есть возможность указать процент, который получит мастер с определённой услуги. Причём один и тот же человек может работать в разных салонах и оказывать разные услуги с разным процентом.
Данные хранятся в табличке serv_user с простой структурой:
id
serv_id (id услуги)
user_id (id пользователя)
percent (процент)
А связь «Мастер» — «Салон» формируется путём прикрепления услуги к определенному салону.
В итоге, чтобы получить список услуг мастера с процентами, мы используем красивую ORM конструкцию, обёрнутую в функцию, которая регламентирует связь «Пользователь» — «Услуга»:
public function servs()
{
return $this->belongsToMany('App\Serv', 'serv_user')->withPivot('percent');
}
При добавлении или обновлении сущностей через API-запросы, удобно использовать кастомизированные Request и Recource. Например, функция добавления услуги:
public function add(ServAddRequest $request)
{
$userInstance = new User();
$this->authorize('owner', $userInstance);
$serv = new Serv;
$serv->name = $request->name;
$serv->servtype_id = $request->servtype_id;
$serv->price = $request->price;
$serv->price_first = $request->price_first;
$serv->price_last = $request->price_last;
$serv->procedures = $request->procedures;
$serv->time = $request->time;
$serv->can_parallel = $request->can_parallel;
$serv->desc = $request->desc;
$serv->save();
if (!empty($request->orgs)) {
$orgsIdsToAttach = [];
foreach ($request->orgs as $tmp) {
$orgsIdsToAttach[] = $tmp['id'];
}
if (!empty($orgsIdsToAttach)) {
$serv->orgs()->attach($orgsIdsToAttach);
}
}
return new ServResource($serv);
}
ServAddRequest при этом выглядит так:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ServAddRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => 'required',
'price' => 'required',
'time' => 'required',
'servtype_id' => 'required',
'orgs' => 'required',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array
*/
public function messages()
{
return [
'name.required' => 'Введите название типа услуги',
'price.required' => 'Введите цену услуги',
'time.required' => 'Введите длительность услуги',
'servtype_id.required' => 'Выберите тип услуги',
'orgs.required' => 'Укажите организации, которые могут использовать тип услуги',
];
}
}
Особенно здорово писать правила валидации и сообщения об ошибках.
В целом очень здорово и удобно разрабатывать API на Laravel. Иии.. Переходим посмотреть что там у нас на фронтэнде с Nuxt.js.
Nuxt.js — это фреймворк на Vue.js. Из коробки вы получаете продвинутый набор утилит, роутинг, модульность и набор best practices для построения вашего приложения. Подробности на официальном сайте https://nuxtjs.org
Мы создали проект Nuxt.js в режиме SPA. Этот режим лучше всего подходит для веб-сервисов. Если же вы разрабатываете сайт, где требуется быть СЕО-фрэндли, нужно воспользоваться режимом Server Side Rendering. Тогда поисковые роботы без проблем смогут проиндексировать всё содержимое.
Конфигурация проекта находится в файле nuxt.config.js в корне приложения. Здесь указывается набор параметров как для приложения целиком, так и для подключаемых модулей. Для построения интерфейса мы решили использовать старый добрый проверенный Twitter Bootstrap. Красивый шрифт берём в Google Fonts, набор иконок FontAwesome. Всё это подгружаем из CDN, указывая в конфигурационном файле в секции head в виде:
script: [
…
{
src: 'https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js',
type: 'text/javascript'
},
{
src: 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js',
type: 'text/javascript'
},
…
]
При переходе между роутами Nuxt.js уже из коробки снабжён красивым прогресс-баром. Меняем стандартный цвет на свой:
loading: {
color: '#9858bf',
height: '5px'
},
Подключаем необходимые модули:
modules: [
'@nuxtjs/axios', // делать запросы к API с axios удобно
'@nuxtjs/auth', // реализуем авторизацию JWT
'cookie-universal-nuxt', // работа с печеньками
['@nuxtjs/moment', {locales: ['ru'], defaultLocale: 'ru'}], // работа с датой / временем
'nuxt-vue-multiselect', // удобный мультиселект для связей многие-ко-многим
'@nuxtjs/toast', // а это in-app пуши
],
В качестве указания настроек модуля в конфигурационном файле:
axios: {
baseURL: 'https://api._DOMAIN_.ru/api'
},
Здесь мы говорим Axios о том, чтобы он добавил нам базовый урл до API, чтобы каждый раз не писать адрес полностью. Мелочь, а приятно.
И настраиваем параметры авторизации, указывая нужные endpoints:
auth: {
strategies: {
local: {
endpoints: {
login: {
url: 'login',
method: 'post',
propertyName: "meta.token"
},
user: {
url: 'user',
method: 'get',
propertyName: 'data'
},
logout: {
url: 'logout',
method: 'post'
}
}
}
}
},
На этом с конфигурацией всё, двигаемся дальше.
Как происходит авторизация JWT на стороне нашего фронтенда? Когда пользователь попадает на страницу, которая предполагает авторизацию, мы просто подключаем middleware под названием auth из одноименного модуля:
…
export default {
middleware: [
'auth'
],
data() {
…
Если же нам требуется дополнительная проверка роли пользователя, оформляем это отдельным middleware в /middleware/owner.js:
export default function (context) {
let user = context.store.getters["user"];
if (user && !user.is_owner) {
return context.redirect('/');
}
}
и в самой странице, где необходимо такая проверка, указываем после middleware auth наш ‘owner’:
…
export default {
middleware: [
'auth’,
‘owner’
],
data() {
…
Для обработки ошибок валидации данных будем использовать серверное решение, никаких клиентских проверок. Чтобы всё было централизованно и монолитно. Происходит это следующим образом: если API получает некорректные данные, то возвращаемый ответ будет содержать статус ошибки и ассоциативный массив самих ошибок, где ключи это поля, а значения это сами сообщения. На стороне Nuxt.js мы будем хранить эти ошибки в store, и чтобы каждый раз не заниматься складыванием / очисткой данных об ошибках непосредственно на странице с формой, сделаем один middleware для этой работы:
router: {
middleware: ['clearValidationErrors']
},
И сам код middleware /middleware/clearValidationErrors.js:
export default function ({store}) {
store.dispatch('validation/clearErrors');
}
который в свою очередь цепляет store в /store/validation.js:
export const state = () => ({
errors: {}
});
// getters
export const getters = {
errors(state) {
return state.errors;
}
};
// mutations
export const mutations = {
SET_VALIDATION_ERRORS(state, errors) {
state.errors = errors;
}
};
// actions
export const actions = {
setErrors({commit}, errors) {
commit("SET_VALIDATION_ERRORS", errors);
},
clearErrors({commit}) {
commit("SET_VALIDATION_ERRORS", {});
},
};
Ошибки заносятся в store каждый раз, когда приходят от API в запросе со статусом 422. Это легко и удобно сделать при помощи плагина, который дополнит стандартный Axios небольшим хуком:
export default function ({$axios, store}) {
$axios.onError(error => {
if (error.response.status === 422) {
store.dispatch('validation/setErrors', error.response.data.errors)
}
});
$axios.onRequest(() => {
store.dispatch('validation/clearErrors');
});
}
Для наглядности приводим листинг страницы добавления услуги собственником салона /pages/servs/add/index.vue:
<template lang="pug">
.container-fluid
.row.mb-4
.col
h1 Добавление услуги
.row.mb-4
.col-lg-6
form.p-3.bg-purple-light.border-0(@submit.prevent="submit")
.form-group
label Тип услуги
multiselect(
v-model="form.servtypes"
:options="additional.servtypes"
placeholder="Выберите"
label="name"
track-by="id"
selectedLabel="Выбран"
deselectLabel="убрать"
selectLabel=""
@input="opt => form.servtype_id = opt.id"
)
small.form-text.text-danger(v-if="errors.servtype_id") {{errors.servtype_id[0]}}
.form-group
label Может использоваться в организациях
multiselect(
v-model="form.orgs"
:options="additional.orgs"
:multiple="true"
placeholder="Выберите"
label="name"
track-by="id"
selectedLabel="Выбран"
deselectLabel="убрать"
selectLabel=""
)
small.form-text.text-danger(v-if="errors.orgs") {{errors.orgs[0]}}
.form-group
label Название
input.form-control.form-control-app(v-model.trim="form.name" type="text" autofocus)
small.form-text.text-danger(v-if="errors.name") {{errors.name[0]}}
.form-group
label Стоимость
input.form-control.form-control-app(v-model.trim="form.price" type="number")
small.form-text.text-danger(v-if="errors.price") {{errors.price[0]}}
.form-group
label Стоимость первой процедуры (если это комплекс)
input.form-control.form-control-app(v-model.trim="form.price_first" type="number")
small.form-text.text-danger(v-if="errors.price_first") {{errors.price_first[0]}}
.form-group
label Стоимость последней процедуры (если это комплекс)
input.form-control.form-control-app(v-model.trim="form.price_last" type="number")
small.form-text.text-danger(v-if="errors.price_last") {{errors.price_last[0]}}
.form-group
label Количество процедур (если это комплекс)
input.form-control.form-control-app(v-model.trim="form.procedures" type="text")
small.form-text.text-danger(v-if="errors.procedures") {{errors.procedures[0]}}
.form-group
label Длительность (минут)
input.form-control.form-control-app(v-model.trim="form.time" type="text")
small.form-text.text-danger(v-if="errors.time") {{errors.time[0]}}
.form-group.form-check
input.form-check-input(v-model="form.can_parallel" type="checkbox" id="userCanParallel")
label(for="userCanParallel") Можно записывать параллельно
.form-group
label Описание
textarea.form-control.form-control-app(v-model.trim="form.desc" type="text" autofocus)
small.form-text.text-danger(v-if="errors.desc") {{errors.desc[0]}}
.d-flex.justify-content-between.align-items-center
nuxt-link.btn.btn-secondary.btn-sm(to="/servs") к списку
button.btn.btn-purple(type="submit") Добавить
</template>
<script>
export default {
middleware: [
'auth',
'owner'
],
data() {
return {
form: {
name: '',
servtype_id: '',
price: '',
time: '',
can_parallel: false,
desc: '',
orgs: []
},
additional: {
orgs: []
}
}
},
async asyncData({$axios, params}) {
const {data} = await $axios.$get(`/servs/additional_data`);
return data;
},
methods: {
async submit() {
try {
await this.$axios.$post('servs/add', this.form);
// redirect
this.$router.push({
'path': this.$route.query.redirect || "/servs"
});
} catch (e) {
console.log(e);
}
}
}
}
</script>
Здесь код
small.form-text.text-danger(v-if="errors.name") {{errors.name[0]}}
выводит текст ошибки для поля «Название», если он присутствует в store
В режиме разработки Nuxt.js приложение запускается на локальной машине веб-мастера. На сервер же выгружается продакшн версия, предварительно собранная командой npm run build
Для автоматизации данного процесса мы написали deployment скрипт. Единственное условие для его работы: на сервер должен быть добавлен ssh-ключ.
const consola = require('consola');
const config = require('./config');
// Проверяем, есть ли данные для подключения
if (!config.deploy) {
consola.error('Создайте данные для подключения в config.js');
return false;
}
consola.start('Начинаю deploy...');
const exe = require('exe');
const appIngoreFiles = ['.git', '.gitignore', '.prettierrc'];
const connect = `${config.deploy.user}@${config.deploy.ip}`;
const projectFolder = config.projectName.toLowerCase();
const appExclude = appIngoreFiles
.map(file => {
return `--exclude="${file}"`
})
.join(' ');
exe(`rsync -avm ${config.deploy.port ? `--rsh="ssh -p${config.deploy.port}" ` : ''}--delete ${appExclude} . ${connect}:/home/bitrix/node/${projectFolder}/`);
console.log(`***`);
consola.success('--- Файлы скопированы ---');
console.log(`***`);
consola.start('Перезапускаю проект');
exe(`ssh ${connect} ${config.deploy.port ? `-p ${config.deploy.port}` : ''} 'cd /home/bitrix/node/${projectFolder}/ && pm2 reload apps.config.js'`);
console.log(`***`);
const date = new Date().toISOString();
consola.ready(`--- Проект перезапущен: ${date} ---`);
В процессе работы над проектом мы получили огромное удовольствие от написания кода. Laravel прекрасно подходит для создания API с JWT авторизацией. Был только единственный нюанс с трудоёмкостью и непонятностью настройки этого самого JWT, в том числе и из-за несовместимости версий Laravel, которую использовали мы и которая была использована при составлении мануала разработчиками JWT плагина. Но это решаемо.
Nuxt.js — это настоящий подарок для разработчика. Прекрасный инструмент для того, быстро, просто и понятно реализовывать сложные интерфейсы и нестандартную логику.