3 Revize 2d97181384 ... 098300967a

Autor SHA1 Zpráva Datum
  Alexander Musikhin 098300967a fix filters, russian roles names před 3 týdny
  Alexander Musikhin 898b86edce import-export před 3 týdny
  Alexander Musikhin f980121868 fix tests před 3 týdny

+ 82 - 0
app/Console/Commands/DbExport.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Symfony\Component\Process\Process;
+
+class DbExport extends Command
+{
+    protected $signature = 'db:export
+                            {--path= : Путь для сохранения файла (по умолчанию storage/app/db-backups)}
+                            {--no-gzip : Не сжимать дамп gzip}
+                            {--connection=mysql : Имя соединения из config/database.php}';
+
+    protected $description = 'Полный SQL-дамп базы данных через mysqldump';
+
+    public function handle(): int
+    {
+        $connection = $this->option('connection');
+        $config = config("database.connections.{$connection}");
+
+        if (!$config || !in_array($config['driver'] ?? null, ['mysql', 'mariadb'], true)) {
+            $this->error("Соединение {$connection} не найдено или не MySQL/MariaDB.");
+            return self::FAILURE;
+        }
+
+        $database = $config['database'];
+        $host = $config['host'] ?? '127.0.0.1';
+        $port = (string) ($config['port'] ?? '3306');
+        $user = $config['username'] ?? 'root';
+        $password = (string) ($config['password'] ?? '');
+
+        $gzip = !$this->option('no-gzip');
+        $timestamp = date('Y-m-d_His');
+        $filename = "{$database}_{$timestamp}.sql" . ($gzip ? '.gz' : '');
+
+        $dir = $this->option('path') ?: storage_path('app/db-backups');
+        if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
+            $this->error("Не удалось создать каталог: {$dir}");
+            return self::FAILURE;
+        }
+
+        $filepath = rtrim($dir, '/') . '/' . $filename;
+
+        $cmd = sprintf(
+            'mysqldump --host=%s --port=%s --user=%s %s --single-transaction --quick --routines --triggers --events --hex-blob --default-character-set=utf8mb4 %s',
+            escapeshellarg($host),
+            escapeshellarg($port),
+            escapeshellarg($user),
+            $password !== '' ? '--password=' . escapeshellarg($password) : '',
+            escapeshellarg($database),
+        );
+
+        if ($gzip) {
+            $cmd .= ' | gzip';
+        }
+        $cmd .= ' > ' . escapeshellarg($filepath);
+
+        $this->info("Создаю дамп БД {$database} → {$filepath}");
+
+        $process = Process::fromShellCommandline($cmd);
+        $process->setTimeout(null);
+        $process->run(function ($type, $buffer) {
+            if ($type === Process::ERR) {
+                $this->getOutput()->write($buffer);
+            }
+        });
+
+        if (!$process->isSuccessful()) {
+            @unlink($filepath);
+            $this->error('Ошибка mysqldump: ' . $process->getErrorOutput());
+            return self::FAILURE;
+        }
+
+        $size = is_file($filepath) ? filesize($filepath) : 0;
+        $this->info(sprintf('Готово. Размер: %.2f MB', $size / 1024 / 1024));
+        $this->line($filepath);
+
+        return self::SUCCESS;
+    }
+}

+ 92 - 0
app/Console/Commands/DbImport.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Symfony\Component\Process\Process;
+
+class DbImport extends Command
+{
+    protected $signature = 'db:import
+                            {file : Путь к .sql или .sql.gz файлу}
+                            {--connection=mysql : Имя соединения из config/database.php}
+                            {--force : Не запрашивать подтверждение}
+                            {--drop : Очистить БД (DROP+CREATE) перед импортом}';
+
+    protected $description = 'Импорт SQL-дампа в базу данных через mysql';
+
+    public function handle(): int
+    {
+        $file = $this->argument('file');
+        if (!is_file($file)) {
+            $this->error("Файл не найден: {$file}");
+            return self::FAILURE;
+        }
+
+        $connection = $this->option('connection');
+        $config = config("database.connections.{$connection}");
+
+        if (!$config || !in_array($config['driver'] ?? null, ['mysql', 'mariadb'], true)) {
+            $this->error("Соединение {$connection} не найдено или не MySQL/MariaDB.");
+            return self::FAILURE;
+        }
+
+        $database = $config['database'];
+        $host = $config['host'] ?? '127.0.0.1';
+        $port = (string) ($config['port'] ?? '3306');
+        $user = $config['username'] ?? 'root';
+        $password = (string) ($config['password'] ?? '');
+
+        if (!$this->option('force') && !$this->confirm("Импортировать дамп в БД {$database}? Текущие данные будут перезаписаны.", false)) {
+            $this->warn('Отменено.');
+            return self::SUCCESS;
+        }
+
+        $authHost = sprintf(
+            '--host=%s --port=%s --user=%s %s',
+            escapeshellarg($host),
+            escapeshellarg($port),
+            escapeshellarg($user),
+            $password !== '' ? '--password=' . escapeshellarg($password) : '',
+        );
+
+        if ($this->option('drop')) {
+            $this->info("Пересоздаю БД {$database}...");
+            $sql = sprintf(
+                'DROP DATABASE IF EXISTS `%s`; CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;',
+                str_replace('`', '', $database),
+                str_replace('`', '', $database),
+            );
+            $dropCmd = "mysql {$authHost} -e " . escapeshellarg($sql);
+            $drop = Process::fromShellCommandline($dropCmd);
+            $drop->setTimeout(null);
+            $drop->run();
+            if (!$drop->isSuccessful()) {
+                $this->error('Ошибка пересоздания БД: ' . $drop->getErrorOutput());
+                return self::FAILURE;
+            }
+        }
+
+        $isGz = str_ends_with($file, '.gz');
+        $reader = $isGz ? 'gunzip -c ' : 'cat ';
+        $cmd = $reader . escapeshellarg($file) . " | mysql {$authHost} --default-character-set=utf8mb4 " . escapeshellarg($database);
+
+        $this->info("Импортирую {$file} → {$database}");
+
+        $process = Process::fromShellCommandline($cmd);
+        $process->setTimeout(null);
+        $process->run(function ($type, $buffer) {
+            if ($type === Process::ERR) {
+                $this->getOutput()->write($buffer);
+            }
+        });
+
+        if (!$process->isSuccessful()) {
+            $this->error('Ошибка импорта: ' . $process->getErrorOutput());
+            return self::FAILURE;
+        }
+
+        $this->info('Импорт завершён.');
+        return self::SUCCESS;
+    }
+}

+ 3 - 0
app/Http/Controllers/FilterController.php

@@ -59,6 +59,9 @@ class FilterController extends Controller
      * Ключ — имя таблицы, затем реальный столбец.
      */
     const VALUE_MAP = [
+        'users' => [
+            'role' => \App\Models\Role::NAMES,
+        ],
         'notifications' => [
             'type' => [
                 'platform' => 'Площадки',

+ 6 - 0
app/Http/Controllers/UserController.php

@@ -51,6 +51,12 @@ class UserController extends Controller
         $this->createFilters($model, 'role');
         $this->createDateFilters($model, 'created_at');
 
+        if (isset($this->data['filters']['role']['values'])) {
+            foreach ($this->data['filters']['role']['values'] as $key => $value) {
+                $this->data['filters']['role']['values'][$key] = \App\Models\Role::NAMES[$key] ?? $value;
+            }
+        }
+
         $q = $model::query();
         $this->acceptFilters($q, $request);
         $this->acceptSearch($q, $request);

+ 2 - 0
resources/views/partials/table.blade.php

@@ -231,6 +231,8 @@
                             @endif
                         @elseif($id === 'notifications' && $headerName === 'created_at')
                             {{ $string->created_at?->format('d.m.Y H:i') }}
+                        @elseif($id === 'users' && $headerName === 'role')
+                            {{ \App\Models\Role::NAMES[$string->role] ?? $string->role }}
                         @elseif($headerName === 'actions' && isset($routeName) && isset($string->id))
                             <a href="{{ route($routeName, $string->id) }}" class="btn btn-sm btn-outline-primary">
                                 Редактировать

+ 7 - 0
tests/Unit/Services/Import/ImportReclamationsServiceTest.php

@@ -167,6 +167,13 @@ class ImportReclamationsServiceTest extends TestCase
         $status = ReclamationStatus::query()->first();
         $this->assertNotNull($status, 'ReclamationStatusSeeder must create at least one status');
 
+        // Create a user and set it as the default MAF order user to avoid FK errors
+        $user = User::factory()->create();
+        \App\Models\Setting::set(
+            \App\Models\Setting::KEY_DEFAULT_MAF_ORDER_USER_ID,
+            $user->id
+        );
+
         $order = Order::factory()->create(['year' => (int) date('Y')]);
         $product = Product::factory()->create(['year' => (int) date('Y')]);
 

+ 7 - 0
tests/Unit/Services/ImportOrdersServiceTest.php

@@ -52,6 +52,13 @@ class ImportOrdersServiceTest extends TestCase
         $objectType = ObjectType::factory()->create(['name' => 'Детская площадка']);
         $orderStatus = OrderStatus::firstOrCreate(['name' => 'Новый']);
 
+        // Create a user for MAF orders and set it in settings to avoid FK errors
+        $mafUser = User::factory()->create(['name' => 'MAF User']);
+        \App\Models\Setting::set(
+            \App\Models\Setting::KEY_DEFAULT_MAF_ORDER_USER_ID,
+            $mafUser->id
+        );
+
         return compact('district', 'area', 'user', 'objectType', 'orderStatus');
     }