Pārlūkot izejas kodu

- year export-import

Alexander Musikhin 1 dienu atpakaļ
vecāks
revīzija
7267e200f1

+ 61 - 0
app/Console/Commands/ExportYearData.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\Export\ExportYearDataJob;
+use App\Services\Export\ExportYearDataService;
+use Illuminate\Console\Command;
+
+class ExportYearData extends Command
+{
+    protected $signature = 'app:export-year-data
+                            {year : Год для экспорта данных}
+                            {--queue : Выполнить в очереди}
+                            {--user= : ID пользователя для уведомлений (по умолчанию 1)}';
+
+    protected $description = 'Экспорт всех данных CRM за указанный год в ZIP-архив';
+
+    public function handle(): int
+    {
+        $year = (int) $this->argument('year');
+        $userId = (int) ($this->option('user') ?? 1);
+
+        if ($year < 2020 || $year > 2100) {
+            $this->error("Некорректный год: {$year}");
+            return self::FAILURE;
+        }
+
+        $this->info("Экспорт данных за {$year} год");
+
+        if ($this->option('queue')) {
+            ExportYearDataJob::dispatch($year, $userId);
+            $this->info('Задание добавлено в очередь. Вы получите уведомление по завершении.');
+            return self::SUCCESS;
+        }
+
+        $this->info('Начинаю экспорт...');
+
+        try {
+            $service = new ExportYearDataService($year, $userId);
+            $downloadLink = $service->handle();
+            $stats = $service->getStats();
+
+            $this->info('Экспорт завершён!');
+            $this->newLine();
+
+            $this->info('Статистика:');
+            $this->table(
+                ['Сущность', 'Количество'],
+                collect($stats)->map(fn($count, $name) => [$name, $count])->toArray()
+            );
+
+            $this->newLine();
+            $this->info("Ссылка на скачивание: {$downloadLink}");
+
+            return self::SUCCESS;
+        } catch (\Exception $e) {
+            $this->error('Ошибка экспорта: ' . $e->getMessage());
+            return self::FAILURE;
+        }
+    }
+}

+ 93 - 0
app/Console/Commands/ImportYearData.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\Import\ImportYearDataJob;
+use App\Services\Import\ImportYearDataService;
+use Illuminate\Console\Command;
+
+class ImportYearData extends Command
+{
+    protected $signature = 'app:import-year-data
+                            {file : Путь к ZIP-архиву с данными}
+                            {year : Год для импорта данных}
+                            {--clear : Очистить существующие данные за год перед импортом}
+                            {--queue : Выполнить в очереди}
+                            {--user= : ID пользователя для уведомлений (по умолчанию 1)}';
+
+    protected $description = 'Импорт всех данных CRM за указанный год из ZIP-архива';
+
+    public function handle(): int
+    {
+        $filePath = $this->argument('file');
+        $year = (int) $this->argument('year');
+        $clearExisting = $this->option('clear');
+        $userId = (int) ($this->option('user') ?? 1);
+
+        if ($year < 2020 || $year > 2100) {
+            $this->error("Некорректный год: {$year}");
+            return self::FAILURE;
+        }
+
+        // Проверяем существование файла
+        if (!file_exists($filePath)) {
+            // Проверяем в storage
+            $storagePath = storage_path('app/public/' . $filePath);
+            if (file_exists($storagePath)) {
+                $filePath = $storagePath;
+            } else {
+                $this->error("Файл не найден: {$filePath}");
+                return self::FAILURE;
+            }
+        }
+
+        $this->info("Импорт данных за {$year} год из файла: {$filePath}");
+
+        if ($clearExisting) {
+            $this->warn('ВНИМАНИЕ: Существующие данные за этот год будут удалены!');
+            if (!$this->confirm('Продолжить?')) {
+                $this->info('Операция отменена.');
+                return self::SUCCESS;
+            }
+        }
+
+        if ($this->option('queue')) {
+            ImportYearDataJob::dispatch($filePath, $year, $userId, $clearExisting);
+            $this->info('Задание добавлено в очередь. Вы получите уведомление по завершении.');
+            return self::SUCCESS;
+        }
+
+        $this->info('Начинаю импорт...');
+
+        try {
+            $service = new ImportYearDataService($filePath, $year, $userId, $clearExisting);
+            $success = $service->handle();
+            $logs = $service->getLogs();
+
+            $this->newLine();
+            $this->info('Лог импорта:');
+
+            foreach ($logs as $log) {
+                $method = match ($log['level']) {
+                    'ERROR' => 'error',
+                    'WARNING' => 'warn',
+                    default => 'line',
+                };
+                $this->$method("[{$log['level']}] {$log['message']}");
+            }
+
+            $this->newLine();
+
+            if ($success) {
+                $this->info('Импорт успешно завершён!');
+                return self::SUCCESS;
+            } else {
+                $this->error('Импорт завершился с ошибками.');
+                return self::FAILURE;
+            }
+        } catch (\Exception $e) {
+            $this->error('Ошибка импорта: ' . $e->getMessage());
+            return self::FAILURE;
+        }
+    }
+}

+ 217 - 0
app/Http/Controllers/YearDataController.php

@@ -0,0 +1,217 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Jobs\Export\ExportYearDataJob;
+use App\Jobs\Import\ImportYearDataJob;
+use App\Models\Contract;
+use App\Models\File;
+use App\Models\MafOrder;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\Reclamation;
+use App\Models\Schedule;
+use App\Models\Ttn;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
+
+class YearDataController extends Controller
+{
+    public function index(): View
+    {
+        return view('year-data.index', [
+            'active' => 'year-data',
+            'years' => $this->getAvailableYears(),
+            'exports' => $this->getRecentExports(),
+        ]);
+    }
+
+    public function stats(Request $request): JsonResponse
+    {
+        $year = (int) $request->input('year');
+
+        if ($year < 2020 || $year > 2100) {
+            return response()->json(['error' => 'Некорректный год'], 422);
+        }
+
+        $stats = $this->collectStats($year);
+
+        return response()->json([
+            'year' => $year,
+            'stats' => $stats,
+            'total' => array_sum($stats),
+        ]);
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        $year = (int) $request->input('year');
+
+        if ($year < 2020 || $year > 2100) {
+            return redirect()->back()->with('danger', 'Некорректный год');
+        }
+
+        ExportYearDataJob::dispatch($year, $request->user()->id);
+
+        return redirect()->route('year-data.index')
+            ->with('success', "Экспорт данных за {$year} год запущен. Вы получите уведомление о завершении.");
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'year' => 'required|integer|min:2020|max:2100',
+            'import_file' => 'required|file|mimes:zip',
+        ]);
+
+        $year = (int) $request->input('year');
+        $clearExisting = $request->boolean('clear_existing');
+
+        // Сохраняем загруженный файл
+        $file = $request->file('import_file');
+        $path = 'import/year_data/' . Str::uuid() . '.zip';
+        Storage::disk('public')->put($path, $file->getContent());
+
+        $fullPath = storage_path('app/public/' . $path);
+
+        ImportYearDataJob::dispatch($fullPath, $year, $request->user()->id, $clearExisting);
+
+        $message = "Импорт данных за {$year} год запущен.";
+        if ($clearExisting) {
+            $message .= " Существующие данные будут очищены.";
+        }
+        $message .= " Вы получите уведомление о завершении.";
+
+        return redirect()->route('year-data.index')->with('success', $message);
+    }
+
+    public function download(File $file)
+    {
+        if (!$file->path || !Storage::disk('public')->exists($file->path)) {
+            abort(404, 'Файл не найден');
+        }
+
+        return Storage::disk('public')->download($file->path, $file->original_name);
+    }
+
+    private function getAvailableYears(): array
+    {
+        $years = [];
+
+        $orderYears = Order::withoutGlobalScopes()->withTrashed()
+            ->selectRaw('DISTINCT year')
+            ->pluck('year')
+            ->toArray();
+
+        $productYears = Product::withoutGlobalScopes()->withTrashed()
+            ->selectRaw('DISTINCT year')
+            ->pluck('year')
+            ->toArray();
+
+        $mafOrderYears = MafOrder::withoutGlobalScopes()->withTrashed()
+            ->selectRaw('DISTINCT year')
+            ->pluck('year')
+            ->toArray();
+
+        $contractYears = Contract::selectRaw('DISTINCT year')
+            ->pluck('year')
+            ->toArray();
+
+        $ttnYears = Ttn::selectRaw('DISTINCT year')
+            ->pluck('year')
+            ->toArray();
+
+        $years = array_unique(array_merge($orderYears, $productYears, $mafOrderYears, $contractYears, $ttnYears));
+        rsort($years);
+
+        return $years;
+    }
+
+    private function getRecentExports(): \Illuminate\Database\Eloquent\Collection
+    {
+        return File::where('original_name', 'like', 'year_data_%')
+            ->where('mime_type', 'application/zip')
+            ->orderBy('created_at', 'desc')
+            ->limit(10)
+            ->get();
+    }
+
+    private function collectStats(int $year): array
+    {
+        $orderIds = Order::withoutGlobalScopes()->withTrashed()->where('year', $year)->pluck('id');
+        $productIds = Product::withoutGlobalScopes()->withTrashed()->where('year', $year)->pluck('id');
+        $mafOrderIds = MafOrder::withoutGlobalScopes()->withTrashed()->where('year', $year)->pluck('id');
+        $productSkuIds = ProductSKU::withoutGlobalScopes()->withTrashed()->where('year', $year)->pluck('id');
+
+        $reclamationCount = Reclamation::whereIn('order_id', $orderIds)->count();
+        $fileIds = $this->collectFileIds($year, $orderIds, $productIds, $productSkuIds);
+
+        return [
+            'Заказы (Orders)' => $orderIds->count(),
+            'Заказы МАФ (MafOrders)' => $mafOrderIds->count(),
+            'Продукты (Products)' => $productIds->count(),
+            'SKU продуктов (ProductSKU)' => $productSkuIds->count(),
+            'Рекламации (Reclamations)' => $reclamationCount,
+            'Расписания (Schedules)' => Schedule::whereIn('order_id', $orderIds)->count(),
+            'ТТН (Ttns)' => Ttn::where('year', $year)->count(),
+            'Контракты (Contracts)' => Contract::where('year', $year)->count(),
+            'Файлы (Files)' => $fileIds->count(),
+        ];
+    }
+
+    private function collectFileIds(int $year, $orderIds, $productIds, $productSkuIds): \Illuminate\Support\Collection
+    {
+        $fileIds = collect();
+
+        $fileIds = $fileIds->merge(
+            DB::table('order_photo')->whereIn('order_id', $orderIds)->pluck('file_id')
+        );
+        $fileIds = $fileIds->merge(
+            DB::table('order_document')->whereIn('order_id', $orderIds)->pluck('file_id')
+        );
+        $fileIds = $fileIds->merge(
+            DB::table('order_statement')->whereIn('order_id', $orderIds)->pluck('file_id')
+        );
+
+        $reclamationIds = Reclamation::whereIn('order_id', $orderIds)->pluck('id');
+
+        $fileIds = $fileIds->merge(
+            DB::table('reclamation_photo_before')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id')
+        );
+        $fileIds = $fileIds->merge(
+            DB::table('reclamation_photo_after')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id')
+        );
+        $fileIds = $fileIds->merge(
+            DB::table('reclamation_document')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id')
+        );
+        $fileIds = $fileIds->merge(
+            DB::table('reclamation_act')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id')
+        );
+
+        $fileIds = $fileIds->merge(
+            Product::withoutGlobalScopes()->withTrashed()
+                ->whereIn('id', $productIds)
+                ->whereNotNull('certificate_id')
+                ->pluck('certificate_id')
+        );
+
+        $fileIds = $fileIds->merge(
+            ProductSKU::withoutGlobalScopes()->withTrashed()
+                ->whereIn('id', $productSkuIds)
+                ->whereNotNull('passport_id')
+                ->pluck('passport_id')
+        );
+
+        $fileIds = $fileIds->merge(
+            Ttn::where('year', $year)->whereNotNull('file_id')->pluck('file_id')
+        );
+
+        return $fileIds->unique();
+    }
+}

