This commit is contained in:
sbb45
2026-06-02 22:18:38 +05:00
parent b81ab27553
commit 7b655ff3de
11 changed files with 212 additions and 129 deletions
+3 -8
View File
@@ -7,19 +7,14 @@ APP_URL=https://example.ru
LOG_CHANNEL=stderr LOG_CHANNEL=stderr
LOG_LEVEL=error LOG_LEVEL=error
DB_CONNECTION=mysql DB_CONNECTION=sqlite
DB_HOST=mysql DB_DATABASE=/var/www/html/database/sqlite/database.sqlite
DB_PORT=3306
DB_DATABASE=airservice
DB_USERNAME=airservice
DB_PASSWORD=
DB_ROOT_PASSWORD=
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
SESSION_DRIVER=database SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# S3 (опционально, если нужно хранение файлов) # S3 (опционально, если нужно хранение файлов)
+3 -3
View File
@@ -11,11 +11,11 @@ FROM php:8.2-fpm-alpine AS app
WORKDIR /var/www/html WORKDIR /var/www/html
RUN apk add --no-cache \ RUN apk add --no-cache \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \ libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev sqlite \
zip unzip git mysql-client zip unzip git
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ 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 COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+67 -23
View File
@@ -1,14 +1,14 @@
# AirService # 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 ```bash
git clone <repo-url> git clone <repo-url>
@@ -18,9 +18,10 @@ composer install
npm install npm install
cp .env.example .env cp .env.example .env
# Поменять APP_ENV=local, APP_DEBUG=true, APP_URL=http://localhost # Поменять APP_ENV=local, APP_DEBUG=true, APP_URL=http://localhost:8000
# Поменять DB_HOST=127.0.0.1, DB_USERNAME=root, убрать DB_ROOT_PASSWORD
php artisan key:generate php artisan key:generate
touch database/sqlite/database.sqlite
php artisan migrate --seed php artisan migrate --seed
``` ```
@@ -34,13 +35,22 @@ php artisan serve
npm run dev 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. Скопировать проект на сервер ### 1. Скопировать проект на сервер
@@ -49,7 +59,7 @@ ssh -i /root/.ssh/docker_vm_ed25519 crimson@10.20.0.20
sudo mkdir -p /opt/docker-apps/airservice sudo mkdir -p /opt/docker-apps/airservice
``` ```
Скопировать файлы (с локальной машины): С локальной машины:
```bash ```bash
rsync -avz --exclude='.git' --exclude='node_modules' --exclude='vendor' \ rsync -avz --exclude='.git' --exclude='node_modules' --exclude='vendor' \
@@ -67,27 +77,62 @@ nano .env
Обязательно заполнить: Обязательно заполнить:
```env ```env
APP_KEY= # php artisan key:generate --show APP_KEY= # сгенерировать ниже
APP_URL=https://your-domain.ru 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 ```bash
docker compose up -d --build docker compose up -d --build
docker compose ps
docker compose logs -f --tail=100
``` ```
Проверить локально на сервере: ### 4. Наполнить базу начальными данными (один раз)
```bash ```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 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 ```bash
ssh -i /root/.ssh/edge_vm_ed25519 crimson@192.168.0.80 ssh -i /root/.ssh/edge_vm_ed25519 crimson@192.168.0.80
@@ -120,9 +165,8 @@ sudo systemctl reload caddy
## Структура Docker-сервисов ## Структура Docker-сервисов
| Сервис | Описание | | Сервис | Описание |
|--------------------|-----------------------------------| |---|---|
| `airservice_app` | PHP-FPM (Laravel) | | `airservice_app` | PHP-FPM (Laravel) |
| `airservice_nginx` | nginx, публикует порт `18201` | | `airservice_nginx` | nginx, порт `18201` |
| `airservice_mysql` | MySQL 8.0, только внутри сети | | `airservice_queue` | Queue worker |
| `airservice_queue` | Queue worker (`queue:work`) |
+8
View File
@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -20,6 +21,13 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void 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 { try {
$companyName = Setting::query()->where('key', 'company_name')->value('value'); $companyName = Setting::query()->where('key', 'company_name')->value('value');
$timezone = Setting::query()->where('key', 'timezone')->value('value'); $timezone = Setting::query()->where('key', 'timezone')->value('value');
+6 -5
View File
@@ -25,10 +25,13 @@ class RequestService
{ {
return DB::transaction(function () use ($data, $actor): ServiceRequest { return DB::transaction(function () use ($data, $actor): ServiceRequest {
$payload = $this->preparePayload($data); $payload = $this->preparePayload($data);
$payload['ticket_number'] = $this->generateTicketNumber(); $payload['ticket_number'] = 'PENDING-' . uniqid('', true);
$request = ServiceRequest::query()->create($payload); $request = ServiceRequest::query()->create($payload);
$request->ticket_number = sprintf('SRV-%04d', $request->id);
$request->saveQuietly();
$this->persistAttachments($request, Arr::get($data, 'attachments', []), $actor); $this->persistAttachments($request, Arr::get($data, 'attachments', []), $actor);
event(new RequestCreated($request, $actor)); event(new RequestCreated($request, $actor));
@@ -111,11 +114,9 @@ class RequestService
return $map[$request->status?->slug ?? StatusSlug::NEW->value] ?? []; 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', $id);
return sprintf('SRV-%04d', $maxId + 1);
} }
private function preparePayload(array $data, ?ServiceRequest $request = null): array private function preparePayload(array $data, ?ServiceRequest $request = null): array
+1 -1
View File
@@ -15,7 +15,7 @@ return [
| |
*/ */
'default' => env('DB_CONNECTION', 'mysql'), 'default' => env('DB_CONNECTION', 'sqlite'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+2 -2
View File
@@ -85,7 +85,7 @@ return [
*/ */
'batching' => [ 'batching' => [
'database' => env('DB_CONNECTION', 'mysql'), 'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches', 'table' => 'job_batches',
], ],
@@ -102,7 +102,7 @@ return [
'failed' => [ 'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'), 'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs', 'table' => 'failed_jobs',
], ],
+109 -61
View File
@@ -16,73 +16,121 @@ class ServiceRequestSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
$types = RequestType::query()->get(); $types = RequestType::query()->get();
$priorities = Priority::query()->orderBy('level')->get(); $priorities = Priority::query()->orderBy('level')->get();
$statuses = Status::query()->get(); $statuses = Status::query()->get()->keyBy('slug');
$clients = User::query()->where('role', 'client')->get(); $clients = User::query()->where('role', 'client')->get();
$techs = User::query()->where('role', 'technician')->get(); $techs = User::query()->where('role', 'technician')->get();
$equipment = Equipment::query()->get(); $equipment = Equipment::query()->get();
for ($i = 1; $i <= 25; $i++) { // Распределение: 10 активных, 8 в ожидании/в работе с дедлайном сегодня-завтра,
$type = $types->random(); // 4 решённых, 3 закрытых.
$priority = $priorities->random(); $buckets = [
$status = $statuses->random(); // [slug, count, deadline_offset_hours от now]
$client = $clients->random(); ['new', 4, [+48, +168]], // новые — дедлайн через 2-7 дней
$assignee = $techs->random(); ['in_progress', 6, [+4, +48]], // в работе — дедлайн через 4-48 ч
$createdAt = now()->subDays(rand(0, 20))->subHours(rand(0, 23)); ['waiting', 4, [+24, +96]], // ожидание — дедлайн через 1-4 дня
$deadlineAt = (clone $createdAt)->addHours($type->sla_hours); ['resolved', 5, [-72, -1]], // решены — дедлайн в прошлом
['closed', 4, [-168, -24]], // закрыты — дедлайн в прошлом
['cancelled', 2, [-240, -48]], // отменены
];
$request = ServiceRequest::query()->create([ $counter = 1;
'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(),
]);
for ($commentIndex = 1; $commentIndex <= rand(1, 3); $commentIndex++) { foreach ($buckets as [$slug, $count, [$dlMin, $dlMax]]) {
$author = rand(0, 1) ? $client : $assignee; $status = $statuses[$slug] ?? $statuses->first();
RequestComment::query()->create([
'request_id' => $request->id, for ($i = 0; $i < $count; $i++) {
'user_id' => $author->id, $type = $types->random();
'body' => fake()->sentence(rand(10, 20)), $priority = $priorities->random();
'is_internal' => $author->role !== 'client' && rand(0, 100) > 60, $client = $clients->random();
'created_at' => $createdAt->copy()->addHours($commentIndex), $assignee = $techs->random();
'updated_at' => $createdAt->copy()->addHours($commentIndex),
$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([ for ($c = 1; $c <= rand(1, 3); $c++) {
'user_id' => $assignee->id, $author = rand(0, 1) ? $client : $assignee;
'model_type' => ServiceRequest::class, RequestComment::query()->create([
'model_id' => $request->id, 'request_id' => $request->id,
'action' => 'created', 'user_id' => $author->id,
'description' => "Создана заявка {$request->ticket_number}", 'body' => fake()->sentence(rand(10, 20)),
'ip_address' => '127.0.0.1', 'is_internal' => $author->role !== 'client' && rand(0, 100) > 60,
'created_at' => $createdAt, '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++;
}
} }
} }
} }
View File
+3 -26
View File
@@ -15,11 +15,9 @@ services:
volumes: volumes:
- storage_uploads:/var/www/html/storage/app/public - storage_uploads:/var/www/html/storage/app/public
- storage_logs:/var/www/html/storage/logs - storage_logs:/var/www/html/storage/logs
- sqlite_data:/var/www/html/database/sqlite
networks: networks:
- backend - backend
depends_on:
mysql:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q 'successful' && echo ok || exit 1"] test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q 'successful' && echo ok || exit 1"]
interval: 30s interval: 30s
@@ -40,34 +38,13 @@ services:
volumes: volumes:
- storage_uploads:/var/www/html/storage/app/public - storage_uploads:/var/www/html/storage/app/public
- storage_logs:/var/www/html/storage/logs - storage_logs:/var/www/html/storage/logs
- sqlite_data:/var/www/html/database/sqlite
networks: networks:
- backend - backend
depends_on: depends_on:
app: app:
condition: service_healthy 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: nginx:
build: build:
context: . context: .
@@ -96,6 +73,6 @@ networks:
driver: bridge driver: bridge
volumes: volumes:
mysql_data: sqlite_data:
storage_uploads: storage_uploads:
storage_logs: storage_logs:
+10
View File
@@ -1,6 +1,16 @@
#!/bin/sh #!/bin/sh
set -e 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 migrate --force
php artisan config:cache php artisan config:cache
php artisan route:cache php artisan route:cache