CRM для Автошколы. Заключение

от автора

Прошло 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 — еще есть что изучить более детально.


ссылка на оригинал статьи https://habr.com/ru/articles/925856/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *