From 7b655ff3deea81cdf3cc56de65930920092ba5b5 Mon Sep 17 00:00:00 2001 From: sbb45 Date: Tue, 2 Jun 2026 22:18:38 +0500 Subject: [PATCH] sqlite --- .env.example | 11 +- Dockerfile | 6 +- README.md | 90 +++++++++--- app/Providers/AppServiceProvider.php | 8 + app/Services/RequestService.php | 11 +- config/database.php | 2 +- config/queue.php | 4 +- database/seeders/ServiceRequestSeeder.php | 170 ++++++++++++++-------- database/sqlite/.gitkeep | 0 docker-compose.yml | 29 +--- docker/entrypoint.sh | 10 ++ 11 files changed, 212 insertions(+), 129 deletions(-) create mode 100644 database/sqlite/.gitkeep diff --git a/.env.example b/.env.example index e967d90..589d93b 100644 --- a/.env.example +++ b/.env.example @@ -7,19 +7,14 @@ APP_URL=https://example.ru LOG_CHANNEL=stderr LOG_LEVEL=error -DB_CONNECTION=mysql -DB_HOST=mysql -DB_PORT=3306 -DB_DATABASE=airservice -DB_USERNAME=airservice -DB_PASSWORD= -DB_ROOT_PASSWORD= +DB_CONNECTION=sqlite +DB_DATABASE=/var/www/html/database/sqlite/database.sqlite BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DISK=local QUEUE_CONNECTION=database -SESSION_DRIVER=database +SESSION_DRIVER=file SESSION_LIFETIME=120 # S3 (опционально, если нужно хранение файлов) diff --git a/Dockerfile b/Dockerfile index d826bce..28c1fdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,11 @@ FROM php:8.2-fpm-alpine AS app WORKDIR /var/www/html RUN apk add --no-cache \ - libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \ - zip unzip git mysql-client + libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev sqlite \ + zip unzip git RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install pdo pdo_mysql gd zip opcache pcntl + && docker-php-ext-install pdo pdo_sqlite gd zip opcache pcntl COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/README.md b/README.md index 38a8fe1..ecc4bdb 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # AirService -Система управления заявками на техническое обслуживание (Service Desk). Сотрудники создают заявки, назначают приоритеты и отслеживают статус выполнения. +Система управления заявками на техническое обслуживание (Service Desk). -**Стек:** Laravel 10, Livewire, Tailwind CSS, MySQL, Vite +**Стек:** Laravel 10, Livewire, Tailwind CSS, SQLite, Vite --- ## Локальная разработка -**Требования:** PHP >= 8.2, Composer, Node.js >= 18, MySQL +**Требования:** PHP >= 8.2, Composer, Node.js >= 18 ```bash git clone @@ -18,9 +18,10 @@ composer install npm install cp .env.example .env -# Поменять APP_ENV=local, APP_DEBUG=true, APP_URL=http://localhost -# Поменять DB_HOST=127.0.0.1, DB_USERNAME=root, убрать DB_ROOT_PASSWORD +# Поменять APP_ENV=local, APP_DEBUG=true, APP_URL=http://localhost:8000 php artisan key:generate + +touch database/sqlite/database.sqlite php artisan migrate --seed ``` @@ -34,13 +35,22 @@ php artisan serve npm run dev ``` -Приложение: [http://127.0.0.1:8000](http://127.0.0.1:8000) +Приложение: http://127.0.0.1:8000 + +**Тестовые аккаунты** (пароль у всех: `password`): + +| Email | Роль | +|---|---| +| admin@service.local | Администратор | +| manager@service.local | Менеджер | +| tech1@service.local | Техник | +| client1@service.local | Клиент | --- -## Деплой на сервер (flamy / docker-runtime) +## Деплой на сервер (Docker) -**Требования:** Docker, Docker Compose на `docker-runtime` (`10.20.0.20`). +**Требования:** Docker, Docker Compose на сервере `10.20.0.20`. ### 1. Скопировать проект на сервер @@ -49,7 +59,7 @@ ssh -i /root/.ssh/docker_vm_ed25519 crimson@10.20.0.20 sudo mkdir -p /opt/docker-apps/airservice ``` -Скопировать файлы (с локальной машины): +С локальной машины: ```bash rsync -avz --exclude='.git' --exclude='node_modules' --exclude='vendor' \ @@ -67,27 +77,62 @@ nano .env Обязательно заполнить: ```env -APP_KEY= # php artisan key:generate --show +APP_KEY= # сгенерировать ниже APP_URL=https://your-domain.ru -DB_PASSWORD= # придумать -DB_ROOT_PASSWORD= # придумать ``` -### 3. Запустить +Сгенерировать ключ: + +```bash +docker compose run --rm app php artisan key:generate --show +# Скопировать результат в APP_KEY= в .env +``` + +### 3. Собрать и запустить ```bash docker compose up -d --build -docker compose ps -docker compose logs -f --tail=100 ``` -Проверить локально на сервере: +### 4. Наполнить базу начальными данными (один раз) ```bash +docker compose exec app php artisan db:seed +``` + +После этого доступны тестовые аккаунты из таблицы выше. + +### 5. Проверить + +```bash +docker compose ps +docker compose logs app --tail=50 curl -I http://10.20.0.20:18201 ``` -### 4. Открыть домен через Caddy (edge-proxy) +### Пересборка после обновления кода + +```bash +rsync -avz --exclude='.git' --exclude='node_modules' --exclude='vendor' \ + ./ crimson@10.20.0.20:/opt/docker-apps/airservice/ + +docker compose up -d --build +``` + +Миграции накатываются автоматически при старте контейнера. База данных (SQLite файл) хранится в Docker volume `airservice_sqlite_data` и не теряется при пересборке. + +### Если нужно сбросить базу и накатить заново + +```bash +docker compose down +docker volume rm airservice_sqlite_data +docker compose up -d --build +docker compose exec app php artisan db:seed +``` + +--- + +### Открыть домен через Caddy (edge-proxy) ```bash ssh -i /root/.ssh/edge_vm_ed25519 crimson@192.168.0.80 @@ -120,9 +165,8 @@ sudo systemctl reload caddy ## Структура Docker-сервисов -| Сервис | Описание | -|--------------------|-----------------------------------| -| `airservice_app` | PHP-FPM (Laravel) | -| `airservice_nginx` | nginx, публикует порт `18201` | -| `airservice_mysql` | MySQL 8.0, только внутри сети | -| `airservice_queue` | Queue worker (`queue:work`) | +| Сервис | Описание | +|---|---| +| `airservice_app` | PHP-FPM (Laravel) | +| `airservice_nginx` | nginx, порт `18201` | +| `airservice_queue` | Queue worker | diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 47fe257..10efe16 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Models\Setting; +use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -20,6 +21,13 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + if (config('database.default') === 'sqlite') { + DB::statement('PRAGMA journal_mode=WAL'); + DB::statement('PRAGMA synchronous=NORMAL'); + DB::statement('PRAGMA busy_timeout=5000'); + DB::statement('PRAGMA foreign_keys=ON'); + } + try { $companyName = Setting::query()->where('key', 'company_name')->value('value'); $timezone = Setting::query()->where('key', 'timezone')->value('value'); diff --git a/app/Services/RequestService.php b/app/Services/RequestService.php index 72760f3..dec1ffe 100644 --- a/app/Services/RequestService.php +++ b/app/Services/RequestService.php @@ -25,10 +25,13 @@ class RequestService { return DB::transaction(function () use ($data, $actor): ServiceRequest { $payload = $this->preparePayload($data); - $payload['ticket_number'] = $this->generateTicketNumber(); + $payload['ticket_number'] = 'PENDING-' . uniqid('', true); $request = ServiceRequest::query()->create($payload); + $request->ticket_number = sprintf('SRV-%04d', $request->id); + $request->saveQuietly(); + $this->persistAttachments($request, Arr::get($data, 'attachments', []), $actor); event(new RequestCreated($request, $actor)); @@ -111,11 +114,9 @@ class RequestService return $map[$request->status?->slug ?? StatusSlug::NEW->value] ?? []; } - public function generateTicketNumber(): string + public function generateTicketNumber(int $id): string { - $maxId = ServiceRequest::withTrashed()->max('id') ?? 0; - - return sprintf('SRV-%04d', $maxId + 1); + return sprintf('SRV-%04d', $id); } private function preparePayload(array $data, ?ServiceRequest $request = null): array diff --git a/config/database.php b/config/database.php index 0a8832b..ac09add 100644 --- a/config/database.php +++ b/config/database.php @@ -15,7 +15,7 @@ return [ | */ - 'default' => env('DB_CONNECTION', 'mysql'), + 'default' => env('DB_CONNECTION', 'sqlite'), /* |-------------------------------------------------------------------------- diff --git a/config/queue.php b/config/queue.php index 01c6b05..c58e390 100644 --- a/config/queue.php +++ b/config/queue.php @@ -85,7 +85,7 @@ return [ */ 'batching' => [ - 'database' => env('DB_CONNECTION', 'mysql'), + 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'job_batches', ], @@ -102,7 +102,7 @@ return [ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'mysql'), + 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs', ], diff --git a/database/seeders/ServiceRequestSeeder.php b/database/seeders/ServiceRequestSeeder.php index 9d1ebf7..27e9954 100644 --- a/database/seeders/ServiceRequestSeeder.php +++ b/database/seeders/ServiceRequestSeeder.php @@ -16,73 +16,121 @@ class ServiceRequestSeeder extends Seeder { public function run(): void { - $types = RequestType::query()->get(); + $types = RequestType::query()->get(); $priorities = Priority::query()->orderBy('level')->get(); - $statuses = Status::query()->get(); - $clients = User::query()->where('role', 'client')->get(); - $techs = User::query()->where('role', 'technician')->get(); - $equipment = Equipment::query()->get(); + $statuses = Status::query()->get()->keyBy('slug'); + $clients = User::query()->where('role', 'client')->get(); + $techs = User::query()->where('role', 'technician')->get(); + $equipment = Equipment::query()->get(); - for ($i = 1; $i <= 25; $i++) { - $type = $types->random(); - $priority = $priorities->random(); - $status = $statuses->random(); - $client = $clients->random(); - $assignee = $techs->random(); - $createdAt = now()->subDays(rand(0, 20))->subHours(rand(0, 23)); - $deadlineAt = (clone $createdAt)->addHours($type->sla_hours); + // Распределение: 10 активных, 8 в ожидании/в работе с дедлайном сегодня-завтра, + // 4 решённых, 3 закрытых. + $buckets = [ + // [slug, count, deadline_offset_hours от now] + ['new', 4, [+48, +168]], // новые — дедлайн через 2-7 дней + ['in_progress', 6, [+4, +48]], // в работе — дедлайн через 4-48 ч + ['waiting', 4, [+24, +96]], // ожидание — дедлайн через 1-4 дня + ['resolved', 5, [-72, -1]], // решены — дедлайн в прошлом + ['closed', 4, [-168, -24]], // закрыты — дедлайн в прошлом + ['cancelled', 2, [-240, -48]], // отменены + ]; - $request = ServiceRequest::query()->create([ - 'ticket_number' => sprintf('SRV-%04d', $i), - 'title' => fake()->randomElement([ - 'Не включается рабочая станция', - 'Проблема с печатью документов', - 'Нет доступа к корпоративной сети', - 'Требуется установка ПО', - 'Сильный шум вентилятора', - ]), - 'description' => fake()->paragraph(3), - 'type_id' => $type->id, - 'priority_id' => $priority->id, - 'status_id' => $status->id, - 'client_id' => $client->id, - 'assigned_to' => $assignee->id, - 'equipment_id' => rand(0, 100) > 20 ? $equipment->random()?->id : null, - 'location' => fake()->randomElement(['Офис 101', 'Офис 204', 'Склад', 'Переговорная 2']), - 'planned_at' => rand(0, 100) > 50 ? $createdAt->copy()->addHours(rand(2, 24)) : null, - 'deadline_at' => $deadlineAt, - 'started_at' => rand(0, 100) > 40 ? $createdAt->copy()->addHours(rand(1, 8)) : null, - 'completed_at' => $status->is_final ? $deadlineAt->copy()->subHours(rand(0, 6)) : null, - 'resolution_notes' => $status->slug === 'resolved' || $status->slug === 'closed' - ? fake()->sentence(12) - : null, - 'client_rating' => $status->slug === 'closed' ? rand(3, 5) : null, - 'client_comment' => $status->slug === 'closed' ? fake()->sentence() : null, - 'created_at' => $createdAt, - 'updated_at' => now(), - ]); + $counter = 1; - for ($commentIndex = 1; $commentIndex <= rand(1, 3); $commentIndex++) { - $author = rand(0, 1) ? $client : $assignee; - RequestComment::query()->create([ - 'request_id' => $request->id, - 'user_id' => $author->id, - 'body' => fake()->sentence(rand(10, 20)), - 'is_internal' => $author->role !== 'client' && rand(0, 100) > 60, - 'created_at' => $createdAt->copy()->addHours($commentIndex), - 'updated_at' => $createdAt->copy()->addHours($commentIndex), + foreach ($buckets as [$slug, $count, [$dlMin, $dlMax]]) { + $status = $statuses[$slug] ?? $statuses->first(); + + for ($i = 0; $i < $count; $i++) { + $type = $types->random(); + $priority = $priorities->random(); + $client = $clients->random(); + $assignee = $techs->random(); + + $deadlineAt = now()->addHours(rand($dlMin, $dlMax)); + + // created_at: задолго до дедлайна, но реалистично + $createdAt = (clone $deadlineAt)->subHours($type->sla_hours + rand(0, 12)); + + // Не даём created_at быть в будущем + if ($createdAt->isFuture()) { + $createdAt = now()->subMinutes(rand(10, 120)); + } + + $startedAt = null; + $completedAt = null; + $resolutionNotes = null; + $clientRating = null; + $clientComment = null; + + if (in_array($slug, ['in_progress', 'resolved', 'closed'])) { + $startedAt = (clone $createdAt)->addHours(rand(1, 4)); + } + + if (in_array($slug, ['resolved', 'closed'])) { + $completedAt = (clone $deadlineAt)->subHours(rand(0, 6)); + $resolutionNotes = fake()->sentence(12); + } + + if ($slug === 'closed') { + $clientRating = rand(3, 5); + $clientComment = fake()->sentence(); + } + + $request = ServiceRequest::query()->create([ + 'ticket_number' => sprintf('SRV-%04d', $counter), + 'title' => fake()->randomElement([ + 'Не включается рабочая станция', + 'Проблема с печатью документов', + 'Нет доступа к корпоративной сети', + 'Требуется установка ПО', + 'Сильный шум вентилятора', + 'Зависает операционная система', + 'Не работает монитор', + 'Проблема с принтером', + ]), + 'description' => fake()->paragraph(3), + 'type_id' => $type->id, + 'priority_id' => $priority->id, + 'status_id' => $status->id, + 'client_id' => $client->id, + 'assigned_to' => $assignee->id, + 'equipment_id' => rand(0, 100) > 20 ? $equipment->random()?->id : null, + 'location' => fake()->randomElement(['Офис 101', 'Офис 204', 'Склад', 'Переговорная 2']), + 'planned_at' => rand(0, 100) > 50 ? (clone $createdAt)->addHours(rand(2, 24)) : null, + 'deadline_at' => $deadlineAt, + 'started_at' => $startedAt, + 'completed_at' => $completedAt, + 'resolution_notes' => $resolutionNotes, + 'client_rating' => $clientRating, + 'client_comment' => $clientComment, + 'created_at' => $createdAt, + 'updated_at' => now(), ]); - } - ActivityLog::query()->create([ - 'user_id' => $assignee->id, - 'model_type' => ServiceRequest::class, - 'model_id' => $request->id, - 'action' => 'created', - 'description' => "Создана заявка {$request->ticket_number}", - 'ip_address' => '127.0.0.1', - 'created_at' => $createdAt, - ]); + for ($c = 1; $c <= rand(1, 3); $c++) { + $author = rand(0, 1) ? $client : $assignee; + RequestComment::query()->create([ + 'request_id' => $request->id, + 'user_id' => $author->id, + 'body' => fake()->sentence(rand(10, 20)), + 'is_internal' => $author->role !== 'client' && rand(0, 100) > 60, + 'created_at' => (clone $createdAt)->addHours($c), + 'updated_at' => (clone $createdAt)->addHours($c), + ]); + } + + ActivityLog::query()->create([ + 'user_id' => $assignee->id, + 'model_type' => ServiceRequest::class, + 'model_id' => $request->id, + 'action' => 'created', + 'description' => "Создана заявка {$request->ticket_number}", + 'ip_address' => '127.0.0.1', + 'created_at' => $createdAt, + ]); + + $counter++; + } } } } diff --git a/database/sqlite/.gitkeep b/database/sqlite/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index c0f43cc..9f5d463 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,11 +15,9 @@ services: volumes: - storage_uploads:/var/www/html/storage/app/public - storage_logs:/var/www/html/storage/logs + - sqlite_data:/var/www/html/database/sqlite networks: - backend - depends_on: - mysql: - condition: service_healthy healthcheck: test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q 'successful' && echo ok || exit 1"] interval: 30s @@ -40,34 +38,13 @@ services: volumes: - storage_uploads:/var/www/html/storage/app/public - storage_logs:/var/www/html/storage/logs + - sqlite_data:/var/www/html/database/sqlite networks: - backend depends_on: app: condition: service_healthy - mysql: - image: mysql:8.0 - container_name: airservice_mysql - restart: unless-stopped - environment: - MYSQL_DATABASE: ${DB_DATABASE} - MYSQL_USER: ${DB_USERNAME} - MYSQL_PASSWORD: ${DB_PASSWORD} - MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} - expose: - - "3306" - volumes: - - mysql_data:/var/lib/mysql - networks: - - backend - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "--password=${DB_ROOT_PASSWORD}"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - nginx: build: context: . @@ -96,6 +73,6 @@ networks: driver: bridge volumes: - mysql_data: + sqlite_data: storage_uploads: storage_logs: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f4b16ca..2ed1083 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,16 @@ #!/bin/sh set -e +if [ "$DB_CONNECTION" = "sqlite" ]; then + DB_DIR=$(dirname "$DB_DATABASE") + mkdir -p "$DB_DIR" + if [ ! -f "$DB_DATABASE" ]; then + touch "$DB_DATABASE" + fi + chown -R www-data:www-data "$DB_DIR" + chmod 664 "$DB_DATABASE" +fi + php artisan migrate --force php artisan config:cache php artisan route:cache