🚗 Практическое руководство

Разработка CRM для автосервиса на Yii2 + Vue.js

Пошаговое создание системы управления автосервисом: СВАП моторов, ремонты, документы на переоборудование. От архитектуры до деплоя.

🐘 PHP 8.1+ 🏗️ Yii2 Framework 💚 Vue.js 3 🐬 MySQL 8 🎨 Tailwind CSS
📦
Полный исходный код Готовые модели, контроллеры, компоненты
🔧
Реальные задачи Заказ-наряды, склад, документы ГИБДД
🚀
Production Ready Можно использовать в работе

🎯 Введение и требования

Создаём CRM-систему для автосервиса, специализирующегося на СВАП моторов, стандартных ремонтах и оформлении документов на переоборудование. Система должна быть простой в разработке, быстрой и недорогой в поддержке.

Почему Yii2 + Vue.js?

🏗️
Yii2 Framework

Быстрая разработка с Gii, встроенный RBAC, ActiveRecord, отличная документация на русском

💚
Vue.js 3

Реактивный UI, компонентный подход, легко интегрируется, быстрее разработка форм и таблиц

💰
Низкая стоимость

PHP-хостинг от 300₽/мес, много разработчиков, простая поддержка

📈
Масштабируемость

Легко добавить функционал, REST API для мобильных приложений

Функциональные требования

📋 Что должна уметь CRM
  • Клиенты: база клиентов, история обращений, контакты
  • Автомобили: карточки авто, история работ, 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. Это оптимально для небольшого автосервиса: просто в разработке, легко деплоить.

🖥️ Browser
Vue.js SPA
↓↑
🔌 REST API
JSON / JWT Auth
↓↑
🐘 Yii2 Backend
Controllers, Models, RBAC
↓↑
🐬 MySQL
Database
📁 Storage
Файлы, фото

Структура проекта

📁 autoservice-crm/
📁 backend/ # Yii2 API
📁 config/
📁 controllers/
📁 models/
📁 modules/
📄 web/
📁 common/ # Общие модели
📁 models/
📁 mail/
📁 console/ # CLI команды
📁 frontend/ # Vue.js SPA
📁 src/
📁 components/
📁 views/
📁 stores/
📁 api/
📄 package.json
📄 vite.config.js
📁 database/ # Миграции
📄 docker-compose.yml
📄 README.md

🗄️ Проектирование базы данных

База данных — фундамент CRM. Проектируем с учётом специфики автосервиса: клиенты, автомобили, заказы, СВАП-проекты, документы.

Основные таблицы

👥 Клиенты (clients)

Поле Тип Описание
id PK INT UNSIGNED Идентификатор
name VARCHAR(255) ФИО клиента
phone VARCHAR(20) Телефон (уникальный)
email 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
console/migrations/m240101_000001_create_clients_table.php
<?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
console/migrations/m240101_000002_create_vehicles_table.php
<?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
console/migrations/m240101_000003_create_orders_table.php
<?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
console/migrations/m240101_000004_create_order_works_table.php
<?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
console/migrations/m240101_000005_create_documents_table.php
<?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 (если понадобится серверный рендеринг).

Установка проекта

Bash
# Создаём проект
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
common/models/Client.php
<?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
common/models/Order.php
<?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
common/models/SwapProject.php
<?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
backend/config/main.php
<?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
backend/components/JwtAuth.php
<?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
backend/controllers/ClientController.php
<?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
backend/controllers/OrderController.php
<?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 для навигации.

Установка и структура

Bash
# Создаём проект 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 клиент

JavaScript
src/api/index.js
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)

JavaScript
src/stores/orders.js
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,
    }
})

Компонент списка заказов

Vue
src/views/orders/OrdersList.vue
<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>

Компонент карточки заказа

Vue
src/views/orders/OrderDetail.vue
<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>

📦 Модули системы

Компонент СВАП проекта

Vue
src/views/orders/components/SwapProjectCard.vue
<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
backend/services/DocumentGenerator.php
<?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/HTML
backend/views/documents/final_expertise.php
<?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

YAML
docker-compose.yml
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

Dockerfile
docker/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 конфигурация

Nginx
docker/nginx/default.conf
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;
}

Команды запуска

Bash
Makefile
# 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-систему для автосервиса, специализирующегося на СВАП-проектах. Система включает:

👥

Клиенты и авто

Полная база клиентов с историей обращений и привязанными автомобилями

📋

Заказ-наряды

Гибкая система заказов с работами, запчастями и оплатой

🔄

СВАП-проекты

Специализированный модуль для ведения сложных проектов по этапам

📄

Документы

Автоматическая генерация документов для ГИБДД и клиентов

Технологический стек

Backend: Yii2 (PHP 8.2), REST API, JWT Auth
Frontend: Vue 3, Pinia, Vue Router, Tailwind CSS
Database: MySQL 8.0, Redis (кэш)
DevOps: Docker, Nginx, Makefile

Дальнейшее развитие

  • 📱 Мобильное приложение для механиков (Flutter/React Native)
  • 📊 Расширенная аналитика и отчёты
  • 💬 Интеграция с мессенджерами (Telegram бот для уведомлений)
  • 🏪 Модуль складского учёта запчастей
  • 💳 Интеграция с кассой и онлайн-оплатой
  • 📧 Email/SMS уведомления для клиентов

Полный исходный код проекта доступен на GitHub. Система готова к развёртыванию и адаптации под конкретный автосервис.

Смотреть на GitHub