Прошло 5 месяцев с последней публикации на тему "разработка CRM для автошколы". Данная статья будет заключительной, и она поставит точку в данном кейсе. Прислушавшись к опытным специалистам и получив оценку все ситуации вокруг данной задачи - я решил: "хватит писать все своими руками, на дворе 2025 год. пора изучать что-то новое и расширять свои познания". Пишем все на Laravel! И поставил для себя цель - что данный проект должен быть именно на нем. Это будет первый мой самостоятельный проект, которой я доведу до состояния продакта.
Я изучил основную концепцию MVC архитектуры веб-приложений. Почитал документацию о фреймворке. Посмотрел ряд видеоуроков, в которых не указаны никакие тонкости в разработке. Ко всему этому, один человек показал мне основы работы с Laravel, познакомил с базовыми пакетами, такими как Laravel Breeze, Permissions, Dto. Спустя несколько месяцев - я освоил базу, и решил начать сначала разработку той самой CRM.
Модели
Я понимал всю бизнес-логику организации, по этому на данном этапе не было никаких проблем. В действующей CRM, вся бизнес логика завязана на "Ученике". Появилась необходимость отойти от этой концепции, в связи с появлением учеников, которые обучаются повторно на альтернативные категории. Необходимо разделить данную модель на "Ученик" -> "Договор".
## Models/Student
/**
* Договор.
*
* @return HasMany
*/
public function agreements(): HasMany
{
return $this->hasMany(StudentAgreement::class, 'student_id', 'id');
}
## Models/StudentAgreement
/**
* Ученик.
*
* @return BelongsTo
*/
public function student(): BelongsTo
{
return $this->belongsTo(Student::class, 'student_id', 'id');
}
Модель "Ученик"(Student) содержит в себе статические поля, которые не меняются при заключении новых договоров(ФИО, телефон, дата рождения, паспорт и т.д.). Модель "Договор"(StudentAgreement) - содержит в себе служебные поля, которые могут меняться в зависимости от категории, на которую обучается ученик:
Стоимость обучения по договору
Кол-во часов вождения по договору
Первоначальный взнос
Учебная группа
Модели "договор" имеет служебное поле "overprice" - что устанавливает итоговую стоимость практического занятия. Если overprice = 0, то оплату по сверх договору мы считаем в случае, если у договора количество практических занятий больше, чем указано (hours_total). Если overprice = 1, то оплата практического занятия всегда считается как сверх договор(вне зависимости от количества практических занятий). И если overprice = 2, по аналогии - всегда стоимость по договору.
const OVERPRICE_DEFAULT = 0; // По умолчанию.
const OVERPRICE_YES = 1; // Стоимость всегда сверх урочная.
const OVERPRICE_NO = 2; // Стоимость всегда по договору.
Соответственно, все остальные модели теперь взаимодействуют только с договором, а не с учеником. Не менее важное поле в договоре - school_payment_first, которая регулирует, будет ли в приоритете тариф филиала, в котором проходит практическое занятие или нет. Договору можно установить фиксированную стоимость занятия или закрепить за ним определенный тарифный план.
Модель "Group" - содержит в себе период обучения договоров, их категории и филиалы обучения. Логика приложения требует разделения практически всех моделей на филиалы. А филиалы - собираются в 1 школу.
## Models/Branch
/**
* Школа.
*
* @return BelongsTo
*/
public function school(): BelongsTo
{
return $this->belongsTo(School::class, 'school_id', 'id');
}
## Models/School
/**
* Филиалы.
*
* @return HasMany
*/
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'school_id', 'id');
}
Исходя из того, какому филиалу принадлежит модель StudetAgreement - происходит одно из условий при расчете стоимости практического занятия, а так же - будет ли предоставлена возможность ученику записаться на данное занятие.
До сих пор на карандаше стоит вопрос о расчете баланса договора. Изначально планировал добавить поле "balance" в модели "Договор" и вести учет в нем(это могло бы сократить количество запросов к БД при выборках, ускорив загрузку списков), но в итоге отказался от этого и решил каждый раз его пересчитывать из сумм в других моделях, что тратит больше времени на запросы, чем предыдущий способ. По мне - лучше каждый раз пересчитывать, чем получить рассинхрон между балансом и суммой финансовых операций.
О финансах. Для этого существует модель "Finance", которая хранит в себе все транзакции договоров(списания/поступления).
protected $fillable = [
'type', // Тип операции.
'agreement_id', // ID Договора.
'method_id', // ID Типа операции.
'date', // Дата операции.
'examen_id', // Если оплата была за экзамен.
'sum', // Сумма операции.
'responsible_id', // ID ответственного.
'is_first_payment' // Первоначальный взнос?
];
const WRITE_OFF_TYPE = 1; // Списание.
const DEPOSIT_TYPE = 2; // Пополнение.
const EXAM_FEE_TYPE = 3; // Оплата экзамена.
Тут должно быть все понятно: Сумма Finance::WRITE_OFF_TYPE - будет сумма списаний. Сумма Finance::DEPOSIT_TYPE - поступления. Оплата экзамена в расчетах не учитывается - это более информативное поле, о том что ученик оплатил экзамен.
Вернемся опять к балансу. У нас есть поступления и списания из модели "Финансы", но нам необходимо так же учесть списания за практические занятия. На этапе планирования была задумка - реализовать списания за уроки вождения в модели "Финансы" (ученик записался на занятие, создалась запись в финансах с его стоимостью Finance::WRITE_OFF_TYPE) - но не стал. Решил не менять логику расчета баланса от текущей CRM, а оставил так же. Суммы списаний за занятия вождения - хранятся в модели с занятиями "ScheduleEvent". Решил - что проще контролировать 1 значение в модели, чем контролировать 2 значения в 2-х разных моделях. Можно было подумать о связке ключей - но, не. Пока пусть будет так. Исходя из этого, баланс рассчитывается так:
/**
* Баланс.
*
* @return int
*/
public function getBalanceAttribute(): int
{
$balance = 0;
foreach ($this->finances()->get() as $fin){
if ($fin->type == FinanceMethod::DEPOSIT_TYPE) $balance += $fin->sum;
if ($fin->type == FinanceMethod::WRITE_OFF_TYPE) $balance -= $fin->sum;
}
$balance -= $this->schedule()->sum('sum');
return $balance;
}
Практические занятия собираются из 2 моделей: ScheduleResource и ScheduleEvent. Ресурсы содержат в себе информацию, которая объединяет в себе все события, закрепленные за собой.
Логика приложения
О контроллерах и маршрутах говорить не интересно, поговорим сразу о логике и расчетах. Рассмотрим всю логику от добавления нового ученика к выходу на экзамен.
К нам приходит новый ученик, который хочет пройти обучение на категорию "B". Сотрудник вносит его персональные данные: ФИО, номер телефона, дату рождения и т.д. Далее - мы оформляем новый договор: сотрудник, исходя из заявления, написанного учеником - заполняет данные в модели договора. Ученику предоставляется доступ к его личному кабинету и начинается процесс обучения. Раз в неделю сотрудник автошколы заполняет графики вождения: выставляет инструкторов, транспорт и маршруты вождения. В определенное время происходит открытие этих записей для всех учеников. Необходимо правильно предоставить, доступные для записи, занятия. На данном этапе есть зависимости и критерии, по которым будет она отображаться или нет.
У модели StudentAgreement есть связь с Group, которая на прямую связана с филиалом(Branch). Мы знаем о принадлежности договора к филиалу - значит выборка достпуных ScheduleResource должна формироваться из одинаковых branch_id группы договора и ресурса занятий. У ресурсов есть критерий - "only_overprice", который может регулировать доступность записей только сверх-урочникам, только по договору или по умолчанию. И все эти условия доступности ScheduleResource могут игнорироваться в случае, если за договором закреплен конкретный автомобиль в модели AgreementCar. Если за договором закреплен хотя бы один авто - ему будут доступны записи только с этим автомобилем - другие не будут отображаться.
В процессе записи договора на практическое занятие идет расчет его стоимости. На нее влияют многие условия по мимо overprice.
Первый критерий расчета стоимость - филиал. Сравнивается принадлежность филиала договора к филиалу, в котором проводится практическое занятие. Может быть игнорироваться - если у договора если у договора отключен school_payment_first. Следующий критерий - фиксированная стоимость занятия(если она есть и предыдущее условие игнорируется - устанавливается). По аналогии - тариф(если зафиксирован - устанавливается) и в конце - если все условия игнорирутеся - стоимость устанавливается согласно тарифу, который установлен в филиале.
/**
* Проверим OVERPRICE.
*/
$overprice = false;
if($agreement->overprice > StudentAgreement::OVERPRICE_DEFAULT){
if($agreement->overprice == StudentAgreement::OVERPRICE_YES) $overprice = 1;
} else {
if($agreement->hours_now + 1 > $agreement->hours_total) $overprice = 1;
}
/**
* Проверяем занятие в школе ученика или нет
*/
if($agreement->branch->school_id != $school_id) $agreement_in_his_school = false;
/**
* Проверка, учитывается ли школа в расчете стоимости
*/
$school_first = 1;
if(!$agreement->school_payment_first) $school_first = false;
/**
* Если школа приоритетнее И ШКОЛЫ ОТЛИЧАЮТСЯ - вернем стоимость занятия за вождения, как в школе (Если разные школы)
*/
if($school_first AND !$agreement_in_his_school){
$payment = app(\App\Home\PaymentRate\Actions\Get::class)->getSchoolPayment($school_id, $category_id, $date);
if($payment){
if($overprice) return $payment->summ_overprice;
return $payment->summ_price;
}
}
/**
* Если зафиксирован тариф за учеником
*/
if($agreement->payment_rate_id){
$payment = app(\App\Home\PaymentRate\Actions\Get::class)->getById($agreement->payment_rate_id);
if($payment){
if($overprice) return $payment->summ_overprice;
return $payment->summ_price;
}
}
/**
* Если зафиксирована стоимость вождения
*/
if($agreement->summ_price > 0 AND $agreement->summ_overprice > 0){
if($overprice) return $agreement->summ_overprice;
return $agreement->summ_price;
}
/**
* Берем стоимость как в школе ученика
*/
$payment = app(\App\Home\PaymentRate\Actions\Get::class)->getSchoolPayment($agreement->branch->school_id, $category_id, $date);
if($payment){
if($overprice) return $payment->summ_overprice;
return $payment->summ_price;
}
Договор проходит обучение до полной выплаты стоимости договора и завершения занятий вождения по договору. Далее - договору назначается экзамен. Никакой замысловатой логики пока тут нет - StudentAgreement -> ExamObject -> Exam . В дальнейшем из перспектив - будет разработка планирования договоров на экзамены. Необходимо производить расчет примерной даты экзамена для договоров, которые повторно пересдают.
Фронт
По фронту не стал продолжать работу над своим дизайном - установил шаблон с bootstrap и не тратил время на построение стилей в css. Все работает на html + jquery. Календарь с графиком практических занятий решил не изобретать самостоятельно, а подключить EventCalendar - аналог FullCalendar (https://github.com/vkurko/calendar).Таблицы где не обходимо делал с фильтрами, используя jquery и ajax.

На данный момент - сотрудники активно тестируют новуб crm и все готовится выйти в продакт. Первый объемный проект на фреймворке Laravel - еще есть что изучить более детально.