+ 67 - 0
app/Jobs/Export/ExportYearDataJob.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportYearDataService;
+use Exception;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+
+class ExportYearDataJob implements ShouldQueue
+{
+    use Queueable;
+
+    /**
+     * Количество попыток выполнения задания.
+     */
+    public int $tries = 1;
+
+    /**
+     * Таймаут выполнения задания (30 минут).
+     */
+    public int $timeout = 1800;
+
+    public function __construct(
+        private readonly int $year,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): void
+    {
+        try {
+            Log::info("ExportYearDataJob started for year {$this->year}");
+
+            $service = new ExportYearDataService($this->year, $this->userId);
+            $downloadLink = $service->handle();
+            $stats = $service->getStats();
+
+            Log::info("ExportYearDataJob completed for year {$this->year}", $stats);
+
+            event(new SendWebSocketMessageEvent(
+                "Экспорт данных за {$this->year} год завершён!",
+                $this->userId,
+                [
+                    'success' => true,
+                    'download' => $downloadLink,
+                    'year' => $this->year,
+                    'stats' => $stats,
+                ]
+            ));
+        } catch (Exception $e) {
+            Log::error("ExportYearDataJob failed for year {$this->year}: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                "Ошибка экспорта данных за {$this->year} год: " . $e->getMessage(),
+                $this->userId,
+                [
+                    'error' => $e->getMessage(),
+                    'year' => $this->year,
+                ]
+            ));
+        }
+    }
+}

+ 91 - 0
app/Jobs/Import/ImportYearDataJob.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Jobs\Import;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Import\ImportYearDataService;
+use Exception;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+
+class ImportYearDataJob implements ShouldQueue
+{
+    use Queueable;
+
+    /**
+     * Количество попыток выполнения задания.
+     */
+    public int $tries = 1;
+
+    /**
+     * Таймаут выполнения задания (60 минут).
+     */
+    public int $timeout = 3600;
+
+    public function __construct(
+        private readonly string $archivePath,
+        private readonly int $year,
+        private readonly int $userId,
+        private readonly bool $clearExisting = false,
+    ) {}
+
+    public function handle(): void
+    {
+        try {
+            Log::info("ImportYearDataJob started for year {$this->year}", [
+                'archive' => $this->archivePath,
+                'clear_existing' => $this->clearExisting,
+            ]);
+
+            $service = new ImportYearDataService(
+                $this->archivePath,
+                $this->year,
+                $this->userId,
+                $this->clearExisting
+            );
+
+            $success = $service->handle();
+            $logs = $service->getLogs();
+
+            if ($success) {
+                Log::info("ImportYearDataJob completed for year {$this->year}");
+
+                event(new SendWebSocketMessageEvent(
+                    "Импорт данных за {$this->year} год завершён!",
+                    $this->userId,
+                    [
+                        'success' => true,
+                        'year' => $this->year,
+                        'logs' => $logs,
+                    ]
+                ));
+            } else {
+                Log::error("ImportYearDataJob failed for year {$this->year}");
+
+                event(new SendWebSocketMessageEvent(
+                    "Ошибка импорта данных за {$this->year} год",
+                    $this->userId,
+                    [
+                        'error' => 'Импорт завершился с ошибками',
+                        'year' => $this->year,
+                        'logs' => $logs,
+                    ]
+                ));
+            }
+        } catch (Exception $e) {
+            Log::error("ImportYearDataJob failed for year {$this->year}: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                "Ошибка импорта данных за {$this->year} год: " . $e->getMessage(),
+                $this->userId,
+                [
+                    'error' => $e->getMessage(),
+                    'year' => $this->year,
+                ]
+            ));
+        }
+    }
+}

+ 814 - 0
app/Services/Export/ExportYearDataService.php

