Ver código fonte

- fix imports in old year
- added console command clear year data
- added clear year data for admin in interface

Alexander Musikhin 1 dia atrás
pai
commit
ee7b0d26bf

+ 94 - 0
CLAUDE.md

@@ -0,0 +1,94 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Обзор проекта
+
+Stroyprofit CRM — Laravel 11 приложение для управления заказами строительных конструкций. Включает каталог товаров, управление заказами (обычные и МАФ), рекламации, генерацию документов и расписание.
+
+## Команды разработки
+
+### Docker и запуск
+```bash
+make up                   # Запуск проекта
+make stop                 # Остановка
+make install              # Полная установка с миграциями
+make application          # Bash в контейнер приложения
+```
+
+### База данных
+```bash
+make db-migrate           # Применить миграции
+make db-seed              # Запустить сидеры
+make db-migrate-refresh   # Сбросить БД и засеять
+make db-rollback          # Откатить последнюю миграцию
+```
+
+### Frontend
+```bash
+make frontend-build       # npm install + build
+npm run dev               # Vite dev server
+npm run build             # Production build
+```
+
+### Очереди и логи
+```bash
+make queue-restart        # Перезапуск воркера
+make queue-log            # Логи очереди
+make application-log      # Логи приложения
+```
+
+### Полное dev-окружение
+```bash
+composer dev              # Запуск server + queue + logs + vite параллельно
+```
+
+## Архитектура
+
+### Service Layer
+Бизнес-логика вынесена в `app/Services/`:
+- **Export/** — экспорт данных в Excel (Orders, MAFs, Schedules)
+- **Import/** — импорт из Excel (Orders, MAFs, Reclamations)
+- **Generate/** — генерация документов (Installation, Handover, Reclamation packs)
+
+### Queue Jobs
+Тяжёлые операции выполняются через очереди (`app/Jobs/`):
+- Экспорт/импорт данных
+- Генерация пакетов документов
+- Уведомления
+
+### Основные модели
+- **Order** / **MafOrder** — заказы клиентов и МАФ
+- **Product** / **ProductSKU** — каталог товаров
+- **Reclamation** — рекламации
+- **Contract** — договоры
+
+### Views для отчётов
+Используются database views для агрегации данных:
+- `OrderView`, `MafOrdersView`, `ReclamationView`
+
+## Технологический стек
+
+- **Backend:** PHP 8.2+, Laravel 11, MySQL 8, Redis
+- **Frontend:** Bootstrap 5, jQuery, Vite, Sass
+- **DevOps:** Docker, Nginx, Node.js WebSocket server
+
+## Особенности
+
+### Мультигодовая структура
+Данные сегментируются по годам. Текущий год хранится в сессии.
+
+### Шаблоны документов
+Excel-шаблоны находятся в `/templates/` — используются для экспорта и генерации документов.
+
+### WebSocket
+Node.js сервер в `docker/simple-ws/` для real-time обновлений. JWT-аутентификация через переменную `JWT_SECRET`.
+
+## Ключевые настройки .env
+
+```
+APP_LOCALE=ru
+APP_TIMEZONE=Europe/Moscow
+QUEUE_CONNECTION=redis
+PAGINATION_LIMIT=2000
+```

+ 217 - 0
app/Console/Commands/ClearYearData.php

@@ -0,0 +1,217 @@
+<?php
+
+namespace App\Console\Commands;
+
+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\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+
+class ClearYearData extends Command
+{
+    protected $signature = 'app:clear-year-data
+                            {year : Год для очистки данных}
+                            {--force : Выполнить без подтверждения}';
+
+    protected $description = 'Очистка всех данных CRM за указанный год и связанных зависимостей';
+
+    public function handle(): int
+    {
+        $year = (int) $this->argument('year');
+
+        if ($year < 2020 || $year > 2100) {
+            $this->error("Некорректный год: {$year}");
+            return self::FAILURE;
+        }
+
+        $stats = $this->collectStats($year);
+
+        $this->info("Данные для удаления за {$year} год:");
+        $this->table(
+            ['Сущность', 'Количество'],
+            collect($stats)->map(fn($count, $name) => [$name, $count])->toArray()
+        );
+
+        if (!$this->option('force')) {
+            if (!$this->confirm('Вы уверены, что хотите удалить все эти данные? Это действие необратимо!')) {
+                $this->info('Операция отменена.');
+                return self::SUCCESS;
+            }
+        }
+
+        $this->info('Начинаю удаление данных...');
+
+        DB::beginTransaction();
+
+        try {
+            $this->clearData($year);
+            DB::commit();
+            $this->info('Все данные за ' . $year . ' год успешно удалены.');
+            return self::SUCCESS;
+        } catch (\Exception $e) {
+            DB::rollBack();
+            $this->error('Ошибка при удалении данных: ' . $e->getMessage());
+            return self::FAILURE;
+        }
+    }
+
+    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();
+
+        // Собираем ID файлов для удаления
+        $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')
+        );
+
+        // Паспорта SKU
+        $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();
+    }
+
+    private function clearData(int $year): void
+    {
+        $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');
+        $reclamationIds = Reclamation::whereIn('order_id', $orderIds)->pluck('id');
+
+        // Собираем файлы до удаления связей
+        $fileIds = $this->collectFileIds($year, $orderIds, $productIds, $productSkuIds);
+
+        $this->info('Удаление рекламаций и связанных данных...');
+        // Детали рекламаций (cascadeOnDelete, но удаляем явно для уверенности)
+        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();
+
+        $this->info('Удаление связей заказов с файлами...');
+        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();
+
+        $this->info('Удаление расписаний...');
+        Schedule::whereIn('order_id', $orderIds)->delete();
+
+        $this->info('Удаление SKU продуктов...');
+        ProductSKU::withoutGlobalScopes()->withTrashed()->where('year', $year)->forceDelete();
+
+        $this->info('Удаление заказов...');
+        Order::withoutGlobalScopes()->withTrashed()->where('year', $year)->forceDelete();
+
+        $this->info('Удаление заказов МАФ...');
+        MafOrder::withoutGlobalScopes()->withTrashed()->where('year', $year)->forceDelete();
+
+        $this->info('Удаление продуктов...');
+        // Сначала обнуляем certificate_id чтобы избежать проблем с FK
+        Product::withoutGlobalScopes()->withTrashed()
+            ->whereIn('id', $productIds)
+            ->update(['certificate_id' => null]);
+        Product::withoutGlobalScopes()->withTrashed()->where('year', $year)->forceDelete();
+
+        $this->info('Удаление ТТН...');
+        // Обнуляем file_id перед удалением
+        Ttn::where('year', $year)->update(['file_id' => null]);
+        Ttn::where('year', $year)->delete();
+
+        $this->info('Удаление контрактов...');
+        Contract::where('year', $year)->delete();
+
+        $this->info('Удаление файлов...');
+        $this->deleteFiles($fileIds);
+    }
+
+    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();
+    }
+}

+ 164 - 0
app/Http/Controllers/ClearDataController.php

@@ -0,0 +1,164 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Jobs\ClearYearDataJob;
+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\Request;
+use Illuminate\View\View;
+
+class ClearDataController extends Controller
+{
+    public function index(): View
+    {
+        return view('clear-data.index', [
+            'active' => 'clear-data',
+            'years' => $this->getAvailableYears(),
+        ]);
+    }
+
+    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 destroy(Request $request)
+    {
+        $year = (int) $request->input('year');
+
+        if ($year < 2020 || $year > 2100) {
+            return redirect()->back()->with('danger', 'Некорректный год');
+        }
+
+        ClearYearDataJob::dispatch($year, $request->user()->id);
+
+        return redirect()->route('clear-data.index')->with('success', 'Удаление данных за ' . $year . ' год запущено. Вы получите уведомление о завершении.');
+    }
+
+    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 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();
+    }
+}

+ 174 - 0
app/Jobs/ClearYearDataJob.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Events\SendWebSocketMessageEvent;
+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 Exception;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+
+class ClearYearDataJob implements ShouldQueue
+{
+    use Queueable;
+
+    public function __construct(
+        private readonly int $year,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): void
+    {
+        try {
+            DB::beginTransaction();
+
+            $this->clearData();
+
+            DB::commit();
+
+            Log::info("Clear year data job done for year {$this->year}");
+
+            event(new SendWebSocketMessageEvent(
+                "Данные за {$this->year} год успешно удалены!",
+                $this->userId,
+                ['success' => true, 'year' => $this->year]
+            ));
+        } catch (Exception $e) {
+            DB::rollBack();
+
+            Log::error("Clear year data job failed for year {$this->year}: " . $e->getMessage());
+
+            event(new SendWebSocketMessageEvent(
+                "Ошибка удаления данных за {$this->year} год: " . $e->getMessage(),
+                $this->userId,
+                ['error' => $e->getMessage(), 'year' => $this->year]
+            ));
+        }
+    }
+
+    private function clearData(): void
+    {
+        $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);
+    }
+
+    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();
+    }
+}

+ 2 - 1
app/Services/ImportBaseService.php

@@ -61,8 +61,9 @@ class ImportBaseService
         return $headers == array_keys($this->headers);
     }
 
-    protected function findId(string $tableNameCol, string $value): string
+    protected function findId(string $tableNameCol, string|null $value): string
     {
+        if(!$value) return '';
         list($table, $column) = explode('.', $tableNameCol);
         $model = DB::table($table)
             ->where($column, $value)

+ 2 - 1
app/Services/ImportMafsService.php

@@ -65,7 +65,7 @@ class ImportMafsService extends ImportBaseService
                 $logMessage = "Row $strNumber: " . $r['orders.object_address'] . '. ';
                 $id = (int) $r['products_sku.id'];
                 $data = [
-                    'rfid' => (string) $r['products_sku.rfid'],
+                    'rfid'              => (string) $r['products_sku.rfid'],
                     'factory_number'    => (string) $r['products_sku.factory_number'],
                     'manufacture_date'  => (!is_null($r['products_sku.manufacture_date']) && DateHelper::isDate($r['products_sku.manufacture_date'])) ? DateHelper::getDateForDB($r['products_sku.manufacture_date']) : null,
                     'statement_number'  => (string) $r['products_sku.statement_number'],
@@ -77,6 +77,7 @@ class ImportMafsService extends ImportBaseService
                 $logMessage .= $r['products_sku.statement_date'] . ' ' . $r['products_sku.manufacture_date'] .' ';
 
                 $productSKU = ProductSKU::query()
+                    ->withoutGlobalScopes()
                     ->where('id', $id)
                     ->first();
                 if ($productSKU) {

+ 2 - 0
app/Services/ImportOrdersService.php

@@ -79,6 +79,7 @@ class ImportOrdersService extends ImportBaseService
 
                 $logMessage = "Row $strNumber: " . $r['orders.object_address'] . '. ';
                 $year = (int)$r['orders.year'];
+                session(['year' => $year]);
 
                 // округ
                 if (!($districtId = $this->findId('districts.shortname', $r['districts.name']))) {
@@ -108,6 +109,7 @@ class ImportOrdersService extends ImportBaseService
 
                 // product
                 $product = Product::query()
+                    ->withoutGlobalScopes()
                     ->where('year', $year)
                     ->where('nomenclature_number', $r['products.nomenclature_number'])
                     ->first();

+ 3 - 0
app/Services/ImportReclamationsService.php

@@ -82,6 +82,7 @@ class ImportReclamationsService extends ImportBaseService
 
                 // order
                 $order = Order::query()
+                    ->withoutGlobalScopes()
                     ->where('year', $year)
                     ->where('object_address', $r['orders.object_address'])
                     ->first();
@@ -94,6 +95,7 @@ class ImportReclamationsService extends ImportBaseService
 
                 // product
                 $product = Product::query()
+                    ->withoutGlobalScopes()
                     ->where('year', $year)
                     ->where('nomenclature_number', $r['products.nomenclature_number'])
                     ->first();
@@ -106,6 +108,7 @@ class ImportReclamationsService extends ImportBaseService
 
                 // check maf with this nomenclature number in order
                 $productSKU = ProductSKU::query()
+                    ->withoutGlobalScopes()
                     ->where('year', $year)
                     ->where('product_id', $product->id)
                     ->where('order_id', $order->id)

+ 1 - 0
app/Services/ImportService.php

@@ -65,6 +65,7 @@ class ImportService
                 if(!isset($record[4])) continue;
                 $certDate = (int) $record[18];
                 Product::query()
+                    ->withoutGlobalScopes()
                     ->updateOrCreate(['year' => $year, 'nomenclature_number' => $record[4]],
                     [
                         'article'               => (string) $record[1],

+ 391 - 0
package-lock.json

@@ -38,6 +38,278 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
+        "node_modules/@esbuild/aix-ppc64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
+            "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==",
+            "cpu": [
+                "ppc64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "aix"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-arm": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz",
+            "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz",
+            "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz",
+            "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/darwin-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz",
+            "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/darwin-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz",
+            "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/freebsd-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz",
+            "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/freebsd-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz",
+            "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-arm": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz",
+            "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz",
+            "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-ia32": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz",
+            "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==",
+            "cpu": [
+                "ia32"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-loong64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz",
+            "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==",
+            "cpu": [
+                "loong64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-mips64el": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz",
+            "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==",
+            "cpu": [
+                "mips64el"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-ppc64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz",
+            "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==",
+            "cpu": [
+                "ppc64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-riscv64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz",
+            "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==",
+            "cpu": [
+                "riscv64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-s390x": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz",
+            "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==",
+            "cpu": [
+                "s390x"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
         "node_modules/@esbuild/linux-x64": {
             "version": "0.24.0",
             "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz",
@@ -55,6 +327,125 @@
                 "node": ">=18"
             }
         },
+        "node_modules/@esbuild/netbsd-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz",
+            "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/openbsd-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz",
+            "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/openbsd-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz",
+            "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/sunos-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz",
+            "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "sunos"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-arm64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz",
+            "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-ia32": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz",
+            "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==",
+            "cpu": [
+                "ia32"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-x64": {
+            "version": "0.24.0",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz",
+            "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
         "node_modules/@isaacs/cliui": {
             "version": "8.0.2",
             "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",

+ 156 - 0
resources/views/clear-data/index.blade.php

@@ -0,0 +1,156 @@
+@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">
+            <div class="card">
+                <div class="card-header">
+                    Выберите год для удаления
+                </div>
+                <div class="card-body">
+                    @if(count($years) > 0)
+                        <div class="mb-3">
+                            <label for="year" class="form-label">Год</label>
+                            <select class="form-select" id="year" name="year">
+                                <option value="">-- Выберите год --</option>
+                                @foreach($years as $year)
+                                    <option value="{{ $year }}">{{ $year }}</option>
+                                @endforeach
+                            </select>
+                        </div>
+
+                        <button type="button" class="btn btn-primary" id="btnGetStats" disabled>
+                            Показать статистику
+                        </button>
+                    @else
+                        <div class="alert alert-info">
+                            Нет данных для удаления
+                        </div>
+                    @endif
+                </div>
+            </div>
+        </div>
+
+        <div class="col-md-6" id="statsBlock" style="display: none;">
+            <div class="card">
+                <div class="card-header">
+                    Статистика удаляемых данных за <span id="statsYear"></span> год
+                </div>
+                <div class="card-body">
+                    <table class="table table-sm table-striped" id="statsTable">
+                        <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="statsTotal"></th>
+                            </tr>
+                        </tfoot>
+                    </table>
+
+                    <div class="alert alert-danger">
+                        <strong>Внимание!</strong> Это действие необратимо. Все данные за выбранный год будут удалены безвозвратно.
+                    </div>
+
+                    <button type="button" class="btn btn-danger" id="btnDelete">
+                        Удалить данные
+                    </button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Модальное окно подтверждения -->
+    <div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header bg-danger text-white">
+                    <h5 class="modal-title">Подтверждение удаления</h5>
+                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <p>Вы уверены, что хотите удалить <strong>все данные</strong> за <strong id="confirmYear"></strong> год?</p>
+                    <p>Будет удалено записей: <strong id="confirmTotal"></strong></p>
+                    <p class="text-danger mb-0">Это действие <strong>необратимо</strong>!</p>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    <form action="{{ route('clear-data.destroy') }}" method="post" id="deleteForm">
+                        @csrf
+                        @method('DELETE')
+                        <input type="hidden" name="year" id="deleteYear">
+                        <button type="submit" class="btn btn-danger">Удалить</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+@endsection
+
+@push('scripts')
+<script type="module">
+    $(document).ready(function() {
+        let selectedYear = null;
+        let totalRecords = 0;
+
+        $('#year').on('change', function() {
+            selectedYear = $(this).val();
+            $('#btnGetStats').prop('disabled', !selectedYear);
+            $('#statsBlock').hide();
+        });
+
+        $('#btnGetStats').on('click', function() {
+            if (!selectedYear) return;
+
+            const btn = $(this);
+            btn.prop('disabled', true).text('Загрузка...');
+
+            $.ajax({
+                url: '{{ route('clear-data.stats') }}',
+                method: 'GET',
+                data: { year: selectedYear },
+                success: function(response) {
+                    $('#statsYear').text(response.year);
+                    $('#statsTotal').text(response.total);
+
+                    const tbody = $('#statsTable 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>`);
+                    }
+
+                    totalRecords = response.total;
+                    $('#statsBlock').show();
+                },
+                error: function(xhr) {
+                    alert('Ошибка получения статистики: ' + (xhr.responseJSON?.error || 'Неизвестная ошибка'));
+                },
+                complete: function() {
+                    btn.prop('disabled', false).text('Показать статистику');
+                }
+            });
+        });
+
+        $('#btnDelete').on('click', function() {
+            $('#confirmYear').text(selectedYear);
+            $('#confirmTotal').text(totalRecords);
+            $('#deleteYear').val(selectedYear);
+            let confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'), {});
+            confirmModal.show();
+        });
+    });
+</script>
+@endpush

+ 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('clear-data.index') }}">Удалить данные</a></li>
             </ul>
         </li>
 

+ 7 - 0
routes/web.php

@@ -1,6 +1,7 @@
 <?php
 
 use App\Http\Controllers\AreaController;
+use App\Http\Controllers\ClearDataController;
 use App\Http\Controllers\ContractController;
 use App\Http\Controllers\FilterController;
 use App\Http\Controllers\ImportController;
@@ -59,6 +60,12 @@ Route::middleware('auth:web')->group(function () {
             Route::get('{import}', [ImportController::class, 'show'])->name('import.show');
             Route::post('', [ImportController::class, 'store'])->name('import.create');
         });
+
+        Route::prefix('clear-data')->group(function (){
+            Route::get('', [ClearDataController::class, 'index'])->name('clear-data.index');
+            Route::get('stats', [ClearDataController::class, 'stats'])->name('clear-data.stats');
+            Route::delete('', [ClearDataController::class, 'destroy'])->name('clear-data.destroy');
+        });
     });
 
     // profile