🎯 Введение и требования
Создаём CRM-систему для автосервиса, специализирующегося на СВАП моторов, стандартных ремонтах и оформлении документов на переоборудование. Система должна быть простой в разработке, быстрой и недорогой в поддержке.
Почему Yii2 + Vue.js?
Быстрая разработка с Gii, встроенный RBAC, ActiveRecord, отличная документация на русском
Реактивный UI, компонентный подход, легко интегрируется, быстрее разработка форм и таблиц
PHP-хостинг от 300₽/мес, много разработчиков, простая поддержка
Легко добавить функционал, REST API для мобильных приложений
Функциональные требования
- Клиенты: база клиентов, история обращений, контакты
- Автомобили: карточки авто, история работ, VIN, госномер
- Заказ-наряды: создание, статусы, работы, запчасти, оплаты
- СВАП проекты: этапы работ, фото-отчёты, комплектующие
- Документы: заключения для ГИБДД, акты, договоры
- Склад: запчасти, поступления, списания, остатки
- Отчёты: выручка, загрузка, популярные работы
Технические требования
# Минимальные требования PHP >= 8.1 MySQL >= 8.0 или MariaDB >= 10.6 Composer Node.js >= 18 (для сборки фронтенда) # PHP расширения php-mbstring php-intl php-gd php-pdo_mysql php-json # Рекомендуемый хостинг VPS от 1GB RAM / 1 CPU или shared-хостинг с поддержкой Composer
🏛️ Архитектура системы
Используем монолитную архитектуру с разделением на Backend API и Frontend SPA. Это оптимально для небольшого автосервиса: просто в разработке, легко деплоить.
Структура проекта
🗄️ Проектирование базы данных
База данных — фундамент CRM. Проектируем с учётом специфики автосервиса: клиенты, автомобили, заказы, СВАП-проекты, документы.
Основные таблицы
👥 Клиенты (clients)
| Поле | Тип | Описание |
|---|---|---|
| id PK | INT UNSIGNED | Идентификатор |
| name | VARCHAR(255) | ФИО клиента |
| phone | VARCHAR(20) | Телефон (уникальный) |
| VARCHAR(255) | Email (опционально) | |
| type | ENUM | individual / company |
| company_name | VARCHAR(255) | Название компании |
| inn | VARCHAR(12) | ИНН (для юрлиц) |
| discount_percent | TINYINT | Персональная скидка % |
| notes | TEXT | Заметки |
| created_at | TIMESTAMP | Дата создания |
🚗 Автомобили (vehicles)
| Поле | Тип | Описание |
|---|---|---|
| id PK | INT UNSIGNED | Идентификатор |
| client_id FK | INT UNSIGNED | Владелец |
| brand | VARCHAR(100) | Марка (Toyota, BMW...) |
| model | VARCHAR(100) | Модель |
| year | SMALLINT | Год выпуска |
| vin | VARCHAR(17) | VIN-код |
| license_plate | VARCHAR(15) | Госномер |
| engine_code | VARCHAR(50) | Код двигателя (оригинал) |
| mileage | INT | Текущий пробег |
| color | VARCHAR(50) | Цвет |
📋 Заказ-наряды (orders)
| Поле | Тип | Описание |
|---|---|---|
| id PK | INT UNSIGNED | Идентификатор |
| number | VARCHAR(20) | Номер заказа (ЗН-2024-0001) |
| client_id FK | INT UNSIGNED | Клиент |
| vehicle_id FK | INT UNSIGNED | Автомобиль |
| type | ENUM | repair / swap / documents |
| status | ENUM | new / in_progress / waiting_parts / completed / cancelled |
| mileage_on_receive | INT | Пробег при приёме |
| description | TEXT | Описание проблемы |
| total_works | DECIMAL(10,2) | Сумма работ |
| total_parts | DECIMAL(10,2) | Сумма запчастей |
| discount | DECIMAL(10,2) | Скидка |
| total | DECIMAL(10,2) | Итого к оплате |
| paid | DECIMAL(10,2) | Оплачено |
| manager_id FK | INT UNSIGNED | Менеджер |
| mechanic_id FK | INT UNSIGNED | Механик |
| created_at | TIMESTAMP | Дата создания |
| completed_at | TIMESTAMP | Дата завершения |
Миграция базы данных
<?php use yii\db\Migration; class m240101_000001_create_clients_table extends Migration { public function safeUp() { $this->createTable('{{%clients}}', [ 'id' => $this->primaryKey()->unsigned(), 'name' => $this->string(255)->notNull(), 'phone' => $this->string(20)->notNull()->unique(), 'email' => $this->string(255), 'type' => $this->string(20)->notNull()->defaultValue('individual'), 'company_name' => $this->string(255), 'inn' => $this->string(12), 'discount_percent' => $this->tinyInteger()->unsigned()->defaultValue(0), 'notes' => $this->text(), 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'), 'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), ]); $this->createIndex('idx-clients-phone', '{{%clients}}', 'phone'); $this->createIndex('idx-clients-type', '{{%clients}}', 'type'); } public function safeDown() { $this->dropTable('{{%clients}}'); } }
<?php use yii\db\Migration; class m240101_000002_create_vehicles_table extends Migration { public function safeUp() { $this->createTable('{{%vehicles}}', [ 'id' => $this->primaryKey()->unsigned(), 'client_id' => $this->integer()->unsigned()->notNull(), 'brand' => $this->string(100)->notNull(), 'model' => $this->string(100)->notNull(), 'year' => $this->smallInteger(), 'vin' => $this->string(17), 'license_plate' => $this->string(15), 'engine_code' => $this->string(50), 'engine_volume' => $this->decimal(3, 1), 'fuel_type' => $this->string(20)->defaultValue('petrol'), 'mileage' => $this->integer()->unsigned(), 'color' => $this->string(50), 'notes' => $this->text(), 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'), 'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), ]); $this->addForeignKey( 'fk-vehicles-client_id', '{{%vehicles}}', 'client_id', '{{%clients}}', 'id', 'CASCADE', 'CASCADE' ); $this->createIndex('idx-vehicles-vin', '{{%vehicles}}', 'vin'); $this->createIndex('idx-vehicles-license_plate', '{{%vehicles}}', 'license_plate'); } public function safeDown() { $this->dropTable('{{%vehicles}}'); } }
<?php use yii\db\Migration; class m240101_000003_create_orders_table extends Migration { public function safeUp() { $this->createTable('{{%orders}}', [ 'id' => $this->primaryKey()->unsigned(), 'number' => $this->string(20)->notNull()->unique(), 'client_id' => $this->integer()->unsigned()->notNull(), 'vehicle_id' => $this->integer()->unsigned()->notNull(), 'type' => $this->string(20)->notNull()->defaultValue('repair'), 'status' => $this->string(20)->notNull()->defaultValue('new'), 'mileage_on_receive' => $this->integer()->unsigned(), 'description' => $this->text(), 'diagnosis' => $this->text(), 'total_works' => $this->decimal(10, 2)->defaultValue(0), 'total_parts' => $this->decimal(10, 2)->defaultValue(0), 'discount' => $this->decimal(10, 2)->defaultValue(0), 'total' => $this->decimal(10, 2)->defaultValue(0), 'paid' => $this->decimal(10, 2)->defaultValue(0), 'manager_id' => $this->integer()->unsigned(), 'mechanic_id' => $this->integer()->unsigned(), 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'), 'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), 'completed_at' => $this->timestamp(), ]); // Foreign Keys $this->addForeignKey('fk-orders-client', '{{%orders}}', 'client_id', '{{%clients}}', 'id'); $this->addForeignKey('fk-orders-vehicle', '{{%orders}}', 'vehicle_id', '{{%vehicles}}', 'id'); // Indexes $this->createIndex('idx-orders-status', '{{%orders}}', 'status'); $this->createIndex('idx-orders-type', '{{%orders}}', 'type'); $this->createIndex('idx-orders-created', '{{%orders}}', 'created_at'); } public function safeDown() { $this->dropTable('{{%orders}}'); } }
Дополнительные таблицы
<?php // Работы в заказ-наряде $this->createTable('{{%order_works}}', [ 'id' => $this->primaryKey()->unsigned(), 'order_id' => $this->integer()->unsigned()->notNull(), 'work_id' => $this->integer()->unsigned(), // из справочника или NULL 'name' => $this->string(255)->notNull(), 'quantity' => $this->decimal(8, 2)->defaultValue(1), 'price' => $this->decimal(10, 2)->notNull(), 'total' => $this->decimal(10, 2)->notNull(), 'mechanic_id' => $this->integer()->unsigned(), 'status' => $this->string(20)->defaultValue('pending'), 'completed_at' => $this->timestamp(), ]); // Запчасти в заказ-наряде $this->createTable('{{%order_parts}}', [ 'id' => $this->primaryKey()->unsigned(), 'order_id' => $this->integer()->unsigned()->notNull(), 'part_id' => $this->integer()->unsigned(), // со склада или NULL 'name' => $this->string(255)->notNull(), 'article' => $this->string(100), // артикул 'quantity' => $this->decimal(8, 2)->notNull(), 'price_buy' => $this->decimal(10, 2), // закупочная 'price_sell' => $this->decimal(10, 2)->notNull(), 'total' => $this->decimal(10, 2)->notNull(), 'source' => $this->string(20)->defaultValue('stock'), // stock / client / order ]); // СВАП проекты (расширение заказа) $this->createTable('{{%swap_projects}}', [ 'id' => $this->primaryKey()->unsigned(), 'order_id' => $this->integer()->unsigned()->notNull()->unique(), 'donor_engine_code' => $this->string(50)->notNull(), // мотор-донор 'donor_engine_mileage' => $this->integer()->unsigned(), 'donor_info' => $this->text(), // откуда мотор 'ecu_type' => $this->string(100), // ЭБУ 'wiring_type' => $this->string(100), // проводка 'gearbox_type' => $this->string(100), // КПП 'additional_mods' => $this->text(), // доп. модификации 'estimated_hours' => $this->integer(), // оценка трудозатрат 'actual_hours' => $this->integer(), // фактические ]); // Этапы СВАП проекта $this->createTable('{{%swap_stages}}', [ 'id' => $this->primaryKey()->unsigned(), 'swap_project_id' => $this->integer()->unsigned()->notNull(), 'name' => $this->string(255)->notNull(), 'sort_order' => $this->integer()->defaultValue(0), 'status' => $this->string(20)->defaultValue('pending'), 'notes' => $this->text(), 'completed_at' => $this->timestamp(), ]);
📄 Таблица документов на переоборудование
<?php // Документы на переоборудование $this->createTable('{{%documents}}', [ 'id' => $this->primaryKey()->unsigned(), 'order_id' => $this->integer()->unsigned()->notNull(), 'type' => $this->string(50)->notNull(), // Типы: preliminary_expertise - предварительная экспертиза // final_expertise - заключение // photo_fixation - фотофиксация // application_gibdd - заявление в ГИБДД // protocol - протокол техосмотра // certificate - свидетельство 'number' => $this->string(50), 'date' => $this->date(), 'status' => $this->string(20)->defaultValue('draft'), // draft / in_progress / completed / sent / approved / rejected 'file_path' => $this->string(500), 'notes' => $this->text(), 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'), 'created_by' => $this->integer()->unsigned(), ]); // Файлы (фото, сканы, документы) $this->createTable('{{%files}}', [ 'id' => $this->primaryKey()->unsigned(), 'entity_type' => $this->string(50)->notNull(), // order, vehicle, document, swap_stage 'entity_id' => $this->integer()->unsigned()->notNull(), 'category' => $this->string(50), // photo_before, photo_after, scan, etc 'original_name' => $this->string(255)->notNull(), 'path' => $this->string(500)->notNull(), 'size' => $this->integer()->unsigned(), 'mime_type' => $this->string(100), 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'), 'created_by' => $this->integer()->unsigned(), ]);
🐘 Backend на Yii2
Создаём backend с использованием Yii2 Advanced Template. Это даст нам разделение на backend (API) и frontend (если понадобится серверный рендеринг).
Установка проекта
# Создаём проект composer create-project --prefer-dist yiisoft/yii2-app-advanced autoservice-crm # Переходим в папку cd autoservice-crm # Инициализируем (выбираем Development) php init # Настраиваем подключение к БД в common/config/main-local.php # Запускаем миграции php yii migrate # Устанавливаем дополнительные пакеты composer require firebase/php-jwt # JWT авторизация composer require yiisoft/yii2-httpclient # HTTP запросы composer require kartik-v/yii2-mpdf # Генерация PDF
Модель Client
<?php namespace common\models; use yii\db\ActiveRecord; use yii\behaviors\TimestampBehavior; use yii\db\Expression; class Client extends ActiveRecord { const TYPE_INDIVIDUAL = 'individual'; const TYPE_COMPANY = 'company'; public static function tableName(): string { return '{{%clients}}'; } public function behaviors(): array { return [ [ 'class' => TimestampBehavior::class, 'value' => new Expression('NOW()'), ], ]; } public function rules(): array { return [ [['name', 'phone'], 'required'], [['name', 'email', 'company_name'], 'string', 'max' => 255], ['phone', 'string', 'max' => 20], ['phone', 'unique'], ['email', 'email'], ['inn', 'string', 'max' => 12], ['inn', 'match', 'pattern' => '/^\d{10,12}$/'], ['type', 'in', 'range' => [self::TYPE_INDIVIDUAL, self::TYPE_COMPANY]], ['discount_percent', 'integer', 'min' => 0, 'max' => 100], ['notes', 'string'], ]; } public function attributeLabels(): array { return [ 'id' => 'ID', 'name' => 'ФИО / Название', 'phone' => 'Телефон', 'email' => 'Email', 'type' => 'Тип клиента', 'company_name' => 'Название компании', 'inn' => 'ИНН', 'discount_percent' => 'Скидка %', 'notes' => 'Заметки', ]; } // Связи public function getVehicles() { return $this->hasMany(Vehicle::class, ['client_id' => 'id']); } public function getOrders() { return $this->hasMany(Order::class, ['client_id' => 'id']); } // Вычисляемые поля public function getTotalSpent(): float { return (float) $this->getOrders() ->where(['status' => Order::STATUS_COMPLETED]) ->sum('total'); } public function getOrdersCount(): int { return (int) $this->getOrders()->count(); } // Форматирование телефона public function beforeSave($insert): bool { if (parent::beforeSave($insert)) { // Очищаем телефон от лишних символов $this->phone = preg_replace('/[^0-9+]/', '', $this->phone); return true; } return false; } // Для API: какие поля отдавать public function fields(): array { return [ 'id', 'name', 'phone', 'email', 'type', 'company_name', 'discount_percent', 'vehicles_count' => fn() => count($this->vehicles), 'orders_count' => fn() => $this->getOrdersCount(), 'created_at', ]; } public function extraFields(): array { return ['vehicles', 'orders', 'notes', 'inn', 'total_spent']; } }
Модель Order (Заказ-наряд)
<?php namespace common\models; use yii\db\ActiveRecord; use yii\behaviors\TimestampBehavior; use yii\db\Expression; class Order extends ActiveRecord { // Типы заказов const TYPE_REPAIR = 'repair'; // Обычный ремонт const TYPE_SWAP = 'swap'; // СВАП проект const TYPE_DOCUMENTS = 'documents'; // Только документы const TYPE_DIAGNOSTICS = 'diagnostics'; // Диагностика const TYPE_MAINTENANCE = 'maintenance'; // ТО // Статусы const STATUS_NEW = 'new'; const STATUS_IN_PROGRESS = 'in_progress'; const STATUS_WAITING_PARTS = 'waiting_parts'; const STATUS_WAITING_CLIENT = 'waiting_client'; const STATUS_COMPLETED = 'completed'; const STATUS_CANCELLED = 'cancelled'; public static function tableName(): string { return '{{%orders}}'; } public function rules(): array { return [ [['client_id', 'vehicle_id'], 'required'], [['client_id', 'vehicle_id', 'manager_id', 'mechanic_id', 'mileage_on_receive'], 'integer'], ['type', 'in', 'range' => self::getTypes()], ['status', 'in', 'range' => array_keys(self::getStatuses())], [['description', 'diagnosis'], 'string'], [['total_works', 'total_parts', 'discount', 'total', 'paid'], 'number'], ['client_id', 'exist', 'targetClass' => Client::class, 'targetAttribute' => 'id'], ['vehicle_id', 'exist', 'targetClass' => Vehicle::class, 'targetAttribute' => 'id'], ]; } public static function getTypes(): array { return [ self::TYPE_REPAIR => 'Ремонт', self::TYPE_SWAP => 'СВАП проект', self::TYPE_DOCUMENTS => 'Оформление документов', self::TYPE_DIAGNOSTICS => 'Диагностика', self::TYPE_MAINTENANCE => 'Техобслуживание', ]; } public static function getStatuses(): array { return [ self::STATUS_NEW => ['label' => 'Новый', 'color' => 'blue'], self::STATUS_IN_PROGRESS => ['label' => 'В работе', 'color' => 'yellow'], self::STATUS_WAITING_PARTS => ['label' => 'Ожидание запчастей', 'color' => 'orange'], self::STATUS_WAITING_CLIENT => ['label' => 'Ожидание клиента', 'color' => 'purple'], self::STATUS_COMPLETED => ['label' => 'Завершён', 'color' => 'green'], self::STATUS_CANCELLED => ['label' => 'Отменён', 'color' => 'red'], ]; } // Генерация номера заказа public function beforeSave($insert): bool { if (parent::beforeSave($insert)) { if ($insert && empty($this->number)) { $this->number = $this->generateNumber(); } // Пересчёт итогов $this->recalculateTotal(); return true; } return false; } protected function generateNumber(): string { $prefix = match($this->type) { self::TYPE_SWAP => 'SW', self::TYPE_DOCUMENTS => 'DOC', default => 'ЗН', }; $year = date('Y'); $lastNumber = self::find() ->where(['LIKE', 'number', "{$prefix}-{$year}-%", false]) ->max('CAST(SUBSTRING_INDEX(number, "-", -1) AS UNSIGNED)'); $nextNumber = ($lastNumber ?? 0) + 1; return sprintf('%s-%s-%04d', $prefix, $year, $nextNumber); } public function recalculateTotal(): void { $this->total_works = $this->getWorks()->sum('total') ?: 0; $this->total_parts = $this->getParts()->sum('total') ?: 0; $this->total = $this->total_works + $this->total_parts - $this->discount; } // Связи public function getClient() { return $this->hasOne(Client::class, ['id' => 'client_id']); } public function getVehicle() { return $this->hasOne(Vehicle::class, ['id' => 'vehicle_id']); } public function getWorks() { return $this->hasMany(OrderWork::class, ['order_id' => 'id']); } public function getParts() { return $this->hasMany(OrderPart::class, ['order_id' => 'id']); } public function getSwapProject() { return $this->hasOne(SwapProject::class, ['order_id' => 'id']); } public function getDocuments() { return $this->hasMany(Document::class, ['order_id' => 'id']); } public function getFiles() { return $this->hasMany(File::class, ['entity_id' => 'id']) ->andWhere(['entity_type' => 'order']); } // Баланс (долг) public function getDebt(): float { return $this->total - $this->paid; } public function isPaid(): bool { return $this->getDebt() <= 0; } // API fields public function fields(): array { return [ 'id', 'number', 'type', 'type_label' => fn() => self::getTypes()[$this->type] ?? $this->type, 'status', 'status_label' => fn() => self::getStatuses()[$this->status]['label'] ?? $this->status, 'status_color' => fn() => self::getStatuses()[$this->status]['color'] ?? 'gray', 'client_id', 'vehicle_id', 'description', 'total_works', 'total_parts', 'discount', 'total', 'paid', 'debt' => fn() => $this->getDebt(), 'is_paid' => fn() => $this->isPaid(), 'created_at', 'completed_at', ]; } public function extraFields(): array { return ['client', 'vehicle', 'works', 'parts', 'swapProject', 'documents', 'files']; } }
Модель SwapProject (СВАП проект)
<?php namespace common\models; use yii\db\ActiveRecord; class SwapProject extends ActiveRecord { // Предустановленные этапы СВАП проекта const DEFAULT_STAGES = [ ['name' => 'Приёмка автомобиля и осмотр', 'sort_order' => 1], ['name' => 'Демонтаж старого мотора', 'sort_order' => 2], ['name' => 'Подготовка донорского мотора', 'sort_order' => 3], ['name' => 'Изготовление/адаптация креплений', 'sort_order' => 4], ['name' => 'Установка мотора', 'sort_order' => 5], ['name' => 'Подключение КПП', 'sort_order' => 6], ['name' => 'Прокладка проводки', 'sort_order' => 7], ['name' => 'Установка и настройка ЭБУ', 'sort_order' => 8], ['name' => 'Система охлаждения', 'sort_order' => 9], ['name' => 'Выпускная система', 'sort_order' => 10], ['name' => 'Топливная система', 'sort_order' => 11], ['name' => 'Первый запуск и диагностика', 'sort_order' => 12], ['name' => 'Обкатка и тестирование', 'sort_order' => 13], ['name' => 'Фотофиксация для ГИБДД', 'sort_order' => 14], ['name' => 'Финальная проверка и сдача', 'sort_order' => 15], ]; public static function tableName(): string { return '{{%swap_projects}}'; } public function rules(): array { return [ [['order_id', 'donor_engine_code'], 'required'], ['order_id', 'integer'], ['order_id', 'unique'], [['donor_engine_code', 'ecu_type', 'wiring_type', 'gearbox_type'], 'string', 'max' => 100], [['donor_engine_mileage', 'estimated_hours', 'actual_hours'], 'integer'], [['donor_info', 'additional_mods'], 'string'], ]; } public function attributeLabels(): array { return [ 'donor_engine_code' => 'Код донорского мотора', 'donor_engine_mileage' => 'Пробег донора', 'donor_info' => 'Информация о доноре', 'ecu_type' => 'Тип ЭБУ', 'wiring_type' => 'Проводка', 'gearbox_type' => 'КПП', 'additional_mods' => 'Дополнительные модификации', 'estimated_hours' => 'Оценка трудозатрат (ч)', 'actual_hours' => 'Фактические трудозатраты (ч)', ]; } // После создания — добавляем стандартные этапы public function afterSave($insert, $changedAttributes): void { parent::afterSave($insert, $changedAttributes); if ($insert) { $this->createDefaultStages(); } } protected function createDefaultStages(): void { foreach (self::DEFAULT_STAGES as $stageData) { $stage = new SwapStage(); $stage->swap_project_id = $this->id; $stage->name = $stageData['name']; $stage->sort_order = $stageData['sort_order']; $stage->status = 'pending'; $stage->save(); } } // Связи public function getOrder() { return $this->hasOne(Order::class, ['id' => 'order_id']); } public function getStages() { return $this->hasMany(SwapStage::class, ['swap_project_id' => 'id']) ->orderBy(['sort_order' => SORT_ASC]); } // Прогресс проекта в процентах public function getProgress(): int { $total = $this->getStages()->count(); if ($total === 0) return 0; $completed = $this->getStages()->where(['status' => 'completed'])->count(); return (int) round(($completed / $total) * 100); } public function fields(): array { return [ 'id', 'order_id', 'donor_engine_code', 'donor_engine_mileage', 'donor_info', 'ecu_type', 'wiring_type', 'gearbox_type', 'additional_mods', 'estimated_hours', 'actual_hours', 'progress' => fn() => $this->getProgress(), ]; } public function extraFields(): array { return ['order', 'stages']; } }
🔌 REST API
Создаём REST API для взаимодействия с Vue.js фронтендом. Yii2 имеет отличную встроенную поддержку REST.
Настройка URL и аутентификации
<?php return [ 'id' => 'app-backend', 'basePath' => dirname(__DIR__), 'controllerNamespace' => 'backend\controllers', 'bootstrap' => ['log'], 'components' => [ 'request' => [ 'parsers' => [ 'application/json' => 'yii\web\JsonParser', ], 'enableCsrfValidation' => false, // Для API ], 'response' => [ 'format' => \yii\web\Response::FORMAT_JSON, 'charset' => 'UTF-8', 'on beforeSend' => function ($event) { $response = $event->sender; $response->headers->set('Access-Control-Allow-Origin', '*'); $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); }, ], 'urlManager' => [ 'enablePrettyUrl' => true, 'enableStrictParsing' => true, 'showScriptName' => false, 'rules' => [ // Auth 'POST auth/login' => 'auth/login', 'POST auth/refresh' => 'auth/refresh', 'GET auth/me' => 'auth/me', // REST ресурсы ['class' => 'yii\rest\UrlRule', 'controller' => 'client', 'pluralize' => true], ['class' => 'yii\rest\UrlRule', 'controller' => 'vehicle', 'pluralize' => true], ['class' => 'yii\rest\UrlRule', 'controller' => 'order', 'pluralize' => true], // Дополнительные эндпоинты 'GET orders/<id:\d+>/works' => 'order/works', 'POST orders/<id:\d+>/works' => 'order/add-work', 'GET orders/<id:\d+>/parts' => 'order/parts', 'POST orders/<id:\d+>/parts' => 'order/add-part', 'PATCH orders/<id:\d+>/status' => 'order/change-status', 'POST orders/<id:\d+>/payment' => 'order/add-payment', // СВАП проекты 'GET swap-projects/<id:\d+>/stages' => 'swap-project/stages', 'PATCH swap-stages/<id:\d+>' => 'swap-project/update-stage', // Документы ['class' => 'yii\rest\UrlRule', 'controller' => 'document', 'pluralize' => true], 'GET documents/<id:\d+>/download' => 'document/download', 'POST documents/<id:\d+>/generate' => 'document/generate', // Файлы 'POST files/upload' => 'file/upload', 'DELETE files/<id:\d+>' => 'file/delete', // Справочники 'GET reference/works' => 'reference/works', 'GET reference/parts' => 'reference/parts', 'GET reference/statuses' => 'reference/statuses', // Дашборд и отчёты 'GET dashboard/stats' => 'dashboard/stats', 'GET reports/revenue' => 'report/revenue', 'GET reports/mechanics' => 'report/mechanics', ], ], 'user' => [ 'identityClass' => 'common\models\User', 'enableAutoLogin' => false, 'enableSession' => false, ], ], ];
JWT Аутентификация
<?php namespace backend\components; use Firebase\JWT\JWT; use Firebase\JWT\Key; use yii\filters\auth\HttpBearerAuth; use common\models\User; class JwtAuth extends HttpBearerAuth { public function authenticate($user, $request, $response) { $authHeader = $request->getHeaders()->get('Authorization'); if ($authHeader !== null && preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { $token = $matches[1]; try { $decoded = JWT::decode( $token, new Key(\Yii::$app->params['jwtSecretKey'], 'HS256') ); $identity = User::findOne($decoded->uid); if ($identity && $identity->status === User::STATUS_ACTIVE) { $user->login($identity); return $identity; } } catch (\Exception $e) { \Yii::warning('JWT decode error: ' . $e->getMessage()); } } return null; } } // Хелпер для генерации токенов class JwtHelper { public static function generateToken(User $user): array { $now = time(); $secretKey = \Yii::$app->params['jwtSecretKey']; // Access token - 1 час $accessPayload = [ 'iss' => 'autoservice-crm', 'iat' => $now, 'exp' => $now + 3600, 'uid' => $user->id, 'role' => $user->role, ]; // Refresh token - 30 дней $refreshPayload = [ 'iss' => 'autoservice-crm', 'iat' => $now, 'exp' => $now + 2592000, 'uid' => $user->id, 'type' => 'refresh', ]; return [ 'access_token' => JWT::encode($accessPayload, $secretKey, 'HS256'), 'refresh_token' => JWT::encode($refreshPayload, $secretKey, 'HS256'), 'expires_in' => 3600, ]; } }
Контроллер клиентов
<?php namespace backend\controllers; use yii\rest\ActiveController; use yii\data\ActiveDataProvider; use common\models\Client; use backend\components\JwtAuth; class ClientController extends ActiveController { public $modelClass = 'common\models\Client'; public function behaviors(): array { $behaviors = parent::behaviors(); // JWT аутентификация $behaviors['authenticator'] = [ 'class' => JwtAuth::class, ]; return $behaviors; } public function actions(): array { $actions = parent::actions(); // Кастомизируем index action для поддержки поиска и фильтров unset($actions['index']); return $actions; } public function actionIndex(): ActiveDataProvider { $request = \Yii::$app->request; $query = Client::find(); // Поиск по имени или телефону if ($search = $request->get('search')) { $query->andWhere(['OR', ['LIKE', 'name', $search], ['LIKE', 'phone', $search], ['LIKE', 'company_name', $search], ]); } // Фильтр по типу if ($type = $request->get('type')) { $query->andWhere(['type' => $type]); } // Сортировка $sort = $request->get('sort', '-created_at'); if (str_starts_with($sort, '-')) { $query->orderBy([substr($sort, 1) => SORT_DESC]); } else { $query->orderBy([$sort => SORT_ASC]); } return new ActiveDataProvider([ 'query' => $query, 'pagination' => [ 'pageSize' => $request->get('per_page', 20), ], ]); } // Поиск клиента для автокомплита public function actionSearch(): array { $query = \Yii::$app->request->get('q', ''); if (strlen($query) < 2) { return []; } return Client::find() ->select(['id', 'name', 'phone', 'company_name']) ->where(['OR', ['LIKE', 'name', $query], ['LIKE', 'phone', $query], ]) ->limit(10) ->asArray() ->all(); } }
Контроллер заказов
<?php namespace backend\controllers; use yii\rest\ActiveController; use yii\data\ActiveDataProvider; use yii\web\NotFoundHttpException; use yii\web\BadRequestHttpException; use common\models\Order; use common\models\OrderWork; use common\models\OrderPart; use common\models\SwapProject; use backend\components\JwtAuth; class OrderController extends ActiveController { public $modelClass = 'common\models\Order'; public function behaviors(): array { $behaviors = parent::behaviors(); $behaviors['authenticator'] = ['class' => JwtAuth::class]; return $behaviors; } public function actions(): array { $actions = parent::actions(); unset($actions['index'], $actions['create']); return $actions; } // Список заказов с фильтрами public function actionIndex(): ActiveDataProvider { $request = \Yii::$app->request; $query = Order::find()->with(['client', 'vehicle']); // Фильтр по статусу if ($status = $request->get('status')) { $query->andWhere(['status' => $status]); } // Фильтр по типу if ($type = $request->get('type')) { $query->andWhere(['type' => $type]); } // Фильтр по клиенту if ($clientId = $request->get('client_id')) { $query->andWhere(['client_id' => $clientId]); } // Фильтр по автомобилю if ($vehicleId = $request->get('vehicle_id')) { $query->andWhere(['vehicle_id' => $vehicleId]); } // Фильтр по датам if ($dateFrom = $request->get('date_from')) { $query->andWhere(['>=', 'created_at', $dateFrom]); } if ($dateTo = $request->get('date_to')) { $query->andWhere(['<=', 'created_at', $dateTo . ' 23:59:59']); } // Поиск по номеру заказа if ($search = $request->get('search')) { $query->andWhere(['LIKE', 'number', $search]); } // Только неоплаченные if ($request->get('unpaid')) { $query->andWhere('total > paid'); } $query->orderBy(['created_at' => SORT_DESC]); return new ActiveDataProvider([ 'query' => $query, 'pagination' => ['pageSize' => $request->get('per_page', 20)], ]); } // Создание заказа public function actionCreate(): Order { $data = \Yii::$app->request->getBodyParams(); $order = new Order(); $order->load($data, ''); $order->manager_id = \Yii::$app->user->id; if (!$order->save()) { throw new BadRequestHttpException(json_encode($order->errors)); } // Если это СВАП — создаём проект if ($order->type === Order::TYPE_SWAP && !empty($data['swap_project'])) { $swapProject = new SwapProject(); $swapProject->load($data['swap_project'], ''); $swapProject->order_id = $order->id; $swapProject->save(); } $order->refresh(); return $order; } // Изменение статуса public function actionChangeStatus($id): Order { $order = $this->findModel($id); $newStatus = \Yii::$app->request->getBodyParam('status'); if (!array_key_exists($newStatus, Order::getStatuses())) { throw new BadRequestHttpException('Invalid status'); } $order->status = $newStatus; if ($newStatus === Order::STATUS_COMPLETED) { $order->completed_at = date('Y-m-d H:i:s'); } $order->save(); return $order; } // Добавление работы в заказ public function actionAddWork($id): OrderWork { $order = $this->findModel($id); $data = \Yii::$app->request->getBodyParams(); $work = new OrderWork(); $work->order_id = $order->id; $work->load($data, ''); $work->total = $work->quantity * $work->price; if (!$work->save()) { throw new BadRequestHttpException(json_encode($work->errors)); } // Пересчитываем итоги заказа $order->recalculateTotal(); $order->save(false); return $work; } // Добавление запчасти в заказ public function actionAddPart($id): OrderPart { $order = $this->findModel($id); $data = \Yii::$app->request->getBodyParams(); $part = new OrderPart(); $part->order_id = $order->id; $part->load($data, ''); $part->total = $part->quantity * $part->price_sell; if (!$part->save()) { throw new BadRequestHttpException(json_encode($part->errors)); } $order->recalculateTotal(); $order->save(false); return $part; } // Добавление оплаты public function actionAddPayment($id): array { $order = $this->findModel($id); $amount = (float) \Yii::$app->request->getBodyParam('amount', 0); $method = \Yii::$app->request->getBodyParam('method', 'cash'); if ($amount <= 0) { throw new BadRequestHttpException('Invalid amount'); } $order->paid += $amount; $order->save(false); // Можно добавить запись в таблицу платежей для истории return [ 'success' => true, 'paid' => $order->paid, 'debt' => $order->getDebt(), ]; } protected function findModel($id): Order { $model = Order::findOne($id); if ($model === null) { throw new NotFoundHttpException('Order not found'); } return $model; } }
💚 Frontend на Vue.js
Создаём SPA на Vue 3 с Composition API, Pinia для состояния и Vue Router для навигации.
Установка и структура
# Создаём проект Vue cd frontend npm create vite@latest . -- --template vue # Устанавливаем зависимости npm install vue-router@4 pinia axios npm install @vueuse/core dayjs npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p # Дополнительные компоненты npm install @headlessui/vue @heroicons/vue npm install vue-toastification@next
API клиент
import axios from 'axios' import { useAuthStore } from '@/stores/auth' import router from '@/router' const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', headers: { 'Content-Type': 'application/json', }, }) // Interceptor для добавления токена api.interceptors.request.use((config) => { const authStore = useAuthStore() if (authStore.token) { config.headers.Authorization = `Bearer ${authStore.token}` } return config }) // Interceptor для обработки ошибок api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { const authStore = useAuthStore() authStore.logout() router.push('/login') } return Promise.reject(error) } ) export default api // API модули export const clientsApi = { getAll(params = {}) { return api.get('/clients', { params }) }, getOne(id) { return api.get(`/clients/${id}`, { params: { expand: 'vehicles,orders' } }) }, create(data) { return api.post('/clients', data) }, update(id, data) { return api.put(`/clients/${id}`, data) }, delete(id) { return api.delete(`/clients/${id}`) }, search(query) { return api.get('/clients/search', { params: { q: query } }) }, } export const ordersApi = { getAll(params = {}) { return api.get('/orders', { params }) }, getOne(id) { return api.get(`/orders/${id}`, { params: { expand: 'client,vehicle,works,parts,swapProject,documents' } }) }, create(data) { return api.post('/orders', data) }, update(id, data) { return api.put(`/orders/${id}`, data) }, changeStatus(id, status) { return api.patch(`/orders/${id}/status`, { status }) }, addWork(orderId, data) { return api.post(`/orders/${orderId}/works`, data) }, addPart(orderId, data) { return api.post(`/orders/${orderId}/parts`, data) }, addPayment(orderId, amount, method = 'cash') { return api.post(`/orders/${orderId}/payment`, { amount, method }) }, } export const vehiclesApi = { getAll(params = {}) { return api.get('/vehicles', { params }) }, getByClient(clientId) { return api.get('/vehicles', { params: { client_id: clientId } }) }, create(data) { return api.post('/vehicles', data) }, update(id, data) { return api.put(`/vehicles/${id}`, data) }, } export const dashboardApi = { getStats() { return api.get('/dashboard/stats') }, } export const documentsApi = { getAll(orderId) { return api.get('/documents', { params: { order_id: orderId } }) }, generate(id, type) { return api.post(`/documents/${id}/generate`, { type }) }, download(id) { return api.get(`/documents/${id}/download`, { responseType: 'blob' }) }, }
Store для заказов (Pinia)
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { ordersApi } from '@/api' export const useOrdersStore = defineStore('orders', () => { // State const orders = ref([]) const currentOrder = ref(null) const loading = ref(false) const pagination = ref({ currentPage: 1, totalPages: 1, totalItems: 0, perPage: 20, }) const filters = ref({ status: '', type: '', search: '', dateFrom: '', dateTo: '', }) // Getters const activeOrders = computed(() => orders.value.filter(o => !['completed', 'cancelled'].includes(o.status)) ) const ordersByStatus = computed(() => { const grouped = {} orders.value.forEach(order => { if (!grouped[order.status]) { grouped[order.status] = [] } grouped[order.status].push(order) }) return grouped }) // Actions async function fetchOrders(page = 1) { loading.value = true try { const params = { page, per_page: pagination.value.perPage, ...filters.value, } // Убираем пустые фильтры Object.keys(params).forEach(key => { if (!params[key]) delete params[key] }) const response = await ordersApi.getAll(params) orders.value = response.data // Парсим заголовки пагинации Yii2 pagination.value = { currentPage: parseInt(response.headers['x-pagination-current-page']) || 1, totalPages: parseInt(response.headers['x-pagination-page-count']) || 1, totalItems: parseInt(response.headers['x-pagination-total-count']) || 0, perPage: parseInt(response.headers['x-pagination-per-page']) || 20, } } catch (error) { console.error('Error fetching orders:', error) throw error } finally { loading.value = false } } async function fetchOrder(id) { loading.value = true try { const response = await ordersApi.getOne(id) currentOrder.value = response.data return response.data } catch (error) { console.error('Error fetching order:', error) throw error } finally { loading.value = false } } async function createOrder(data) { const response = await ordersApi.create(data) orders.value.unshift(response.data) return response.data } async function updateOrderStatus(id, status) { const response = await ordersApi.changeStatus(id, status) // Обновляем в списке const index = orders.value.findIndex(o => o.id === id) if (index !== -1) { orders.value[index] = response.data } // Обновляем текущий заказ if (currentOrder.value?.id === id) { currentOrder.value = { ...currentOrder.value, ...response.data } } return response.data } async function addWorkToOrder(orderId, workData) { const response = await ordersApi.addWork(orderId, workData) if (currentOrder.value?.id === orderId) { currentOrder.value.works.push(response.data) // Обновляем итоги await fetchOrder(orderId) } return response.data } async function addPartToOrder(orderId, partData) { const response = await ordersApi.addPart(orderId, partData) if (currentOrder.value?.id === orderId) { currentOrder.value.parts.push(response.data) await fetchOrder(orderId) } return response.data } async function addPayment(orderId, amount, method) { const response = await ordersApi.addPayment(orderId, amount, method) if (currentOrder.value?.id === orderId) { currentOrder.value.paid = response.data.paid currentOrder.value.debt = response.data.debt } return response.data } function setFilters(newFilters) { filters.value = { ...filters.value, ...newFilters } } function resetFilters() { filters.value = { status: '', type: '', search: '', dateFrom: '', dateTo: '', } } return { // State orders, currentOrder, loading, pagination, filters, // Getters activeOrders, ordersByStatus, // Actions fetchOrders, fetchOrder, createOrder, updateOrderStatus, addWorkToOrder, addPartToOrder, addPayment, setFilters, resetFilters, } })
Компонент списка заказов
<template> <div class="orders-page"> <!-- Header --> <div class="page-header"> <h1>Заказ-наряды</h1> <button @click="showCreateModal = true" class="btn btn-primary"> <PlusIcon class="icon" /> Новый заказ </button> </div> <!-- Filters --> <div class="filters-bar"> <div class="search-box"> <MagnifyingGlassIcon class="search-icon" /> <input v-model="searchQuery" type="text" placeholder="Поиск по номеру заказа..." @input="debouncedSearch" /> </div> <select v-model="filters.status" @change="applyFilters" class="filter-select"> <option value="">Все статусы</option> <option v-for="(status, key) in statuses" :key="key" :value="key"> {{ status.label }} </option> </select> <select v-model="filters.type" @change="applyFilters" class="filter-select"> <option value="">Все типы</option> <option value="repair">Ремонт</option> <option value="swap">СВАП</option> <option value="documents">Документы</option> <option value="diagnostics">Диагностика</option> </select> <input v-model="filters.dateFrom" type="date" @change="applyFilters" class="filter-date" /> <span class="date-separator">—</span> <input v-model="filters.dateTo" type="date" @change="applyFilters" class="filter-date" /> <button v-if="hasActiveFilters" @click="resetFilters" class="btn-text"> Сбросить </button> </div> <!-- Stats Cards --> <div class="stats-cards"> <div class="stat-card"> <div class="stat-value">{{ ordersByStatus['new']?.length || 0 }}</div> <div class="stat-label">Новых</div> </div> <div class="stat-card"> <div class="stat-value">{{ ordersByStatus['in_progress']?.length || 0 }}</div> <div class="stat-label">В работе</div> </div> <div class="stat-card warning"> <div class="stat-value">{{ ordersByStatus['waiting_parts']?.length || 0 }}</div> <div class="stat-label">Ждут запчасти</div> </div> <div class="stat-card success"> <div class="stat-value">{{ pagination.totalItems }}</div> <div class="stat-label">Всего</div> </div> </div> <!-- Orders Table --> <div class="table-container"> <table class="data-table"> <thead> <tr> <th>Номер</th> <th>Тип</th> <th>Клиент</th> <th>Автомобиль</th> <th>Статус</th> <th>Сумма</th> <th>Долг</th> <th>Дата</th> <th></th> </tr> </thead> <tbody> <tr v-for="order in orders" :key="order.id" @click="openOrder(order.id)" class="clickable-row" > <td class="order-number">{{ order.number }}</td> <td> <span :class="['type-badge', order.type]"> {{ order.type_label }} </span> </td> <td>{{ order.client?.name }}</td> <td> <span class="vehicle-info"> {{ order.vehicle?.brand }} {{ order.vehicle?.model }} <small>{{ order.vehicle?.license_plate }}</small> </span> </td> <td> <StatusBadge :status="order.status" :label="order.status_label" /> </td> <td class="amount">{{ formatMoney(order.total) }}</td> <td :class="['amount', { 'text-danger': order.debt > 0 }]"> {{ order.debt > 0 ? formatMoney(order.debt) : '—' }} </td> <td class="date">{{ formatDate(order.created_at) }}</td> <td> <button @click.stop="openMenu(order)" class="btn-icon"> <EllipsisVerticalIcon class="icon" /> </button> </td> </tr> </tbody> </table> <!-- Loading State --> <div v-if="loading" class="loading-overlay"> <Spinner /> </div> <!-- Empty State --> <div v-if="!loading && orders.length === 0" class="empty-state"> <ClipboardDocumentListIcon class="empty-icon" /> <p>Заказов не найдено</p> </div> </div> <!-- Pagination --> <Pagination v-if="pagination.totalPages > 1" :current="pagination.currentPage" :total="pagination.totalPages" @change="changePage" /> <!-- Create Order Modal --> <OrderCreateModal v-model="showCreateModal" @created="onOrderCreated" /> </div> </template> <script setup> import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { storeToRefs } from 'pinia' import { useDebounceFn } from '@vueuse/core' import { useOrdersStore } from '@/stores/orders' import { formatMoney, formatDate } from '@/utils/formatters' import StatusBadge from '@/components/StatusBadge.vue' import Pagination from '@/components/Pagination.vue' import Spinner from '@/components/Spinner.vue' import OrderCreateModal from './OrderCreateModal.vue' import { PlusIcon, MagnifyingGlassIcon, EllipsisVerticalIcon, ClipboardDocumentListIcon, } from '@heroicons/vue/24/outline' const router = useRouter() const ordersStore = useOrdersStore() const { orders, loading, pagination, ordersByStatus } = storeToRefs(ordersStore) const showCreateModal = ref(false) const searchQuery = ref('') const filters = ref({ status: '', type: '', dateFrom: '', dateTo: '', }) const statuses = { new: { label: 'Новый', color: 'blue' }, in_progress: { label: 'В работе', color: 'yellow' }, waiting_parts: { label: 'Ожидание запчастей', color: 'orange' }, waiting_client: { label: 'Ожидание клиента', color: 'purple' }, completed: { label: 'Завершён', color: 'green' }, cancelled: { label: 'Отменён', color: 'red' }, } const hasActiveFilters = computed(() => { return searchQuery.value || filters.value.status || filters.value.type || filters.value.dateFrom || filters.value.dateTo }) const debouncedSearch = useDebounceFn(() => { ordersStore.setFilters({ search: searchQuery.value }) ordersStore.fetchOrders(1) }, 300) function applyFilters() { ordersStore.setFilters({ status: filters.value.status, type: filters.value.type, date_from: filters.value.dateFrom, date_to: filters.value.dateTo, }) ordersStore.fetchOrders(1) } function resetFilters() { searchQuery.value = '' filters.value = { status: '', type: '', dateFrom: '', dateTo: '' } ordersStore.resetFilters() ordersStore.fetchOrders(1) } function changePage(page) { ordersStore.fetchOrders(page) } function openOrder(id) { router.push({ name: 'order-detail', params: { id } }) } function onOrderCreated(order) { showCreateModal.value = false router.push({ name: 'order-detail', params: { id: order.id } }) } onMounted(() => { ordersStore.fetchOrders() }) </script>
Компонент карточки заказа
<template> <div v-if="order" class="order-detail"> <!-- Header --> <div class="detail-header"> <div class="header-left"> <button @click="$router.back()" class="btn-back"> <ArrowLeftIcon class="icon" /> </button> <div> <h1>{{ order.number }}</h1> <span class="order-type">{{ order.type_label }}</span> </div> </div> <div class="header-actions"> <StatusSelector :current="order.status" @change="changeStatus" /> <button @click="printOrder" class="btn btn-secondary"> <PrinterIcon class="icon" /> Печать </button> </div> </div> <div class="detail-content"> <!-- Main Info --> <div class="detail-main"> <!-- Client & Vehicle Card --> <div class="info-card"> <div class="info-row"> <div class="info-block"> <h3>Клиент</h3> <p class="client-name">{{ order.client?.name }}</p> <p class="client-phone"> <a :href="'tel:' + order.client?.phone"> {{ order.client?.phone }} </a> </p> </div> <div class="info-block"> <h3>Автомобиль</h3> <p class="vehicle-name"> {{ order.vehicle?.brand }} {{ order.vehicle?.model }} {{ order.vehicle?.year }} </p> <p class="vehicle-details"> {{ order.vehicle?.license_plate }} · VIN: {{ order.vehicle?.vin || '—' }} </p> </div> </div> <div v-if="order.description" class="description"> <h3>Описание проблемы</h3> <p>{{ order.description }}</p> </div> </div> <!-- SWAP Project Info --> <SwapProjectCard v-if="order.type === 'swap' && order.swapProject" :project="order.swapProject" @stage-updated="refreshOrder" /> <!-- Works --> <div class="section-card"> <div class="section-header"> <h2>🔧 Работы</h2> <button @click="showAddWork = true" class="btn btn-sm"> <PlusIcon class="icon" /> Добавить </button> </div> <table v-if="order.works?.length" class="items-table"> <thead> <tr> <th>Наименование</th> <th>Кол-во</th> <th>Цена</th> <th>Сумма</th> <th></th> </tr> </thead> <tbody> <tr v-for="work in order.works" :key="work.id"> <td>{{ work.name }}</td> <td>{{ work.quantity }}</td> <td>{{ formatMoney(work.price) }}</td> <td class="amount">{{ formatMoney(work.total) }}</td> <td> <button @click="deleteWork(work.id)" class="btn-icon danger"> <TrashIcon class="icon" /> </button> </td> </tr> </tbody> <tfoot> <tr> <td colspan="3">Итого работы:</td> <td class="amount total">{{ formatMoney(order.total_works) }}</td> <td></td> </tr> </tfoot> </table> <p v-else class="empty-message">Работы не добавлены</p> </div> <!-- Parts --> <div class="section-card"> <div class="section-header"> <h2>🔩 Запчасти</h2> <button @click="showAddPart = true" class="btn btn-sm"> <PlusIcon class="icon" /> Добавить </button> </div> <table v-if="order.parts?.length" class="items-table"> <thead> <tr> <th>Наименование</th> <th>Артикул</th> <th>Кол-во</th> <th>Цена</th> <th>Сумма</th> <th></th> </tr> </thead> <tbody> <tr v-for="part in order.parts" :key="part.id"> <td>{{ part.name }}</td> <td class="article">{{ part.article || '—' }}</td> <td>{{ part.quantity }}</td> <td>{{ formatMoney(part.price_sell) }}</td> <td class="amount">{{ formatMoney(part.total) }}</td> <td> <button @click="deletePart(part.id)" class="btn-icon danger"> <TrashIcon class="icon" /> </button> </td> </tr> </tbody> <tfoot> <tr> <td colspan="4">Итого запчасти:</td> <td class="amount total">{{ formatMoney(order.total_parts) }}</td> <td></td> </tr> </tfoot> </table> <p v-else class="empty-message">Запчасти не добавлены</p> </div> <!-- Documents (for SWAP and documents type) --> <DocumentsSection v-if="['swap', 'documents'].includes(order.type)":order-id="order.id" :documents="order.documents" @updated="refreshOrder" /> <!-- Photos --> <PhotoGallery :entity-type="'order'" :entity-id="order.id" :files="order.files" @uploaded="refreshOrder" /> </div> <!-- Sidebar --> <div class="detail-sidebar"> <!-- Payment Summary --> <div class="summary-card"> <h3>Итого</h3> <div class="summary-rows"> <div class="summary-row"> <span>Работы</span> <span>{{ formatMoney(order.total_works) }}</span> </div> <div class="summary-row"> <span>Запчасти</span> <span>{{ formatMoney(order.total_parts) }}</span> </div> <div v-if="order.discount > 0" class="summary-row discount"> <span>Скидка</span> <span>-{{ formatMoney(order.discount) }}</span> </div> <div class="summary-row total"> <span>К оплате</span> <span>{{ formatMoney(order.total) }}</span> </div> <div class="summary-row paid"> <span>Оплачено</span> <span>{{ formatMoney(order.paid) }}</span> </div> <div v-if="order.debt > 0" class="summary-row debt"> <span>Долг</span> <span class="text-danger">{{ formatMoney(order.debt) }}</span> </div> </div> <button v-if="order.debt > 0" @click="showPaymentModal = true" class="btn btn-primary btn-block" > <BanknotesIcon class="icon" /> Принять оплату </button> <div v-else class="paid-badge"> <CheckCircleIcon class="icon" /> Оплачено полностью </div> </div> <!-- SWAP Progress --> <div v-if="order.type === 'swap' && order.swapProject" class="progress-card"> <h3>Прогресс СВАП</h3> <div class="progress-bar"> <div class="progress-fill" :style="{ width: order.swapProject.progress + '%' }" ></div> </div> <p class="progress-text">{{ order.swapProject.progress }}% выполнено</p> </div> <!-- Timeline --> <div class="timeline-card"> <h3>История</h3> <div class="timeline"> <div class="timeline-item"> <div class="timeline-dot"></div> <div class="timeline-content"> <span class="timeline-title">Создан</span> <span class="timeline-date">{{ formatDateTime(order.created_at) }}</span> </div> </div> <div v-if="order.completed_at" class="timeline-item completed"> <div class="timeline-dot"></div> <div class="timeline-content"> <span class="timeline-title">Завершён</span> <span class="timeline-date">{{ formatDateTime(order.completed_at) }}</span> </div> </div> </div> </div> </div> </div> <!-- Modals --> <AddWorkModal v-model="showAddWork" :order-id="order.id" @added="onWorkAdded" /> <AddPartModal v-model="showAddPart" :order-id="order.id" @added="onPartAdded" /> <PaymentModal v-model="showPaymentModal" :order-id="order.id" :max-amount="order.debt" @paid="onPaymentAdded" /> </div> <!-- Loading --> <div v-else class="loading-page"> <Spinner /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import { storeToRefs } from 'pinia' import { useOrdersStore } from '@/stores/orders' import { useToast } from 'vue-toastification' import { formatMoney, formatDateTime } from '@/utils/formatters' // Components import StatusSelector from '@/components/StatusSelector.vue' import SwapProjectCard from './components/SwapProjectCard.vue' import DocumentsSection from './components/DocumentsSection.vue' import PhotoGallery from '@/components/PhotoGallery.vue' import AddWorkModal from './modals/AddWorkModal.vue' import AddPartModal from './modals/AddPartModal.vue' import PaymentModal from './modals/PaymentModal.vue' import Spinner from '@/components/Spinner.vue' import { ArrowLeftIcon, PlusIcon, TrashIcon, PrinterIcon, BanknotesIcon, CheckCircleIcon } from '@heroicons/vue/24/outline' const route = useRoute() const toast = useToast() const ordersStore = useOrdersStore() const { currentOrder: order } = storeToRefs(ordersStore) const showAddWork = ref(false) const showAddPart = ref(false) const showPaymentModal = ref(false) async function refreshOrder() { await ordersStore.fetchOrder(route.params.id) } async function changeStatus(newStatus) { try { await ordersStore.updateOrderStatus(order.value.id, newStatus) toast.success('Статус обновлён') } catch (error) { toast.error('Ошибка при смене статуса') } } async function onWorkAdded() { showAddWork.value = false await refreshOrder() toast.success('Работа добавлена') } async function onPartAdded() { showAddPart.value = false await refreshOrder() toast.success('Запчасть добавлена') } async function onPaymentAdded() { showPaymentModal.value = false await refreshOrder() toast.success('Оплата принята') } function printOrder() { window.print() } onMounted(() => { ordersStore.fetchOrder(route.params.id) }) </script>
📦 Модули системы
Компонент СВАП проекта
<template> <div class="swap-project-card"> <div class="section-header"> <h2>🔄 СВАП Проект</h2> <span class="progress-badge">{{ project.progress }}%</span> </div> <!-- Project Info --> <div class="project-info"> <div class="info-grid"> <div class="info-item"> <label>Донорский мотор</label> <span class="value engine-code">{{ project.donor_engine_code }}</span> </div> <div class="info-item"> <label>Пробег донора</label> <span class="value">{{ formatNumber(project.donor_engine_mileage) }} км</span> </div> <div class="info-item"> <label>ЭБУ</label> <span class="value">{{ project.ecu_type || '—' }}</span> </div> <div class="info-item"> <label>КПП</label> <span class="value">{{ project.gearbox_type || '—' }}</span> </div> </div> <div v-if="project.donor_info" class="donor-info"> <label>Информация о доноре</label> <p>{{ project.donor_info }}</p> </div> </div> <!-- Stages --> <div class="stages-section"> <h3>Этапы работ</h3> <div class="stages-list"> <div v-for="stage in project.stages" :key="stage.id" :class="['stage-item', stage.status]" > <div class="stage-checkbox"> <input type="checkbox" :checked="stage.status === 'completed'" @change="toggleStage(stage)" :disabled="updating" /> </div> <div class="stage-content"> <span class="stage-name">{{ stage.name }}</span> <span v-if="stage.completed_at" class="stage-date"> {{ formatDate(stage.completed_at) }} </span> </div> <button @click="openStageNotes(stage)" class="btn-icon" :class="{ 'has-notes': stage.notes }" > <ChatBubbleLeftIcon class="icon" /> </button> </div> </div> </div> <!-- Hours tracking --> <div class="hours-section"> <div class="hours-item"> <label>Оценка (ч)</label> <span>{{ project.estimated_hours || '—' }}</span> </div> <div class="hours-item"> <label>Факт (ч)</label> <input v-model.number="actualHours" type="number" min="0" @blur="updateHours" class="hours-input" /> </div> </div> <!-- Stage Notes Modal --> <Modal v-model="showNotesModal" title="Заметки к этапу"> <div v-if="selectedStage"> <h4>{{ selectedStage.name }}</h4> <textarea v-model="stageNotes" rows="4" placeholder="Добавьте заметки..." class="form-textarea" ></textarea> <div class="modal-actions"> <button @click="showNotesModal = false" class="btn btn-secondary">Отмена</button> <button @click="saveStageNotes" class="btn btn-primary">Сохранить</button> </div> </div> </Modal> </div> </template> <script setup> import { ref, watch } from 'vue' import { formatDate, formatNumber } from '@/utils/formatters' import api from '@/api' import Modal from '@/components/Modal.vue' import { ChatBubbleLeftIcon } from '@heroicons/vue/24/outline' const props = defineProps({ project: { type: Object, required: true } }) const emit = defineEmits(['stage-updated']) const updating = ref(false) const actualHours = ref(props.project.actual_hours || 0) const showNotesModal = ref(false) const selectedStage = ref(null) const stageNotes = ref('') watch(() => props.project.actual_hours, (val) => { actualHours.value = val || 0 }) async function toggleStage(stage) { updating.value = true try { const newStatus = stage.status === 'completed' ? 'pending' : 'completed' await api.patch(`/swap-stages/${stage.id}`, { status: newStatus }) emit('stage-updated') } catch (error) { console.error('Error updating stage:', error) } finally { updating.value = false } } async function updateHours() { try { await api.patch(`/swap-projects/${props.project.id}`, { actual_hours: actualHours.value }) } catch (error) { console.error('Error updating hours:', error) } } function openStageNotes(stage) { selectedStage.value = stage stageNotes.value = stage.notes || '' showNotesModal.value = true } async function saveStageNotes() { try { await api.patch(`/swap-stages/${selectedStage.value.id}`, { notes: stageNotes.value }) showNotesModal.value = false emit('stage-updated') } catch (error) { console.error('Error saving notes:', error) } } </script> <style scoped> .swap-project-card { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; } .engine-code { font-family: 'JetBrains Mono', monospace; font-weight: 600; color: #92400e; } .stages-list { display: flex; flex-direction: column; gap: 0.5rem; } .stage-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: white; border-radius: 8px; transition: all 0.2s; } .stage-item.completed { background: #d1fae5; } .stage-item.completed .stage-name { text-decoration: line-through; color: #6b7280; } .progress-badge { background: #92400e; color: white; padding: 0.25rem 0.75rem; border-radius: 20px; font-weight: 600; font-size: 0.875rem; } </style>
📄 Работа с документами
Для СВАП проектов и переоборудования необходимо формировать документы для ГИБДД: предварительное заключение, фотофиксация, протоколы и т.д.
Генерация PDF документов
<?php namespace backend\services; use common\models\Order; use common\models\Document; use kartik\mpdf\Pdf; use Yii; class DocumentGenerator { // Типы документов для переоборудования const TYPE_PRELIMINARY_EXPERTISE = 'preliminary_expertise'; // Предварительная экспертиза const TYPE_FINAL_EXPERTISE = 'final_expertise'; // Итоговое заключение const TYPE_PHOTO_FIXATION = 'photo_fixation'; // Акт фотофиксации const TYPE_APPLICATION = 'application_gibdd'; // Заявление в ГИБДД const TYPE_WORK_ACT = 'work_act'; // Акт выполненных работ const TYPE_ORDER_PRINT = 'order_print'; // Печать заказ-наряда public static function getTypes(): array { return [ self::TYPE_PRELIMINARY_EXPERTISE => 'Предварительная техническая экспертиза', self::TYPE_FINAL_EXPERTISE => 'Заключение о соответствии ТС', self::TYPE_PHOTO_FIXATION => 'Акт фотофиксации', self::TYPE_APPLICATION => 'Заявление в ГИБДД', self::TYPE_WORK_ACT => 'Акт выполненных работ', self::TYPE_ORDER_PRINT => 'Заказ-наряд', ]; } public function generate(Order $order, string $type): Document { // Создаём запись документа $document = new Document(); $document->order_id = $order->id; $document->type = $type; $document->number = $this->generateDocumentNumber($type); $document->date = date('Y-m-d'); $document->status = 'draft'; $document->created_by = Yii::$app->user->id; // Генерируем PDF $html = $this->renderTemplate($order, $type); $filePath = $this->savePdf($html, $document); $document->file_path = $filePath; $document->save(); return $document; } protected function renderTemplate(Order $order, string $type): string { $view = Yii::$app->controller->view; return match($type) { self::TYPE_PRELIMINARY_EXPERTISE => $view->render( '@backend/views/documents/preliminary_expertise', ['order' => $order] ), self::TYPE_FINAL_EXPERTISE => $view->render( '@backend/views/documents/final_expertise', ['order' => $order] ), self::TYPE_WORK_ACT => $view->render( '@backend/views/documents/work_act', ['order' => $order] ), self::TYPE_ORDER_PRINT => $view->render( '@backend/views/documents/order_print', ['order' => $order] ), default => throw new \InvalidArgumentException("Unknown document type: {$type}"), }; } protected function savePdf(string $html, Document $document): string { $pdf = new Pdf([ 'mode' => Pdf::MODE_UTF8, 'format' => Pdf::FORMAT_A4, 'orientation' => Pdf::ORIENT_PORTRAIT, 'destination' => Pdf::DEST_FILE, 'content' => $html, 'cssFile' => '@backend/web/css/pdf-styles.css', 'options' => [ 'title' => self::getTypes()[$document->type], ], 'methods' => [ 'SetFooter' => ['Страница {PAGENO} из {nbpg}'], ], ]); $filename = sprintf( '%s_%s_%s.pdf', $document->type, $document->number, date('Ymd') ); $dir = Yii::getAlias('@backend/web/uploads/documents/' . date('Y/m')); if (!is_dir($dir)) { mkdir($dir, 0755, true); } $fullPath = $dir . '/' . $filename; $pdf->filename = $fullPath; $pdf->render(); return 'uploads/documents/' . date('Y/m') . '/' . $filename; } protected function generateDocumentNumber(string $type): string { $prefix = match($type) { self::TYPE_PRELIMINARY_EXPERTISE => 'ПЭ', self::TYPE_FINAL_EXPERTISE => 'ЗКЛ', self::TYPE_WORK_ACT => 'АКТ', default => 'DOC', }; $year = date('Y'); $count = Document::find() ->where(['type' => $type]) ->andWhere(['YEAR(created_at)' => $year]) ->count(); return sprintf('%s-%s-%04d', $prefix, $year, $count + 1); } }
Шаблон документа (Заключение)
<?php /** @var common\models\Order $order */ $vehicle = $order->vehicle; $client = $order->client; $swap = $order->swapProject; ?> <div class="document"> <div class="header"> <div class="logo"> <img src="<?= Yii::getAlias('@backend/web/images/logo.png') ?>" alt="Logo"> </div> <div class="company-info"> <h3>ООО "АвтоСВАП Сервис"</h3> <p>ИНН 7712345678 / КПП 771201001</p> <p>г. Москва, ул. Промышленная, д. 15</p> <p>Тел: +7 (495) 123-45-67</p> </div> </div> <h1 class="document-title"> ЗАКЛЮЧЕНИЕ<br> о соответствии транспортного средства<br> требованиям безопасности после внесения изменений в конструкцию </h1> <p class="document-number"> № <strong><?= $order->number ?></strong> от <strong><?= Yii::$app->formatter->asDate($order->completed_at, 'long') ?></strong> </p> <section class="section"> <h2>1. Сведения о транспортном средстве</h2> <table class="info-table"> <tr> <td class="label">Марка, модель:</td> <td><?= htmlspecialchars($vehicle->brand . ' ' . $vehicle->model) ?></td> </tr> <tr> <td class="label">Год выпуска:</td> <td><?= $vehicle->year ?></td> </tr> <tr> <td class="label">VIN:</td> <td class="monospace"><?= $vehicle->vin ?></td> </tr> <tr> <td class="label">Гос. номер:</td> <td><?= $vehicle->license_plate ?></td> </tr> <tr> <td class="label">Пробег на момент приёмки:</td> <td><?= Yii::$app->formatter->asInteger($order->mileage_on_receive) ?> км</td> </tr> </table> </section> <section class="section"> <h2>2. Сведения о владельце</h2> <table class="info-table"> <tr> <td class="label">ФИО / Наименование:</td> <td><?= htmlspecialchars($client->name) ?></td> </tr> <?php if ($client->inn): ?> <tr> <td class="label">ИНН:</td> <td><?= $client->inn ?></td> </tr> <?php endif; ?> </table> </section> <?php if ($swap): ?> <section class="section"> <h2>3. Описание внесённых изменений</h2> <table class="info-table"> <tr> <td class="label">Установленный двигатель:</td> <td class="monospace"><?= $swap->donor_engine_code ?></td> </tr> <tr> <td class="label">Пробег донорского ДВС:</td> <td><?= Yii::$app->formatter->asInteger($swap->donor_engine_mileage) ?> км</td> </tr> <?php if ($swap->gearbox_type): ?> <tr> <td class="label">КПП:</td> <td><?= htmlspecialchars($swap->gearbox_type) ?></td> </tr> <?php endif; ?> <?php if ($swap->ecu_type): ?> <tr> <td class="label">Блок управления (ЭБУ):</td> <td><?= htmlspecialchars($swap->ecu_type) ?></td> </tr> <?php endif; ?> </table> <?php if ($swap->additional_mods): ?> <div class="subsection"> <h3>Дополнительные модификации:</h3> <p><?= nl2br(htmlspecialchars($swap->additional_mods)) ?></p> </div> <?php endif; ?> </section> <?php endif; ?> <section class="section"> <h2>4. Перечень выполненных работ</h2> <table class="works-table"> <thead> <tr> <th>№</th> <th>Наименование работ</th> <th>Кол-во</th> </tr> </thead> <tbody> <?php foreach ($order->works as $i => $work): ?> <tr> <td><?= $i + 1 ?></td> <td><?= htmlspecialchars($work->name) ?></td> <td><?= $work->quantity ?></td> </tr> <?php endforeach; ?> </tbody> </table> </section> <section class="section conclusion"> <h2>5. Заключение</h2> <p> На основании проведённого осмотра и проверки транспортного средства <strong><?= htmlspecialchars($vehicle->brand . ' ' . $vehicle->model) ?></strong>, VIN: <strong><?= $vehicle->vin ?></strong>, установлено, что внесённые изменения в конструкцию ТС выполнены в соответствии с требованиями технического регламента Таможенного союза "О безопасности колёсных транспортных средств" (ТР ТС 018/2011). </p> <p class="verdict"> Транспортное средство <strong>СООТВЕТСТВУЕТ</strong> требованиям безопасности и может быть допущено к эксплуатации. </p> </section> <section class="signatures"> <div class="signature-block"> <p>Технический эксперт:</p> <div class="signature-line"></div> <p class="signature-name">/ Иванов И.И. /</p> </div> <div class="signature-block"> <p>Директор:</p> <div class="signature-line"></div> <p class="signature-name">/ Петров П.П. /</p> </div> </section> <div class="stamp-area"> <p>М.П.</p> </div> </div>
🚀 Развёртывание
Настраиваем Docker-окружение для локальной разработки и production-деплоя.
Docker Compose
version: '3.8' services: # Nginx nginx: image: nginx:alpine ports: - "80:80" volumes: - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - ./backend/web:/var/www/backend/web - ./frontend/dist:/var/www/frontend depends_on: - php - frontend networks: - autoservice-network # PHP-FPM for Yii2 php: build: context: ./docker/php dockerfile: Dockerfile volumes: - ./:/var/www environment: - DB_HOST=mysql - DB_NAME=autoservice_crm - DB_USER=autoservice - DB_PASSWORD=secret - JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production depends_on: - mysql - redis networks: - autoservice-network # MySQL mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: autoservice_crm MYSQL_USER: autoservice MYSQL_PASSWORD: secret volumes: - mysql-data:/var/lib/mysql - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "3306:3306" networks: - autoservice-network # Redis for caching and sessions redis: image: redis:alpine volumes: - redis-data:/data networks: - autoservice-network # Frontend build frontend: build: context: ./frontend dockerfile: Dockerfile volumes: - ./frontend:/app - /app/node_modules environment: - VITE_API_URL=http://localhost/api command: npm run build networks: - autoservice-network # phpMyAdmin for development phpmyadmin: image: phpmyadmin environment: PMA_HOST: mysql PMA_USER: root PMA_PASSWORD: root ports: - "8080:80" depends_on: - mysql networks: - autoservice-network profiles: - dev volumes: mysql-data: redis-data: networks: autoservice-network: driver: bridge
PHP Dockerfile
FROM php:8.2-fpm-alpine # Install dependencies RUN apk add --no-cache \ curl \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ zip \ libzip-dev \ unzip \ git \ oniguruma-dev \ icu-dev \ libxml2-dev # Configure and install PHP extensions RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) \ pdo_mysql \ mbstring \ exif \ pcntl \ bcmath \ gd \ zip \ intl \ opcache # Install Redis extension RUN pecl install redis && docker-php-ext-enable redis # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # Set working directory WORKDIR /var/www # Copy custom PHP config COPY php.ini /usr/local/etc/php/conf.d/custom.ini # Set permissions RUN chown -R www-data:www-data /var/www USER www-data EXPOSE 9000 CMD ["php-fpm"]
Nginx конфигурация
server { listen 80; server_name localhost; # Frontend (Vue.js SPA) location / { root /var/www/frontend; index index.html; try_files $uri $uri/ /index.html; } # API (Yii2 Backend) location /api { alias /var/www/backend/web; index index.php; location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $request_filename; include fastcgi_params; } try_files $uri $uri/ /api/index.php?$query_string; } # Rewrite /api requests to backend location ~ ^/api/(.*)$ { rewrite ^/api/(.*)$ /$1 break; root /var/www/backend/web; try_files $uri /index.php?$query_string; location ~ \.php$ { fastcgi_pass php:9000; fastcgi_param SCRIPT_FILENAME /var/www/backend/web/index.php; include fastcgi_params; } } # Uploads location /uploads { alias /var/www/backend/web/uploads; expires 30d; add_header Cache-Control "public, immutable"; } # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; gzip_min_length 1000; }
Команды запуска
# Makefile для удобства работы .PHONY: up down build migrate seed # Запуск проекта up: docker-compose up -d @echo "✅ Проект запущен: http://localhost" # Остановка down: docker-compose down # Пересборка build: docker-compose build --no-cache docker-compose up -d # Миграции migrate: docker-compose exec php php yii migrate --interactive=0 # Откат миграций migrate-down: docker-compose exec php php yii migrate/down --interactive=0 # Тестовые данные seed: docker-compose exec php php yii seed/all # Установка зависимостей backend composer-install: docker-compose exec php composer install # Установка зависимостей frontend npm-install: docker-compose exec frontend npm install # Сборка frontend для production frontend-build: docker-compose exec frontend npm run build # Режим разработки frontend frontend-dev: cd frontend && npm run dev # Логи logs: docker-compose logs -f # Shell в PHP контейнер php-shell: docker-compose exec php sh # Инициализация проекта (первый запуск) init: build composer-install npm-install migrate seed @echo "🎉 Проект инициализирован!" @echo "📍 Frontend: http://localhost" @echo "📍 API: http://localhost/api" @echo "📍 phpMyAdmin: http://localhost:8080"
📊 Итоги
Мы разработали полнофункциональную CRM-систему для автосервиса, специализирующегося на СВАП-проектах. Система включает:
Клиенты и авто
Полная база клиентов с историей обращений и привязанными автомобилями
Заказ-наряды
Гибкая система заказов с работами, запчастями и оплатой
СВАП-проекты
Специализированный модуль для ведения сложных проектов по этапам
Документы
Автоматическая генерация документов для ГИБДД и клиентов
Технологический стек
Дальнейшее развитие
- 📱 Мобильное приложение для механиков (Flutter/React Native)
- 📊 Расширенная аналитика и отчёты
- 💬 Интеграция с мессенджерами (Telegram бот для уведомлений)
- 🏪 Модуль складского учёта запчастей
- 💳 Интеграция с кассой и онлайн-оплатой
- 📧 Email/SMS уведомления для клиентов
Полный исходный код проекта доступен на GitHub. Система готова к развёртыванию и адаптации под конкретный автосервис.
Смотреть на GitHub