@@ -0,0 +1,814 @@
+<?php
+
+namespace App\Services\Export;
+
+use App\Models\Contract;
+use App\Models\File;
+use App\Models\MafOrder;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\Reclamation;
+use App\Models\ReclamationDetail;
+use App\Models\Schedule;
+use App\Models\Ttn;
+use Exception;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use ZipArchive;
+
+class ExportYearDataService
+{
+    private string $tempDir;
+    private string $dataDir;
+    private string $filesDir;
+    private array $stats = [];
+    private array $fileMapping = []; // file_id => archive_path
+
+    public function __construct(
+        private readonly int $year,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): string
+    {
+        $this->prepareTempDirectories();
+
+        try {
+            // Экспорт данных в Excel
+            $this->exportProducts();
+            $this->exportMafOrders();
+            $this->exportOrders();
+            $this->exportProductsSku();
+            $this->exportReclamations();
+            $this->exportSchedules();
+            $this->exportContracts();
+            $this->exportTtn();
+
+            // Копирование файлов и создание pivot таблицы
+            $this->copyFilesAndExportPivots();
+
+            // Создание манифеста
+            $this->createManifest();
+
+            // Создание ZIP архива
+            $archivePath = $this->createArchive();
+
+            return $archivePath;
+        } finally {
+            // Очистка временной директории
+            $this->cleanupTempDirectory();
+        }
+    }
+
+    private function prepareTempDirectories(): void
+    {
+        $this->tempDir = storage_path('app/temp/export_year_' . $this->year . '_' . Str::random(8));
+        $this->dataDir = $this->tempDir . '/data';
+        $this->filesDir = $this->tempDir . '/files';
+
+        if (!is_dir($this->dataDir)) {
+            mkdir($this->dataDir, 0755, true);
+        }
+        if (!is_dir($this->filesDir)) {
+            mkdir($this->filesDir, 0755, true);
+        }
+    }
+
+    /**
+     * Записывает строку данных в лист Excel
+     */
+    private function writeRow($sheet, int $row, array $data): void
+    {
+        $col = 1;
+        foreach ($data as $value) {
+            $cellAddress = Coordinate::stringFromColumnIndex($col) . $row;
+            $sheet->setCellValue($cellAddress, $value);
+            $col++;
+        }
+    }
+
+    private function exportProducts(): void
+    {
+        $products = Product::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->with('certificate')
+            ->cursor();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Products');
+
+        // Заголовки
+        $headers = [
+            'id', 'article', 'name_tz', 'type_tz', 'nomenclature_number', 'sizes',
+            'manufacturer', 'unit', 'type', 'product_price', 'installation_price',
+            'total_price', 'manufacturer_name', 'note', 'passport_name', 'statement_name',
+            'service_life', 'certificate_number', 'certificate_date', 'certificate_issuer',
+            'certificate_type', 'weight', 'volume', 'places', 'certificate_file',
+            'created_at', 'updated_at', 'deleted_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        $count = 0;
+        foreach ($products as $product) {
+            $certificatePath = '';
+            if ($product->certificate_id && $product->certificate) {
+                $certificatePath = $this->mapFileToArchive($product->certificate, 'products/certificates');
+            }
+
+            $this->writeRow($sheet, $row, [
+                $product->id,
+                $product->article,
+                $product->name_tz,
+                $product->type_tz,
+                $product->nomenclature_number,
+                $product->sizes,
+                $product->manufacturer,
+                $product->unit,
+                $product->type,
+                $product->getRawOriginal('product_price'),
+                $product->getRawOriginal('installation_price'),
+                $product->getRawOriginal('total_price'),
+                $product->manufacturer_name,
+                $product->note,
+                $product->passport_name,
+                $product->statement_name,
+                $product->service_life,
+                $product->certificate_number,
+                $product->certificate_date,
+                $product->certificate_issuer,
+                $product->certificate_type,
+                $product->weight,
+                $product->volume,
+                $product->places,
+                $certificatePath,
+                $product->created_at?->toIso8601String(),
+                $product->updated_at?->toIso8601String(),
+                $product->deleted_at?->toIso8601String(),
+            ]);
+
+            $row++;
+            $count++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/products.xlsx');
+        $this->stats['products'] = $count;
+    }
+
+    private function exportMafOrders(): void
+    {
+        $mafOrders = MafOrder::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->with(['user', 'product'])
+            ->cursor();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('MafOrders');
+
+        $headers = [
+            'id', 'order_number', 'status', 'user_id', 'user_name', 'product_id',
+            'product_nomenclature', 'quantity', 'in_stock', 'created_at', 'updated_at', 'deleted_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        $count = 0;
+        foreach ($mafOrders as $mafOrder) {
+            $this->writeRow($sheet, $row, [
+                $mafOrder->id,
+                $mafOrder->order_number,
+                $mafOrder->status,
+                $mafOrder->user_id,
+                $mafOrder->user?->name,
+                $mafOrder->product_id,
+                $mafOrder->product?->nomenclature_number,
+                $mafOrder->quantity,
+                $mafOrder->in_stock,
+                $mafOrder->created_at?->toIso8601String(),
+                $mafOrder->updated_at?->toIso8601String(),
+                $mafOrder->deleted_at?->toIso8601String(),
+            ]);
+
+            $row++;
+            $count++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/maf_orders.xlsx');
+        $this->stats['maf_orders'] = $count;
+    }
+
+    private function exportOrders(): void
+    {
+        $orders = Order::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->with(['user', 'district', 'area', 'objectType', 'brigadier', 'orderStatus'])
+            ->cursor();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Orders');
+
+        $headers = [
+            'id', 'name', 'user_id', 'user_name', 'district_id', 'district_shortname',
+            'area_id', 'area_name', 'object_address', 'object_type_id', 'object_type_name',
+            'comment', 'installation_date', 'ready_date', 'brigadier_id', 'brigadier_name',
+            'order_status_id', 'order_status_name', 'tg_group_name', 'tg_group_link',
+            'ready_to_mount', 'install_days', 'created_at', 'updated_at', 'deleted_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        $count = 0;
+        foreach ($orders as $order) {
+            $this->writeRow($sheet, $row, [
+                $order->id,
+                $order->name,
+                $order->user_id,
+                $order->user?->name,
+                $order->district_id,
+                $order->district?->shortname,
+                $order->area_id,
+                $order->area?->name,
+                $order->object_address,
+                $order->object_type_id,
+                $order->objectType?->name,
+                $order->comment,
+                $order->installation_date,
+                $order->ready_date,
+                $order->brigadier_id,
+                $order->brigadier?->name,
+                $order->order_status_id,
+                $order->orderStatus?->name,
+                $order->tg_group_name,
+                $order->tg_group_link,
+                $order->ready_to_mount,
+                $order->install_days,
+                $order->created_at?->toIso8601String(),
+                $order->updated_at?->toIso8601String(),
+                $order->deleted_at?->toIso8601String(),
+            ]);
+
+            $row++;
+            $count++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/orders.xlsx');
+        $this->stats['orders'] = $count;
+    }
+
+    private function exportProductsSku(): void
+    {
+        $skus = ProductSKU::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->with(['product', 'order', 'maf_order', 'passport'])
+            ->cursor();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('ProductsSKU');
+
+        $headers = [
+            'id', 'product_id', 'product_nomenclature', 'order_id', 'order_address',
+            'maf_order_id', 'maf_order_number', 'status', 'rfid', 'factory_number',
+            'manufacture_date', 'statement_number', 'statement_date', 'upd_number',
+            'comment', 'passport_file', 'created_at', 'updated_at', 'deleted_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        $count = 0;
+        foreach ($skus as $sku) {
+            $passportPath = '';
+            if ($sku->passport_id && $sku->passport) {
+                $passportPath = $this->mapFileToArchive($sku->passport, 'products_sku/passports');
+            }
+
+            $this->writeRow($sheet, $row, [
+                $sku->id,
+                $sku->product_id,
+                $sku->product?->nomenclature_number,
+                $sku->order_id,
+                $sku->order?->object_address,
+                $sku->maf_order_id,
+                $sku->maf_order?->order_number,
+                $sku->status,
+                $sku->rfid,
+                $sku->factory_number,
+                $sku->manufacture_date,
+                $sku->statement_number,
+                $sku->statement_date,
+                $sku->upd_number,
+                $sku->comment,
+                $passportPath,
+                $sku->created_at?->toIso8601String(),
+                $sku->updated_at?->toIso8601String(),
+                $sku->deleted_at?->toIso8601String(),
+            ]);
+
+            $row++;
+            $count++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/products_sku.xlsx');
+        $this->stats['products_sku'] = $count;
+    }
+
+    private function exportReclamations(): void
+    {
+        $orderIds = Order::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->pluck('id');
+
+        $reclamations = Reclamation::whereIn('order_id', $orderIds)
+            ->with(['order', 'user', 'brigadier', 'status'])
+            ->get();
+
+        $spreadsheet = new Spreadsheet();
+
+        // Лист 1: Reclamations
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Reclamations');
+
+        $headers = [
+            'id', 'order_id', 'order_address', 'user_id', 'user_name', 'status_id',
+            'status_name', 'reason', 'guarantee', 'whats_done', 'create_date',
+            'finish_date', 'start_work_date', 'work_days', 'brigadier_id',
+            'brigadier_name', 'comment', 'created_at', 'updated_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        foreach ($reclamations as $reclamation) {
+            $this->writeRow($sheet, $row, [
+                $reclamation->id,
+                $reclamation->order_id,
+                $reclamation->order?->object_address,
+                $reclamation->user_id,
+                $reclamation->user?->name,
+                $reclamation->status_id,
+                $reclamation->status?->name,
+                $reclamation->reason,
+                $reclamation->guarantee,
+                $reclamation->whats_done,
+                $reclamation->create_date,
+                $reclamation->finish_date,
+                $reclamation->start_work_date,
+                $reclamation->work_days,
+                $reclamation->brigadier_id,
+                $reclamation->brigadier?->name,
+                $reclamation->comment,
+                $reclamation->created_at?->toIso8601String(),
+                $reclamation->updated_at?->toIso8601String(),
+            ]);
+            $row++;
+        }
+
+        // Лист 2: ReclamationDetails
+        $sheet2 = $spreadsheet->createSheet();
+        $sheet2->setTitle('ReclamationDetails');
+
+        $headers2 = ['id', 'reclamation_id', 'name', 'quantity', 'created_at', 'updated_at'];
+        $this->writeRow($sheet2, 1, $headers2);
+
+        $reclamationIds = $reclamations->pluck('id');
+        $details = ReclamationDetail::whereIn('reclamation_id', $reclamationIds)->get();
+
+        $row = 2;
+        $detailsCount = 0;
+        foreach ($details as $detail) {
+            $this->writeRow($sheet2, $row, [
+                $detail->id,
+                $detail->reclamation_id,
+                $detail->name,
+                $detail->quantity,
+                $detail->created_at?->toIso8601String(),
+                $detail->updated_at?->toIso8601String(),
+            ]);
+            $row++;
+            $detailsCount++;
+        }
+
+        // Лист 3: ReclamationSKU (many-to-many)
+        $sheet3 = $spreadsheet->createSheet();
+        $sheet3->setTitle('ReclamationSKU');
+
+        $headers3 = ['reclamation_id', 'product_sku_id'];
+        $this->writeRow($sheet3, 1, $headers3);
+
+        $skuRelations = DB::table('reclamation_product_sku')
+            ->whereIn('reclamation_id', $reclamationIds)
+            ->get();
+
+        $row = 2;
+        foreach ($skuRelations as $relation) {
+            $this->writeRow($sheet3, $row, [
+                $relation->reclamation_id,
+                $relation->product_sku_id,
+            ]);
+            $row++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/reclamations.xlsx');
+        $this->stats['reclamations'] = $reclamations->count();
+        $this->stats['reclamation_details'] = $detailsCount;
+    }
+
+    private function exportSchedules(): void
+    {
+        $orderIds = Order::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->pluck('id');
+
+        $schedules = Schedule::whereIn('order_id', $orderIds)
+            ->with(['district', 'area', 'brigadier'])
+            ->get();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Schedules');
+
+        $headers = [
+            'id', 'installation_date', 'address_code', 'manual', 'source', 'order_id',
+            'district_id', 'district_shortname', 'area_id', 'area_name', 'object_address',
+            'object_type', 'mafs', 'mafs_count', 'brigadier_id', 'brigadier_name',
+            'comment', 'created_at', 'updated_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        foreach ($schedules as $schedule) {
+            $this->writeRow($sheet, $row, [
+                $schedule->id,
+                $schedule->installation_date,
+                $schedule->address_code,
+                $schedule->manual,
+                $schedule->source,
+                $schedule->order_id,
+                $schedule->district_id,
+                $schedule->district?->shortname,
+                $schedule->area_id,
+                $schedule->area?->name,
+                $schedule->object_address,
+                $schedule->object_type,
+                $schedule->mafs,
+                $schedule->mafs_count,
+                $schedule->brigadier_id,
+                $schedule->brigadier?->name,
+                $schedule->comment,
+                $schedule->created_at?->toIso8601String(),
+                $schedule->updated_at?->toIso8601String(),
+            ]);
+            $row++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/schedules.xlsx');
+        $this->stats['schedules'] = $schedules->count();
+    }
+
+    private function exportContracts(): void
+    {
+        $contracts = Contract::where('year', $this->year)->get();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Contracts');
+
+        $headers = ['id', 'contract_number', 'contract_date', 'created_at', 'updated_at'];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        foreach ($contracts as $contract) {
+            $this->writeRow($sheet, $row, [
+                $contract->id,
+                $contract->contract_number,
+                $contract->contract_date,
+                $contract->created_at?->toIso8601String(),
+                $contract->updated_at?->toIso8601String(),
+            ]);
+            $row++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/contracts.xlsx');
+        $this->stats['contracts'] = $contracts->count();
+    }
+
+    private function exportTtn(): void
+    {
+        $ttns = Ttn::where('year', $this->year)->with('file')->get();
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Ttn');
+
+        $headers = [
+            'id', 'ttn_number', 'ttn_number_suffix', 'order_number', 'order_date',
+            'order_sum', 'skus', 'file_path', 'created_at', 'updated_at'
+        ];
+
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        foreach ($ttns as $ttn) {
+            $filePath = '';
+            if ($ttn->file_id && $ttn->file) {
+                $filePath = $this->mapFileToArchive($ttn->file, 'ttn');
+            }
+
+            $this->writeRow($sheet, $row, [
+                $ttn->id,
+                $ttn->ttn_number,
+                $ttn->ttn_number_suffix,
+                $ttn->order_number,
+                $ttn->order_date,
+                $ttn->order_sum,
+                $ttn->skus,
+                $filePath,
+                $ttn->created_at?->toIso8601String(),
+                $ttn->updated_at?->toIso8601String(),
+            ]);
+            $row++;
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/ttn.xlsx');
+        $this->stats['ttn'] = $ttns->count();
+    }
+
+    private function copyFilesAndExportPivots(): void
+    {
+        $orderIds = Order::withoutGlobalScopes()
+            ->withTrashed()
+            ->where('year', $this->year)
+            ->pluck('id');
+
+        $reclamationIds = Reclamation::whereIn('order_id', $orderIds)->pluck('id');
+
+        $spreadsheet = new Spreadsheet();
+        $hasSheets = false;
+
+        // Order photos
+        if ($this->exportPivotSheet($spreadsheet, 'order_photo', 'order_id', $orderIds, 'orders', 'photos', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Order documents
+        if ($this->exportPivotSheet($spreadsheet, 'order_document', 'order_id', $orderIds, 'orders', 'documents', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Order statements
+        if ($this->exportPivotSheet($spreadsheet, 'order_statement', 'order_id', $orderIds, 'orders', 'statements', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Reclamation photos before
+        if ($this->exportPivotSheet($spreadsheet, 'reclamation_photo_before', 'reclamation_id', $reclamationIds, 'reclamations', 'photos_before', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Reclamation photos after
+        if ($this->exportPivotSheet($spreadsheet, 'reclamation_photo_after', 'reclamation_id', $reclamationIds, 'reclamations', 'photos_after', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Reclamation documents
+        if ($this->exportPivotSheet($spreadsheet, 'reclamation_document', 'reclamation_id', $reclamationIds, 'reclamations', 'documents', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Reclamation acts
+        if ($this->exportPivotSheet($spreadsheet, 'reclamation_act', 'reclamation_id', $reclamationIds, 'reclamations', 'acts', !$hasSheets)) {
+            $hasSheets = true;
+        }
+
+        // Если ничего не экспортировано, создаём пустой лист
+        if (!$hasSheets) {
+            $sheet = $spreadsheet->getActiveSheet();
+            $sheet->setTitle('empty');
+            $sheet->setCellValue('A1', 'No pivot data');
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->dataDir . '/pivot_tables.xlsx');
+
+        // Копируем все файлы из fileMapping
+        $this->copyMappedFiles();
+    }
+
+    private function exportPivotSheet(
+        Spreadsheet $spreadsheet,
+        string $tableName,
+        string $foreignKey,
+        Collection $ids,
+        string $entityFolder,
+        string $fileSubfolder,
+        bool $isFirstSheet
+    ): bool {
+        $records = DB::table($tableName)
+            ->whereIn($foreignKey, $ids)
+            ->get();
+
+        if ($records->isEmpty()) {
+            return false;
+        }
+
+        if ($isFirstSheet) {
+            $sheet = $spreadsheet->getActiveSheet();
+        } else {
+            $sheet = $spreadsheet->createSheet();
+        }
+        $sheet->setTitle($tableName);
+
+        $headers = [$foreignKey, 'file_id', 'file_archive_path'];
+        $this->writeRow($sheet, 1, $headers);
+
+        $row = 2;
+        foreach ($records as $record) {
+            $file = File::find($record->file_id);
+            $archivePath = '';
+
+            if ($file) {
+                $entityId = $record->$foreignKey;
+                $archivePath = $this->mapFileToArchive(
+                    $file,
+                    "{$entityFolder}/{$entityId}/{$fileSubfolder}"
+                );
+            }
+
+            $this->writeRow($sheet, $row, [
+                $record->$foreignKey,
+                $record->file_id,
+                $archivePath,
+            ]);
+            $row++;
+        }
+
+        return true;
+    }
+
+    private function mapFileToArchive(File $file, string $subPath): string
+    {
+        if (isset($this->fileMapping[$file->id])) {
+            return $this->fileMapping[$file->id]['archive_path'];
+        }
+
+        $extension = pathinfo($file->original_name ?? '', PATHINFO_EXTENSION);
+        $safeName = Str::slug(pathinfo($file->original_name ?? 'file', PATHINFO_FILENAME));
+        if (empty($safeName)) {
+            $safeName = 'file';
+        }
+        $archivePath = "files/{$subPath}/{$file->id}_{$safeName}.{$extension}";
+
+        $this->fileMapping[$file->id] = [
+            'archive_path' => $archivePath,
+            'storage_path' => $file->path,
+            'original_name' => $file->original_name,
+            'mime_type' => $file->mime_type,
+        ];
+
+        return $archivePath;
+    }
+
+    private function copyMappedFiles(): void
+    {
+        $filesCount = 0;
+
+        foreach ($this->fileMapping as $fileId => $fileData) {
+            $storagePath = $fileData['storage_path'];
+            $archivePath = $fileData['archive_path'];
+
+            if ($storagePath && Storage::disk('public')->exists($storagePath)) {
+                $targetPath = $this->tempDir . '/' . $archivePath;
+                $targetDir = dirname($targetPath);
+
+                if (!is_dir($targetDir)) {
+                    mkdir($targetDir, 0755, true);
+                }
+
+                $content = Storage::disk('public')->get($storagePath);
+                file_put_contents($targetPath, $content);
+                $filesCount++;
+            }
+        }
+
+        $this->stats['files'] = $filesCount;
+    }
+
+    private function createManifest(): void
+    {
+        $manifest = [
+            'version' => '1.0',
+            'exported_at' => now()->toIso8601String(),
+            'year' => $this->year,
+            'exported_by_user_id' => $this->userId,
+            'stats' => $this->stats,
+        ];
+
+        file_put_contents(
+            $this->tempDir . '/manifest.json',
+            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
+        );
+    }
+
+    private function createArchive(): string
+    {
+        $archiveName = "year_data_{$this->year}_" . date('Y-m-d_His') . '.zip';
+        $archivePath = storage_path('app/public/export/' . $archiveName);
+
+        // Создаем директорию если не существует
+        $exportDir = storage_path('app/public/export');
+        if (!is_dir($exportDir)) {
+            mkdir($exportDir, 0755, true);
+        }
+
+        $zip = new ZipArchive();
+        if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+            throw new Exception("Не удалось создать ZIP архив");
+        }
+
+        $files = new RecursiveIteratorIterator(
+            new RecursiveDirectoryIterator($this->tempDir),
+            RecursiveIteratorIterator::LEAVES_ONLY
+        );
+
+        foreach ($files as $file) {
+            if ($file->isDir()) {
+                continue;
+            }
+
+            $filePath = $file->getRealPath();
+            $relativePath = substr($filePath, strlen($this->tempDir) + 1);
+            $zip->addFile($filePath, $relativePath);
+        }
+
+        $zip->close();
+
+        // Создаем запись File в БД
+        File::create([
+            'user_id' => $this->userId,
+            'original_name' => $archiveName,
+            'mime_type' => 'application/zip',
+            'path' => 'export/' . $archiveName,
+            'link' => url('/storage/export/' . $archiveName),
+        ]);
+
+        // Возвращаем только имя файла (фронтенд добавляет /storage/export/ сам)
+        return $archiveName;
+    }
+
+    private function cleanupTempDirectory(): void
+    {
+        if (is_dir($this->tempDir)) {
+            $this->deleteDirectory($this->tempDir);
+        }
+    }
+
+    private function deleteDirectory(string $dir): void
+    {
+        if (!is_dir($dir)) {
+            return;
+        }
+
+        $files = array_diff(scandir($dir), ['.', '..']);
+        foreach ($files as $file) {
+            $path = $dir . '/' . $file;
+            is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
+        }
+        rmdir($dir);
+    }
+
+    public function getStats(): array
+    {
+        return $this->stats;
+    }
+}

+ 1188 - 0
app/Services/Import/ImportYearDataService.php

@@ -0,0 +1,1188 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\Contract;
+use App\Models\File;
+use App\Models\MafOrder;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\Reclamation;
+use App\Models\ReclamationDetail;
+use App\Models\Schedule;
+use App\Models\Ttn;
+use App\Models\User;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use ZipArchive;
+
+class ImportYearDataService
+{
+    private string $tempDir;
+    private array $manifest;
+    private array $logs = [];
+
+    // ID маппинги: old_id => new_id
+    private array $productIdMapping = [];
+    private array $mafOrderIdMapping = [];
+    private array $orderIdMapping = [];
+    private array $productSkuIdMapping = [];
+    private array $reclamationIdMapping = [];
+    private array $fileIdMapping = [];
+
+    // Справочники для маппинга
+    private array $districtMapping = [];
+    private array $areaMapping = [];
+    private array $userMapping = [];
+    private array $objectTypeMapping = [];
+    private array $orderStatusMapping = [];
+    private array $reclamationStatusMapping = [];
+
+    public function __construct(
+        private readonly string $archivePath,
+        private readonly int $year,
+        private readonly int $userId,
+        private readonly bool $clearExisting = false,
+    ) {}
+
+    public function handle(): bool
+    {
+        $this->prepareTempDirectory();
+
+        try {
+            $this->log("Начало импорта данных за {$this->year} год");
+
+            // Распаковка архива
+            $this->extractArchive();
+
+            // Валидация манифеста
+            $this->validateManifest();
+
+            // Загрузка справочников для маппинга
+            $this->loadDictionaries();
+
+            // Очистка существующих данных (опционально)
+            if ($this->clearExisting) {
+                $this->log("Очистка существующих данных за {$this->year} год...");
+                $this->clearExistingData();
+            }
+
+            DB::beginTransaction();
+
+            try {
+                // Импорт данных в правильном порядке
+                $this->importProducts();
+                $this->importMafOrders();
+                $this->importOrders();
+                $this->importProductsSku();
+                $this->importReclamations();
+                $this->importSchedules();
+                $this->importContracts();
+                $this->importTtn();
+
+                // Импорт pivot таблиц (файлы заказов и рекламаций)
+                $this->importPivotTables();
+
+                DB::commit();
+                $this->log("Импорт успешно завершён");
+
+                return true;
+            } catch (Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        } catch (Exception $e) {
+            $this->log("Ошибка импорта: " . $e->getMessage(), 'ERROR');
+            Log::error("ImportYearDataService error: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString()
+            ]);
+            return false;
+        } finally {
+            $this->cleanupTempDirectory();
+        }
+    }
+
+    private function prepareTempDirectory(): void
+    {
+        $this->tempDir = storage_path('app/temp/import_year_' . $this->year . '_' . Str::random(8));
+
+        if (!is_dir($this->tempDir)) {
+            mkdir($this->tempDir, 0755, true);
+        }
+    }
+
+    private function extractArchive(): void
+    {
+        $this->log("Распаковка архива...");
+
+        $zip = new ZipArchive();
+        if ($zip->open($this->archivePath) !== true) {
+            throw new Exception("Не удалось открыть архив");
+        }
+
+        $zip->extractTo($this->tempDir);
+        $zip->close();
+
+        $this->log("Архив распакован");
+    }
+
+    private function validateManifest(): void
+    {
+        $manifestPath = $this->tempDir . '/manifest.json';
+
+        if (!file_exists($manifestPath)) {
+            throw new Exception("Файл manifest.json не найден в архиве");
+        }
+
+        $this->manifest = json_decode(file_get_contents($manifestPath), true);
+
+        if (!$this->manifest) {
+            throw new Exception("Некорректный формат manifest.json");
+        }
+
+        if (!isset($this->manifest['version']) || !isset($this->manifest['year'])) {
+            throw new Exception("Отсутствуют обязательные поля в manifest.json");
+        }
+
+        $this->log("Манифест валиден. Версия: {$this->manifest['version']}, Год экспорта: {$this->manifest['year']}");
+
+        // Показываем статистику из манифеста
+        if (isset($this->manifest['stats'])) {
+            $this->log("Статистика из архива:");
+            foreach ($this->manifest['stats'] as $entity => $count) {
+                $this->log("  - {$entity}: {$count}");
+            }
+        }
+    }
+
+    private function loadDictionaries(): void
+    {
+        $this->log("Загрузка справочников...");
+
+        // Округа
+        $districts = DB::table('districts')->get();
+        foreach ($districts as $d) {
+            $this->districtMapping[$d->shortname] = $d->id;
+        }
+
+        // Районы
+        $areas = DB::table('areas')->get();
+        foreach ($areas as $a) {
+            $this->areaMapping[$a->name] = $a->id;
+        }
+
+        // Пользователи
+        $users = User::all();
+        foreach ($users as $u) {
+            $this->userMapping[$u->name] = $u->id;
+        }
+
+        // Типы объектов
+        $objectTypes = DB::table('object_types')->get();
+        foreach ($objectTypes as $ot) {
+            $this->objectTypeMapping[$ot->name] = $ot->id;
+        }
+
+        // Статусы заказов
+        $orderStatuses = DB::table('order_statuses')->get();
+        foreach ($orderStatuses as $os) {
+            $this->orderStatusMapping[$os->name] = $os->id;
+        }
+
+        // Статусы рекламаций
+        $reclamationStatuses = DB::table('reclamation_statuses')->get();
+        foreach ($reclamationStatuses as $rs) {
+            $this->reclamationStatusMapping[$rs->name] = $rs->id;
+        }
+
+        $this->log("Справочники загружены");
+    }
+
+    private function clearExistingData(): void
+    {
+        // Используем логику из ClearYearDataJob
+        $orderIds = Order::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->pluck('id');
+        $productIds = Product::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->pluck('id');
+        $productSkuIds = ProductSKU::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->pluck('id');
+        $reclamationIds = Reclamation::whereIn('order_id', $orderIds)->pluck('id');
+
+        // Собираем файлы до удаления связей
+        $fileIds = $this->collectFileIds($orderIds, $productIds, $productSkuIds);
+
+        // Рекламации
+        DB::table('reclamation_details')->whereIn('reclamation_id', $reclamationIds)->delete();
+        DB::table('reclamation_product_sku')->whereIn('reclamation_id', $reclamationIds)->delete();
+        DB::table('reclamation_photo_before')->whereIn('reclamation_id', $reclamationIds)->delete();
+        DB::table('reclamation_photo_after')->whereIn('reclamation_id', $reclamationIds)->delete();
+        DB::table('reclamation_document')->whereIn('reclamation_id', $reclamationIds)->delete();
+        DB::table('reclamation_act')->whereIn('reclamation_id', $reclamationIds)->delete();
+        Reclamation::whereIn('id', $reclamationIds)->delete();
+
+        // Связи заказов
+        DB::table('order_photo')->whereIn('order_id', $orderIds)->delete();
+        DB::table('order_document')->whereIn('order_id', $orderIds)->delete();
+        DB::table('order_statement')->whereIn('order_id', $orderIds)->delete();
+
+        // Расписания
+        Schedule::whereIn('order_id', $orderIds)->delete();
+
+        // SKU
+        ProductSKU::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->forceDelete();
+
+        // Заказы
+        Order::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->forceDelete();
+
+        // МАФ заказы
+        MafOrder::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->forceDelete();
+
+        // Продукты
+        Product::withoutGlobalScopes()->withTrashed()
+            ->whereIn('id', $productIds)
+            ->update(['certificate_id' => null]);
+        Product::withoutGlobalScopes()->withTrashed()->where('year', $this->year)->forceDelete();
+
+        // ТТН
+        Ttn::where('year', $this->year)->update(['file_id' => null]);
+        Ttn::where('year', $this->year)->delete();
+
+        // Контракты
+        Contract::where('year', $this->year)->delete();
+
+        // Файлы
+        $this->deleteFiles($fileIds);
+
+        $this->log("Существующие данные очищены");
+    }
+
+    private function collectFileIds($orderIds, $productIds, $productSkuIds): \Illuminate\Support\Collection
+    {
+        $fileIds = collect();
+
+        $fileIds = $fileIds->merge(DB::table('order_photo')->whereIn('order_id', $orderIds)->pluck('file_id'));
+        $fileIds = $fileIds->merge(DB::table('order_document')->whereIn('order_id', $orderIds)->pluck('file_id'));
+        $fileIds = $fileIds->merge(DB::table('order_statement')->whereIn('order_id', $orderIds)->pluck('file_id'));
+
+        $reclamationIds = Reclamation::whereIn('order_id', $orderIds)->pluck('id');
+
+        $fileIds = $fileIds->merge(DB::table('reclamation_photo_before')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id'));
+        $fileIds = $fileIds->merge(DB::table('reclamation_photo_after')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id'));
+        $fileIds = $fileIds->merge(DB::table('reclamation_document')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id'));
+        $fileIds = $fileIds->merge(DB::table('reclamation_act')->whereIn('reclamation_id', $reclamationIds)->pluck('file_id'));
+
+        $fileIds = $fileIds->merge(
+            Product::withoutGlobalScopes()->withTrashed()
+                ->whereIn('id', $productIds)
+                ->whereNotNull('certificate_id')
+                ->pluck('certificate_id')
+        );
+
+        $fileIds = $fileIds->merge(
+            ProductSKU::withoutGlobalScopes()->withTrashed()
+                ->whereIn('id', $productSkuIds)
+                ->whereNotNull('passport_id')
+                ->pluck('passport_id')
+        );
+
+        $fileIds = $fileIds->merge(
+            Ttn::where('year', $this->year)->whereNotNull('file_id')->pluck('file_id')
+        );
+
+        return $fileIds->unique();
+    }
+
+    private function deleteFiles($fileIds): void
+    {
+        $files = File::whereIn('id', $fileIds)->get();
+
+        foreach ($files as $file) {
+            if ($file->path && Storage::disk('public')->exists($file->path)) {
+                Storage::disk('public')->delete($file->path);
+            }
+        }
+
+        File::whereIn('id', $fileIds)->delete();
+    }
+
+    /**
+     * Безопасно получает значение из строки Excel
+     * Возвращает null для пустых строк, или значение по умолчанию
+     */
+    private function getValue(array $row, array $headerMap, string $key, mixed $default = null): mixed
+    {
+        if (!isset($headerMap[$key])) {
+            return $default;
+        }
+        $value = $row[$headerMap[$key]] ?? null;
+        if ($value === null || $value === '') {
+            return $default;
+        }
+        return $value;
+    }
+
+    /**
+     * Получает строковое значение (пустая строка вместо null)
+     */
+    private function getStringValue(array $row, array $headerMap, string $key, string $default = ''): string
+    {
+        $value = $this->getValue($row, $headerMap, $key);
+        return $value !== null ? (string) $value : $default;
+    }
+
+    /**
+     * Получает числовое значение
+     */
+    private function getNumericValue(array $row, array $headerMap, string $key, int|float $default = 0): int|float
+    {
+        $value = $this->getValue($row, $headerMap, $key);
+        if ($value === null) {
+            return $default;
+        }
+        return is_numeric($value) ? $value : $default;
+    }
+
+    private function importProducts(): void
+    {
+        $this->log("Импорт продуктов...");
+
+        $filePath = $this->tempDir . '/data/products.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл products.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldId = $this->getValue($row, $headerMap, 'id');
+            $nomenclatureNumber = $this->getStringValue($row, $headerMap, 'nomenclature_number', '');
+
+            // Проверяем существует ли продукт
+            $existing = Product::withoutGlobalScopes()
+                ->where('year', $this->year)
+                ->where('nomenclature_number', $nomenclatureNumber)
+                ->first();
+
+            // Импорт сертификата если есть
+            $certificateId = null;
+            $certificatePath = $this->getValue($row, $headerMap, 'certificate_file');
+            if ($certificatePath) {
+                $certificateId = $this->importFile($certificatePath);
+            }
+
+            $productData = [
+                'year' => $this->year,
+                'article' => $this->getStringValue($row, $headerMap, 'article'),
+                'name_tz' => $this->getValue($row, $headerMap, 'name_tz'),
+                'type_tz' => $this->getValue($row, $headerMap, 'type_tz'),
+                'nomenclature_number' => $nomenclatureNumber,
+                'sizes' => $this->getValue($row, $headerMap, 'sizes'),
+                'manufacturer' => $this->getValue($row, $headerMap, 'manufacturer'),
+                'unit' => $this->getStringValue($row, $headerMap, 'unit', 'шт.'),
+                'type' => $this->getStringValue($row, $headerMap, 'type', 'Товар'),
+                'product_price' => $this->getNumericValue($row, $headerMap, 'product_price', 0),
+                'installation_price' => $this->getNumericValue($row, $headerMap, 'installation_price', 0),
+                'total_price' => $this->getNumericValue($row, $headerMap, 'total_price', 0),
+                'manufacturer_name' => $this->getValue($row, $headerMap, 'manufacturer_name'),
+                'note' => $this->getValue($row, $headerMap, 'note'),
+                'passport_name' => $this->getValue($row, $headerMap, 'passport_name'),
+                'statement_name' => $this->getValue($row, $headerMap, 'statement_name'),
+                'service_life' => $this->getValue($row, $headerMap, 'service_life'),
+                'certificate_number' => $this->getValue($row, $headerMap, 'certificate_number'),
+                'certificate_date' => $this->getValue($row, $headerMap, 'certificate_date'),
+                'certificate_issuer' => $this->getValue($row, $headerMap, 'certificate_issuer'),
+                'certificate_type' => $this->getValue($row, $headerMap, 'certificate_type'),
+                'weight' => $this->getNumericValue($row, $headerMap, 'weight', 0),
+                'volume' => $this->getNumericValue($row, $headerMap, 'volume', 0),
+                'places' => $this->getNumericValue($row, $headerMap, 'places', 0),
+                'certificate_id' => $certificateId,
+            ];
+
+            if ($existing) {
+                $existing->update($productData);
+                $this->productIdMapping[$oldId] = $existing->id;
+            } else {
+                $product = Product::withoutGlobalScopes()->create($productData);
+                $this->productIdMapping[$oldId] = $product->id;
+            }
+
+            $count++;
+        }
+
+        $this->log("Импортировано продуктов: {$count}");
+    }
+
+    private function importMafOrders(): void
+    {
+        $this->log("Импорт заказов МАФ...");
+
+        $filePath = $this->tempDir . '/data/maf_orders.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл maf_orders.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldId = $this->getValue($row, $headerMap, 'id');
+            $oldProductId = $this->getValue($row, $headerMap, 'product_id');
+            $orderNumber = $this->getStringValue($row, $headerMap, 'order_number', '');
+
+            // Получаем новый product_id
+            $newProductId = $this->productIdMapping[$oldProductId] ?? null;
+            if (!$newProductId) {
+                // Пробуем найти по номенклатуре
+                $nomenclature = $this->getValue($row, $headerMap, 'product_nomenclature');
+                if ($nomenclature) {
+                    $product = Product::withoutGlobalScopes()
+                        ->where('year', $this->year)
+                        ->where('nomenclature_number', $nomenclature)
+                        ->first();
+                    $newProductId = $product?->id;
+                }
+            }
+
+            if (!$newProductId) {
+                $this->log("Пропуск MafOrder {$oldId}: продукт не найден", 'WARNING');
+                continue;
+            }
+
+            // Проверяем существует ли
+            $existing = MafOrder::withoutGlobalScopes()
+                ->where('year', $this->year)
+                ->where('product_id', $newProductId)
+                ->where('order_number', $orderNumber)
+                ->first();
+
+            // Получаем user_id
+            $userName = $this->getValue($row, $headerMap, 'user_name');
+            $userId = $this->userMapping[$userName] ?? $this->userId;
+
+            $mafOrderData = [
+                'year' => $this->year,
+                'order_number' => $orderNumber,
+                'status' => $this->getValue($row, $headerMap, 'status'),
+                'user_id' => $userId,
+                'product_id' => $newProductId,
+                'quantity' => $this->getNumericValue($row, $headerMap, 'quantity', 0),
+                'in_stock' => $this->getNumericValue($row, $headerMap, 'in_stock', 0),
+            ];
+
+            if ($existing) {
+                $existing->update($mafOrderData);
+                $this->mafOrderIdMapping[$oldId] = $existing->id;
+            } else {
+                $mafOrder = MafOrder::withoutGlobalScopes()->create($mafOrderData);
+                $this->mafOrderIdMapping[$oldId] = $mafOrder->id;
+            }
+
+            $count++;
+        }
+
+        $this->log("Импортировано заказов МАФ: {$count}");
+    }
+
+    private function importOrders(): void
+    {
+        $this->log("Импорт заказов (площадок)...");
+
+        $filePath = $this->tempDir . '/data/orders.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл orders.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldId = $this->getValue($row, $headerMap, 'id');
+            $objectAddress = $this->getStringValue($row, $headerMap, 'object_address', '');
+
+            // Проверяем существует ли
+            $existing = Order::withoutGlobalScopes()
+                ->where('year', $this->year)
+                ->where('object_address', $objectAddress)
+                ->first();
+
+            // Маппинг справочников
+            $districtShortname = $this->getValue($row, $headerMap, 'district_shortname');
+            $districtId = $this->districtMapping[$districtShortname] ?? null;
+
+            $areaName = $this->getValue($row, $headerMap, 'area_name');
+            $areaId = $this->areaMapping[$areaName] ?? null;
+
+            $userName = $this->getValue($row, $headerMap, 'user_name');
+            $userId = $this->userMapping[$userName] ?? $this->userId;
+
+            $objectTypeName = $this->getValue($row, $headerMap, 'object_type_name');
+            $objectTypeId = $this->objectTypeMapping[$objectTypeName] ?? null;
+
+            $brigadierName = $this->getValue($row, $headerMap, 'brigadier_name');
+            $brigadierId = $this->userMapping[$brigadierName] ?? null;
+
+            $orderStatusName = $this->getValue($row, $headerMap, 'order_status_name');
+            $orderStatusId = $this->orderStatusMapping[$orderStatusName] ?? Order::STATUS_NEW;
+
+            $orderData = [
+                'year' => $this->year,
+                'name' => $this->getValue($row, $headerMap, 'name'),
+                'user_id' => $userId,
+                'district_id' => $districtId,
+                'area_id' => $areaId,
+                'object_address' => $objectAddress,
+                'object_type_id' => $objectTypeId,
+                'comment' => $this->getValue($row, $headerMap, 'comment'),
+                'installation_date' => $this->getValue($row, $headerMap, 'installation_date'),
+                'ready_date' => $this->getValue($row, $headerMap, 'ready_date'),
+                'brigadier_id' => $brigadierId,
+                'order_status_id' => $orderStatusId,
+                'tg_group_name' => $this->getValue($row, $headerMap, 'tg_group_name'),
+                'tg_group_link' => $this->getValue($row, $headerMap, 'tg_group_link'),
+                'ready_to_mount' => $this->getValue($row, $headerMap, 'ready_to_mount'),
+                'install_days' => $this->getValue($row, $headerMap, 'install_days'),
+            ];
+
+            if ($existing) {
+                $existing->update($orderData);
+                $this->orderIdMapping[$oldId] = $existing->id;
+            } else {
+                $order = Order::withoutGlobalScopes()->create($orderData);
+                $this->orderIdMapping[$oldId] = $order->id;
+            }
+
+            $count++;
+        }
+
+        $this->log("Импортировано заказов: {$count}");
+    }
+
+    private function importProductsSku(): void
+    {
+        $this->log("Импорт SKU продуктов...");
+
+        $filePath = $this->tempDir . '/data/products_sku.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл products_sku.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldId = $this->getValue($row, $headerMap, 'id');
+
+            // Маппинг product_id
+            $oldProductId = $this->getValue($row, $headerMap, 'product_id');
+            $newProductId = $this->productIdMapping[$oldProductId] ?? null;
+
+            if (!$newProductId) {
+                // Пробуем найти по номенклатуре
+                $nomenclature = $this->getValue($row, $headerMap, 'product_nomenclature');
+                if ($nomenclature) {
+                    $product = Product::withoutGlobalScopes()
+                        ->where('year', $this->year)
+                        ->where('nomenclature_number', $nomenclature)
+                        ->first();
+                    $newProductId = $product?->id;
+                }
+            }
+
+            if (!$newProductId) {
+                $this->log("Пропуск SKU {$oldId}: продукт не найден", 'WARNING');
+                continue;
+            }
+
+            // Маппинг order_id
+            $oldOrderId = $this->getValue($row, $headerMap, 'order_id');
+            $newOrderId = $this->orderIdMapping[$oldOrderId] ?? null;
+
+            if (!$newOrderId) {
+                // Пробуем найти по адресу
+                $orderAddress = $this->getValue($row, $headerMap, 'order_address');
+                if ($orderAddress) {
+                    $order = Order::withoutGlobalScopes()
+                        ->where('year', $this->year)
+                        ->where('object_address', $orderAddress)
+                        ->first();
+                    $newOrderId = $order?->id;
+                }
+            }
+
+            if (!$newOrderId) {
+                $this->log("Пропуск SKU {$oldId}: заказ не найден", 'WARNING');
+                continue;
+            }
+
+            // Маппинг maf_order_id
+            $oldMafOrderId = $this->getValue($row, $headerMap, 'maf_order_id');
+            $newMafOrderId = null;
+            if ($oldMafOrderId) {
+                $newMafOrderId = $this->mafOrderIdMapping[$oldMafOrderId] ?? null;
+
+                if (!$newMafOrderId) {
+                    // Пробуем найти по номеру заказа
+                    $mafOrderNumber = $this->getValue($row, $headerMap, 'maf_order_number');
+                    if ($mafOrderNumber) {
+                        $mafOrder = MafOrder::withoutGlobalScopes()
+                            ->where('year', $this->year)
+                            ->where('product_id', $newProductId)
+                            ->where('order_number', $mafOrderNumber)
+                            ->first();
+                        $newMafOrderId = $mafOrder?->id;
+                    }
+                }
+            }
+
+            // Импорт паспорта если есть
+            $passportId = null;
+            $passportPath = $this->getValue($row, $headerMap, 'passport_file');
+            if ($passportPath) {
+                $passportId = $this->importFile($passportPath);
+            }
+
+            $skuData = [
+                'year' => $this->year,
+                'product_id' => $newProductId,
+                'order_id' => $newOrderId,
+                'maf_order_id' => $newMafOrderId,
+                'status' => $this->getValue($row, $headerMap, 'status'),
+                'rfid' => $this->getValue($row, $headerMap, 'rfid'),
+                'factory_number' => $this->getValue($row, $headerMap, 'factory_number'),
+                'manufacture_date' => $this->getValue($row, $headerMap, 'manufacture_date'),
+                'statement_number' => $this->getValue($row, $headerMap, 'statement_number'),
+                'statement_date' => $this->getValue($row, $headerMap, 'statement_date'),
+                'upd_number' => $this->getValue($row, $headerMap, 'upd_number'),
+                'comment' => $this->getValue($row, $headerMap, 'comment'),
+                'passport_id' => $passportId,
+            ];
+
+            $sku = ProductSKU::withoutGlobalScopes()->create($skuData);
+            $this->productSkuIdMapping[$oldId] = $sku->id;
+
+            $count++;
+        }
+
+        $this->log("Импортировано SKU: {$count}");
+    }
+
+    private function importReclamations(): void
+    {
+        $this->log("Импорт рекламаций...");
+
+        $filePath = $this->tempDir . '/data/reclamations.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл reclamations.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+
+        // Лист 1: Reclamations
+        $sheet = $spreadsheet->getSheetByName('Reclamations');
+        if (!$sheet) {
+            $this->log("Лист Reclamations не найден", 'WARNING');
+            return;
+        }
+
+        $rows = $sheet->toArray();
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldId = $this->getValue($row, $headerMap, 'id');
+
+            // Маппинг order_id
+            $oldOrderId = $this->getValue($row, $headerMap, 'order_id');
+            $newOrderId = $this->orderIdMapping[$oldOrderId] ?? null;
+
+            if (!$newOrderId) {
+                // Пробуем найти по адресу
+                $orderAddress = $this->getValue($row, $headerMap, 'order_address');
+                if ($orderAddress) {
+                    $order = Order::withoutGlobalScopes()
+                        ->where('year', $this->year)
+                        ->where('object_address', $orderAddress)
+                        ->first();
+                    $newOrderId = $order?->id;
+                }
+            }
+
+            if (!$newOrderId) {
+                $this->log("Пропуск рекламации {$oldId}: заказ не найден", 'WARNING');
+                continue;
+            }
+
+            $userName = $this->getValue($row, $headerMap, 'user_name');
+            $userId = $this->userMapping[$userName] ?? $this->userId;
+
+            $brigadierName = $this->getValue($row, $headerMap, 'brigadier_name');
+            $brigadierId = $this->userMapping[$brigadierName] ?? null;
+
+            $statusName = $this->getValue($row, $headerMap, 'status_name');
+            $statusId = $this->reclamationStatusMapping[$statusName] ?? Reclamation::STATUS_NEW;
+
+            $reclamationData = [
+                'order_id' => $newOrderId,
+                'user_id' => $userId,
+                'status_id' => $statusId,
+                'reason' => $this->getValue($row, $headerMap, 'reason'),
+                'guarantee' => $this->getValue($row, $headerMap, 'guarantee'),
+                'whats_done' => $this->getValue($row, $headerMap, 'whats_done'),
+                'create_date' => $this->getValue($row, $headerMap, 'create_date'),
+                'finish_date' => $this->getValue($row, $headerMap, 'finish_date'),
+                'start_work_date' => $this->getValue($row, $headerMap, 'start_work_date'),
+                'work_days' => $this->getValue($row, $headerMap, 'work_days'),
+                'brigadier_id' => $brigadierId,
+                'comment' => $this->getValue($row, $headerMap, 'comment'),
+            ];
+
+            $reclamation = Reclamation::create($reclamationData);
+            $this->reclamationIdMapping[$oldId] = $reclamation->id;
+
+            $count++;
+        }
+
+        $this->log("Импортировано рекламаций: {$count}");
+
+        // Лист 2: ReclamationDetails
+        $this->importReclamationDetails($spreadsheet);
+
+        // Лист 3: ReclamationSKU
+        $this->importReclamationSkuRelations($spreadsheet);
+    }
+
+    private function importReclamationDetails(Spreadsheet $spreadsheet): void
+    {
+        $sheet = $spreadsheet->getSheetByName('ReclamationDetails');
+        if (!$sheet) {
+            return;
+        }
+
+        $rows = $sheet->toArray();
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldReclamationId = $this->getValue($row, $headerMap, 'reclamation_id');
+            $newReclamationId = $this->reclamationIdMapping[$oldReclamationId] ?? null;
+
+            if (!$newReclamationId) {
+                continue;
+            }
+
+            ReclamationDetail::create([
+                'reclamation_id' => $newReclamationId,
+                'name' => $this->getStringValue($row, $headerMap, 'name', ''),
+                'quantity' => $this->getNumericValue($row, $headerMap, 'quantity', 0),
+            ]);
+
+            $count++;
+        }
+
+        $this->log("Импортировано деталей рекламаций: {$count}");
+    }
+
+    private function importReclamationSkuRelations(Spreadsheet $spreadsheet): void
+    {
+        $sheet = $spreadsheet->getSheetByName('ReclamationSKU');
+        if (!$sheet) {
+            return;
+        }
+
+        $rows = $sheet->toArray();
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            $oldReclamationId = $this->getValue($row, $headerMap, 'reclamation_id');
+            $oldProductSkuId = $this->getValue($row, $headerMap, 'product_sku_id');
+
+            if (!$oldReclamationId || !$oldProductSkuId) {
+                continue;
+            }
+
+            $newReclamationId = $this->reclamationIdMapping[$oldReclamationId] ?? null;
+            $newProductSkuId = $this->productSkuIdMapping[$oldProductSkuId] ?? null;
+
+            if (!$newReclamationId || !$newProductSkuId) {
+                continue;
+            }
+
+            DB::table('reclamation_product_sku')->insert([
+                'reclamation_id' => $newReclamationId,
+                'product_sku_id' => $newProductSkuId,
+            ]);
+
+            $count++;
+        }
+
+        $this->log("Импортировано связей рекламация-SKU: {$count}");
+    }
+
+    private function importSchedules(): void
+    {
+        $this->log("Импорт расписаний...");
+
+        $filePath = $this->tempDir . '/data/schedules.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл schedules.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            $oldOrderId = $this->getValue($row, $headerMap, 'order_id');
+            $newOrderId = $this->orderIdMapping[$oldOrderId] ?? null;
+
+            if (!$newOrderId) {
+                continue;
+            }
+
+            $districtShortname = $this->getValue($row, $headerMap, 'district_shortname');
+            $districtId = $this->districtMapping[$districtShortname] ?? null;
+
+            $areaName = $this->getValue($row, $headerMap, 'area_name');
+            $areaId = $this->areaMapping[$areaName] ?? null;
+
+            $brigadierName = $this->getValue($row, $headerMap, 'brigadier_name');
+            $brigadierId = $this->userMapping[$brigadierName] ?? null;
+
+            Schedule::create([
+                'installation_date' => $this->getValue($row, $headerMap, 'installation_date'),
+                'address_code' => $this->getValue($row, $headerMap, 'address_code'),
+                'manual' => $this->getValue($row, $headerMap, 'manual'),
+                'source' => $this->getValue($row, $headerMap, 'source'),
+                'order_id' => $newOrderId,
+                'district_id' => $districtId,
+                'area_id' => $areaId,
+                'object_address' => $this->getValue($row, $headerMap, 'object_address'),
+                'object_type' => $this->getValue($row, $headerMap, 'object_type'),
+                'mafs' => $this->getValue($row, $headerMap, 'mafs'),
+                'mafs_count' => $this->getNumericValue($row, $headerMap, 'mafs_count', 0),
+                'brigadier_id' => $brigadierId,
+                'comment' => $this->getValue($row, $headerMap, 'comment'),
+            ]);
+
+            $count++;
+        }
+
+        $this->log("Импортировано расписаний: {$count}");
+    }
+
+    private function importContracts(): void
+    {
+        $this->log("Импорт контрактов...");
+
+        $filePath = $this->tempDir . '/data/contracts.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл contracts.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            Contract::create([
+                'year' => $this->year,
+                'contract_number' => $this->getStringValue($row, $headerMap, 'contract_number', ''),
+                'contract_date' => $this->getValue($row, $headerMap, 'contract_date'),
+            ]);
+
+            $count++;
+        }
+
+        $this->log("Импортировано контрактов: {$count}");
+    }
+
+    private function importTtn(): void
+    {
+        $this->log("Импорт ТТН...");
+
+        $filePath = $this->tempDir . '/data/ttn.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл ttn.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+        $sheet = $spreadsheet->getActiveSheet();
+        $rows = $sheet->toArray();
+
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            if (empty($row[$headerMap['id']])) {
+                continue;
+            }
+
+            // Импорт файла ТТН если есть
+            $fileId = null;
+            $ttnFilePath = $this->getValue($row, $headerMap, 'file_path');
+            if ($ttnFilePath) {
+                $fileId = $this->importFile($ttnFilePath);
+            }
+
+            Ttn::create([
+                'year' => $this->year,
+                'ttn_number' => $this->getStringValue($row, $headerMap, 'ttn_number', ''),
+                'ttn_number_suffix' => $this->getStringValue($row, $headerMap, 'ttn_number_suffix', ''),
+                'order_number' => $this->getStringValue($row, $headerMap, 'order_number', ''),
+                'order_date' => $this->getValue($row, $headerMap, 'order_date'),
+                'order_sum' => $this->getNumericValue($row, $headerMap, 'order_sum', 0),
+                'skus' => $this->getValue($row, $headerMap, 'skus'),
+                'file_id' => $fileId,
+            ]);
+
+            $count++;
+        }
+
+        $this->log("Импортировано ТТН: {$count}");
+    }
+
+    private function importPivotTables(): void
+    {
+        $this->log("Импорт pivot таблиц (связей файлов)...");
+
+        $filePath = $this->tempDir . '/data/pivot_tables.xlsx';
+        if (!file_exists($filePath)) {
+            $this->log("Файл pivot_tables.xlsx не найден, пропуск", 'WARNING');
+            return;
+        }
+
+        $spreadsheet = IOFactory::load($filePath);
+
+        // Order photos
+        $this->importPivotSheet($spreadsheet, 'order_photo', 'order_id', $this->orderIdMapping);
+
+        // Order documents
+        $this->importPivotSheet($spreadsheet, 'order_document', 'order_id', $this->orderIdMapping);
+
+        // Order statements
+        $this->importPivotSheet($spreadsheet, 'order_statement', 'order_id', $this->orderIdMapping);
+
+        // Reclamation photos before
+        $this->importPivotSheet($spreadsheet, 'reclamation_photo_before', 'reclamation_id', $this->reclamationIdMapping);
+
+        // Reclamation photos after
+        $this->importPivotSheet($spreadsheet, 'reclamation_photo_after', 'reclamation_id', $this->reclamationIdMapping);
+
+        // Reclamation documents
+        $this->importPivotSheet($spreadsheet, 'reclamation_document', 'reclamation_id', $this->reclamationIdMapping);
+
+        // Reclamation acts
+        $this->importPivotSheet($spreadsheet, 'reclamation_act', 'reclamation_id', $this->reclamationIdMapping);
+    }
+
+    private function importPivotSheet(
+        Spreadsheet $spreadsheet,
+        string $sheetName,
+        string $foreignKey,
+        array $idMapping
+    ): void {
+        $sheet = $spreadsheet->getSheetByName($sheetName);
+        if (!$sheet) {
+            return;
+        }
+
+        $rows = $sheet->toArray();
+        $headers = array_shift($rows);
+        $headerMap = array_flip($headers);
+
+        $count = 0;
+        foreach ($rows as $row) {
+            $oldEntityId = $row[$headerMap[$foreignKey]] ?? null;
+            $archivePath = $row[$headerMap['file_archive_path']] ?? null;
+
+            if (!$oldEntityId || !$archivePath) {
+                continue;
+            }
+
+            $newEntityId = $idMapping[$oldEntityId] ?? null;
+            if (!$newEntityId) {
+                continue;
+            }
+
+            // Импортируем файл
+            $fileId = $this->importFile($archivePath);
+            if (!$fileId) {
+                continue;
+            }
+
+            // Вставляем связь
+            DB::table($sheetName)->insert([
+                $foreignKey => $newEntityId,
+                'file_id' => $fileId,
+            ]);
+
+            $count++;
+        }
+
+        if ($count > 0) {
+            $this->log("Импортировано связей {$sheetName}: {$count}");
+        }
+    }
+
+    private function importFile(string $archivePath): ?int
+    {
+        // Проверяем кэш
+        if (isset($this->fileIdMapping[$archivePath])) {
+            return $this->fileIdMapping[$archivePath];
+        }
+
+        $sourcePath = $this->tempDir . '/' . $archivePath;
+
+        if (!file_exists($sourcePath)) {
+            $this->log("Файл не найден: {$archivePath}", 'WARNING');
+            return null;
+        }
+
+        // Определяем путь для сохранения
+        $pathParts = explode('/', $archivePath);
+        array_shift($pathParts); // Убираем 'files'
+
+        $relativePath = implode('/', $pathParts);
+        $fileName = basename($archivePath);
+        $targetPath = dirname($relativePath) . '/' . $fileName;
+
+        // Сохраняем файл
+        $content = file_get_contents($sourcePath);
+        Storage::disk('public')->put($targetPath, $content);
+
+        // Создаем запись в БД
+        $originalName = preg_replace('/^\d+_/', '', $fileName); // Убираем ID из имени
+        $mimeType = mime_content_type($sourcePath) ?: 'application/octet-stream';
+
+        $file = File::create([
+            'user_id' => $this->userId,
+            'original_name' => $originalName,
+            'mime_type' => $mimeType,
+            'path' => $targetPath,
+            'link' => url('/storage/' . $targetPath),
+        ]);
+
+        $this->fileIdMapping[$archivePath] = $file->id;
+
+        return $file->id;
+    }
+
+    private function cleanupTempDirectory(): void
+    {
+        if (is_dir($this->tempDir)) {
+            $this->deleteDirectory($this->tempDir);
+        }
+    }
+
+    private function deleteDirectory(string $dir): void
+    {
+        if (!is_dir($dir)) {
+            return;
+        }
+
+        $files = array_diff(scandir($dir), ['.', '..']);
+        foreach ($files as $file) {
+            $path = $dir . '/' . $file;
+            is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
+        }
+        rmdir($dir);
+    }
+
+    private function log(string $message, string $level = 'INFO'): void
+    {
+        $this->logs[] = [
+            'level' => $level,
+            'message' => $message,
+            'timestamp' => now()->toIso8601String(),
+        ];
+
+        if ($level === 'ERROR') {
+            Log::error("ImportYearDataService: {$message}");
+        } else {
+            Log::info("ImportYearDataService: {$message}");
+        }
+    }
+
+    public function getLogs(): array
+    {
+        return $this->logs;
+    }
+}

+ 2 - 1
composer.json

@@ -13,7 +13,8 @@
         "laravel/ui": "^4.6",
         "phpoffice/phpspreadsheet": "^5.1",
         "predis/predis": "^2.3",
-        "syntech/syntechfcm": "^1.3"
+        "syntech/syntechfcm": "^1.3",
+        "ext-fileinfo": "*"
     },
     "require-dev": {
         "fakerphp/faker": "^1.23",

+ 1 - 0
resources/views/layouts/menu.blade.php

@@ -30,6 +30,7 @@
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>
+                <li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>
             </ul>
         </li>

+ 219 - 0
resources/views/year-data/index.blade.php

@@ -0,0 +1,219 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3">
+        <div class="col-12">
+            <h3>Экспорт и импорт данных за год</h3>
+        </div>
+    </div>
+
+    <div class="row">
+        <!-- Экспорт -->
+        <div class="col-md-6 mb-4">
+            <div class="card">
+                <div class="card-header bg-primary text-white">
+                    <i class="bi bi-download me-2"></i>Экспорт данных
+                </div>
+                <div class="card-body">
+                    @if(count($years) > 0)
+                        <p class="text-muted">Экспортирует все данные за выбранный год в ZIP-архив, включая файлы (фото, документы, сертификаты, паспорта).</p>
+
+                        <div class="mb-3">
+                            <label for="exportYear" class="form-label">Год для экспорта</label>
+                            <select class="form-select" id="exportYear" name="year">
+                                <option value="">-- Выберите год --</option>
+                                @foreach($years as $year)
+                                    <option value="{{ $year }}">{{ $year }}</option>
+                                @endforeach
+                            </select>
+                        </div>
+
+                        <button type="button" class="btn btn-outline-primary" id="btnGetExportStats" disabled>
+                            Показать статистику
+                        </button>
+
+                        <div id="exportStatsBlock" class="mt-3" style="display: none;">
+                            <h6>Данные для экспорта за <span id="exportStatsYear"></span> год:</h6>
+                            <table class="table table-sm table-striped" id="exportStatsTable">
+                                <thead>
+                                    <tr>
+                                        <th>Сущность</th>
+                                        <th class="text-end">Количество</th>
+                                    </tr>
+                                </thead>
+                                <tbody></tbody>
+                                <tfoot>
+                                    <tr class="table-dark">
+                                        <th>Итого</th>
+                                        <th class="text-end" id="exportStatsTotal"></th>
+                                    </tr>
+                                </tfoot>
+                            </table>
+
+                            <form action="{{ route('year-data.export') }}" method="post">
+                                @csrf
+                                <input type="hidden" name="year" id="exportYearInput">
+                                <button type="submit" class="btn btn-primary">
+                                    <i class="bi bi-download me-2"></i>Начать экспорт
+                                </button>
+                            </form>
+                        </div>
+                    @else
+                        <div class="alert alert-info mb-0">
+                            Нет данных для экспорта
+                        </div>
+                    @endif
+                </div>
+            </div>
+        </div>
+
+        <!-- Импорт -->
+        <div class="col-md-6 mb-4">
+            <div class="card">
+                <div class="card-header bg-success text-white">
+                    <i class="bi bi-upload me-2"></i>Импорт данных
+                </div>
+                <div class="card-body">
+                    <p class="text-muted">Импортирует данные из ранее созданного ZIP-архива экспорта.</p>
+
+                    <form action="{{ route('year-data.import') }}" method="post" enctype="multipart/form-data">
+                        @csrf
+                        <div class="mb-3">
+                            <label for="importYear" class="form-label">Год для импорта</label>
+                            <input type="number" class="form-control" id="importYear" name="year"
+                                   min="2020" max="2100" value="{{ date('Y') }}" required>
+                            <div class="form-text">Год, в который будут импортированы данные</div>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="importFile" class="form-label">ZIP-архив с данными</label>
+                            <input type="file" class="form-control" id="importFile" name="import_file"
+                                   accept=".zip" required>
+                        </div>
+
+                        <div class="mb-3 form-check">
+                            <input type="checkbox" class="form-check-input" id="clearExisting" name="clear_existing" value="1">
+                            <label class="form-check-label text-danger" for="clearExisting">
+                                <strong>Очистить существующие данные</strong> за этот год перед импортом
+                            </label>
+                        </div>
+
+                        <div class="alert alert-warning">
+                            <i class="bi bi-exclamation-triangle me-2"></i>
+                            <strong>Внимание!</strong> При включенной опции очистки все существующие данные за указанный год будут удалены перед импортом.
+                        </div>
+
+                        <button type="submit" class="btn btn-success" id="btnImport">
+                            <i class="bi bi-upload me-2"></i>Начать импорт
+                        </button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Недавние экспорты -->
+    @if($exports->count() > 0)
+    <div class="row">
+        <div class="col-12">
+            <div class="card">
+                <div class="card-header">
+                    <i class="bi bi-clock-history me-2"></i>Недавние экспорты
+                </div>
+                <div class="card-body">
+                    <table class="table table-striped table-hover">
+                        <thead>
+                            <tr>
+                                <th>Файл</th>
+                                <th>Дата создания</th>
+                                <th>Действия</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            @foreach($exports as $export)
+                            <tr>
+                                <td>{{ $export->original_name }}</td>
+                                <td>{{ $export->created_at->format('d.m.Y H:i') }}</td>
+                                <td>
+                                    <a href="{{ $export->link }}" class="btn btn-sm btn-outline-primary" target="_blank">
+                                        <i class="bi bi-download"></i> Скачать
+                                    </a>
+                                </td>
+                            </tr>
+                            @endforeach
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+    @endif
+@endsection
+
+@push('scripts')
+<script type="module">
+    $(document).ready(function() {
+        let selectedExportYear = null;
+
+        $('#exportYear').on('change', function() {
+            selectedExportYear = $(this).val();
+            $('#btnGetExportStats').prop('disabled', !selectedExportYear);
+            $('#exportStatsBlock').hide();
+        });
+
+        $('#btnGetExportStats').on('click', function() {
+            if (!selectedExportYear) return;
+
+            const btn = $(this);
+            btn.prop('disabled', true).text('Загрузка...');
+
+            $.ajax({
+                url: '{{ route('year-data.stats') }}',
+                method: 'GET',
+                data: { year: selectedExportYear },
+                success: function(response) {
+                    $('#exportStatsYear').text(response.year);
+                    $('#exportStatsTotal').text(response.total);
+                    $('#exportYearInput').val(response.year);
+
+                    const tbody = $('#exportStatsTable tbody');
+                    tbody.empty();
+
+                    for (const [entity, count] of Object.entries(response.stats)) {
+                        tbody.append(`<tr><td>${entity}</td><td class="text-end">${count}</td></tr>`);
+                    }
+
+                    $('#exportStatsBlock').show();
+                },
+                error: function(xhr) {
+                    alert('Ошибка получения статистики: ' + (xhr.responseJSON?.error || 'Неизвестная ошибка'));
+                },
+                complete: function() {
+                    btn.prop('disabled', false).text('Показать статистику');
+                }
+            });
+        });
+
+        // Валидация формы импорта
+        $('form[action="{{ route('year-data.import') }}"]').on('submit', function(e) {
+            const file = $('#importFile').val();
+            const clearExisting = $('#clearExisting').is(':checked');
+
+            if (!file) {
+                e.preventDefault();
+                alert('Пожалуйста, выберите ZIP-архив для импорта');
+                return false;
+            }
+
+            if (clearExisting) {
+                if (!confirm('Вы уверены, что хотите очистить существующие данные за этот год? Это действие необратимо!')) {
+                    e.preventDefault();
+                    return false;
+                }
+            }
+
+            $('#btnImport').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-2"></span>Загрузка...');
+        });
+    });
+</script>
+@endpush

+ 9 - 0
routes/web.php

@@ -2,6 +2,7 @@
 
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
+use App\Http\Controllers\YearDataController;
 use App\Http\Controllers\ContractController;
 use App\Http\Controllers\FilterController;
 use App\Http\Controllers\ImportController;
@@ -66,6 +67,14 @@ Route::middleware('auth:web')->group(function () {
             Route::get('stats', [ClearDataController::class, 'stats'])->name('clear-data.stats');
             Route::delete('', [ClearDataController::class, 'destroy'])->name('clear-data.destroy');
         });
+
+        Route::prefix('year-data')->group(function (){
+            Route::get('', [YearDataController::class, 'index'])->name('year-data.index');
+            Route::get('stats', [YearDataController::class, 'stats'])->name('year-data.stats');
+            Route::post('export', [YearDataController::class, 'export'])->name('year-data.export');
+            Route::post('import', [YearDataController::class, 'import'])->name('year-data.import');
+            Route::get('download/{file}', [YearDataController::class, 'download'])->name('year-data.download');
+        });
     });
 
     // profile