Jelajahi Sumber

справочники, тесты

Alexander Musikhin 2 hari lalu
induk
melakukan
924b9716f4

+ 134 - 0
app/Http/Controllers/Admin/AdminAreaController.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Services\Export\ExportAreasService;
+use App\Services\Import\ImportAreasService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\View\View;
+
+class AdminAreaController extends Controller
+{
+    protected string $id = 'id';
+    protected array $header = [
+        'id' => 'ID',
+        'name' => 'Название',
+        'district_name' => 'Округ',
+    ];
+    protected array $searchFields = ['name'];
+
+    public function index(Request $request): View
+    {
+        $query = Area::with('district')->orderBy('name');
+
+        // Фильтр по округу
+        if ($request->has('district_id') && $request->district_id) {
+            $query->where('district_id', $request->district_id);
+        }
+
+        $areas = $query->get()->map(function ($area) {
+            return [
+                'id' => $area->id,
+                'name' => $area->name,
+                'district_name' => $area->district?->shortname ?? '-',
+            ];
+        });
+
+        $districts = District::orderBy('shortname')->pluck('shortname', 'id')->toArray();
+
+        return view('admin.areas.index', [
+            'active' => 'admin_areas',
+            'id' => $this->id,
+            'header' => $this->header,
+            'areas' => $areas,
+            'districts' => $districts,
+            'selectedDistrict' => $request->district_id,
+        ]);
+    }
+
+    public function show(int $areaId): View
+    {
+        $area = Area::withTrashed()->findOrFail($areaId);
+        $districts = District::orderBy('shortname')->pluck('name', 'id')->toArray();
+
+        return view('admin.areas.edit', [
+            'active' => 'admin_areas',
+            'area' => $area,
+            'districts' => $districts,
+        ]);
+    }
+
+    public function store(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'id' => ['nullable', 'integer'],
+            'name' => ['required', 'string', 'max:255'],
+            'district_id' => ['required', 'integer', 'exists:districts,id'],
+        ]);
+
+        if (!empty($validated['id'])) {
+            $area = Area::withTrashed()->findOrFail($validated['id']);
+            $area->update([
+                'name' => $validated['name'],
+                'district_id' => $validated['district_id'],
+            ]);
+        } else {
+            Area::create([
+                'name' => $validated['name'],
+                'district_id' => $validated['district_id'],
+            ]);
+        }
+
+        return redirect()->route('admin.area.index')->with('success', 'Район сохранён');
+    }
+
+    public function destroy(Area $area): RedirectResponse
+    {
+        $area->delete();
+
+        return redirect()->route('admin.area.index')->with('success', 'Район удалён');
+    }
+
+    public function undelete(int $areaId): RedirectResponse
+    {
+        $area = Area::withTrashed()->findOrFail($areaId);
+        $area->restore();
+
+        return redirect()->route('admin.area.index')->with('success', 'Район восстановлен');
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        $service = new ExportAreasService();
+        $link = $service->handle($request->user()->id);
+
+        return redirect()->away($link);
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx,xls'],
+        ]);
+
+        $file = $request->file('import_file');
+        $path = $file->store('import/areas', 'upload');
+        $fullPath = Storage::disk('upload')->path($path);
+
+        $service = new ImportAreasService($fullPath, $request->user()->id);
+        $result = $service->handle();
+
+        if ($result['success']) {
+            return redirect()->route('admin.area.index')
+                ->with('success', 'Импорт завершён успешно');
+        }
+
+        return redirect()->route('admin.area.index')
+            ->with('error', 'Ошибка импорта: ' . ($result['error'] ?? 'неизвестная ошибка'));
+    }
+}

+ 130 - 0
app/Http/Controllers/Admin/AdminDistrictController.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Dictionary\District;
+use App\Services\Export\ExportDistrictsService;
+use App\Services\Import\ImportDistrictsService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\View\View;
+
+class AdminDistrictController extends Controller
+{
+    protected string $id = 'id';
+    protected array $header = [
+        'id' => 'ID',
+        'shortname' => 'Сокращение',
+        'name' => 'Название',
+        'areas_count' => 'Районов',
+    ];
+    protected array $searchFields = ['shortname', 'name'];
+
+    public function index(Request $request): View
+    {
+        $districts = District::withCount('areas')
+            ->orderBy('id')
+            ->get()
+            ->map(function ($district) {
+                return [
+                    'id' => $district->id,
+                    'shortname' => $district->shortname,
+                    'name' => $district->name,
+                    'areas_count' => $district->areas_count,
+                ];
+            });
+
+        return view('admin.districts.index', [
+            'active' => 'admin_districts',
+            'id' => $this->id,
+            'header' => $this->header,
+            'districts' => $districts,
+        ]);
+    }
+
+    public function show(int $districtId): View
+    {
+        $district = District::withTrashed()->findOrFail($districtId);
+
+        return view('admin.districts.edit', [
+            'active' => 'admin_districts',
+            'district' => $district,
+        ]);
+    }
+
+    public function store(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'id' => ['nullable', 'integer'],
+            'shortname' => ['required', 'string', 'max:50'],
+            'name' => ['required', 'string', 'max:255'],
+        ]);
+
+        if (!empty($validated['id'])) {
+            $district = District::withTrashed()->findOrFail($validated['id']);
+            $district->update([
+                'shortname' => $validated['shortname'],
+                'name' => $validated['name'],
+            ]);
+        } else {
+            District::create([
+                'shortname' => $validated['shortname'],
+                'name' => $validated['name'],
+            ]);
+        }
+
+        return redirect()->route('admin.district.index')->with('success', 'Округ сохранён');
+    }
+
+    public function destroy(District $district): RedirectResponse
+    {
+        if ($district->areas()->count() > 0) {
+            return redirect()->route('admin.district.index')
+                ->with('error', 'Невозможно удалить округ с привязанными районами');
+        }
+
+        $district->delete();
+
+        return redirect()->route('admin.district.index')->with('success', 'Округ удалён');
+    }
+
+    public function undelete(int $districtId): RedirectResponse
+    {
+        $district = District::withTrashed()->findOrFail($districtId);
+        $district->restore();
+
+        return redirect()->route('admin.district.index')->with('success', 'Округ восстановлен');
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        $service = new ExportDistrictsService();
+        $link = $service->handle($request->user()->id);
+
+        return redirect()->away($link);
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx,xls'],
+        ]);
+
+        $file = $request->file('import_file');
+        $path = $file->store('import/districts', 'upload');
+        $fullPath = Storage::disk('upload')->path($path);
+
+        $service = new ImportDistrictsService($fullPath, $request->user()->id);
+        $result = $service->handle();
+
+        if ($result['success']) {
+            return redirect()->route('admin.district.index')
+                ->with('success', 'Импорт завершён успешно');
+        }
+
+        return redirect()->route('admin.district.index')
+            ->with('error', 'Ошибка импорта: ' . ($result['error'] ?? 'неизвестная ошибка'));
+    }
+}

+ 81 - 0
app/Services/Export/ExportAreasService.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Services\Export;
+
+use App\Models\Dictionary\Area;
+use App\Models\File;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Color;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportAreasService
+{
+    public function handle(int $userId): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Районы');
+
+        // Заголовки
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Название');
+        $sheet->setCellValue('C1', 'Округ (сокращение)');
+        $sheet->setCellValue('D1', 'ID округа');
+
+        // Стиль заголовков
+        $sheet->getStyle('A1:D1')->getFont()->setBold(true);
+        $sheet->getStyle('A1:D1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('DDDDDD');
+
+        // Данные
+        $areas = Area::with('district')->orderBy('name')->get();
+        $i = 2;
+        foreach ($areas as $area) {
+            $sheet->setCellValue('A' . $i, $area->id);
+            $sheet->setCellValue('B' . $i, $area->name);
+            $sheet->setCellValue('C' . $i, $area->district?->shortname ?? '');
+            $sheet->setCellValue('D' . $i, $area->district_id);
+            $i++;
+        }
+
+        // Границы
+        if ($i > 2) {
+            $sheet->getStyle('A1:D' . ($i - 1))
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN)
+                ->setColor(new Color('777777'));
+        }
+
+        // Автоширина колонок
+        foreach (range('A', 'D') as $col) {
+            $sheet->getColumnDimension($col)->setAutoSize(true);
+        }
+
+        // Сохранение
+        $fileName = 'export_areas_' . date('Y-m-d_H-i-s') . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $fd = 'export/areas';
+        Storage::disk('public')->makeDirectory($fd);
+        $fp = storage_path('app/public/' . $fd . '/') . $fileName;
+        Storage::disk('public')->delete($fd . '/' . $fileName);
+        $writer->save($fp);
+
+        $link = url('/storage/' . $fd . '/' . $fileName);
+
+        // Создаём запись в таблице files
+        File::query()->create([
+            'link' => $link,
+            'path' => $fp,
+            'user_id' => $userId,
+            'original_name' => $fileName,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        ]);
+
+        return $link;
+    }
+}

+ 79 - 0
app/Services/Export/ExportDistrictsService.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Services\Export;
+
+use App\Models\Dictionary\District;
+use App\Models\File;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Color;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportDistrictsService
+{
+    public function handle(int $userId): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Округа');
+
+        // Заголовки
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Сокращение');
+        $sheet->setCellValue('C1', 'Название');
+
+        // Стиль заголовков
+        $sheet->getStyle('A1:C1')->getFont()->setBold(true);
+        $sheet->getStyle('A1:C1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('DDDDDD');
+
+        // Данные
+        $districts = District::orderBy('id')->get();
+        $i = 2;
+        foreach ($districts as $district) {
+            $sheet->setCellValue('A' . $i, $district->id);
+            $sheet->setCellValue('B' . $i, $district->shortname);
+            $sheet->setCellValue('C' . $i, $district->name);
+            $i++;
+        }
+
+        // Границы
+        if ($i > 2) {
+            $sheet->getStyle('A1:C' . ($i - 1))
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN)
+                ->setColor(new Color('777777'));
+        }
+
+        // Автоширина колонок
+        foreach (range('A', 'C') as $col) {
+            $sheet->getColumnDimension($col)->setAutoSize(true);
+        }
+
+        // Сохранение
+        $fileName = 'export_districts_' . date('Y-m-d_H-i-s') . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $fd = 'export/districts';
+        Storage::disk('public')->makeDirectory($fd);
+        $fp = storage_path('app/public/' . $fd . '/') . $fileName;
+        Storage::disk('public')->delete($fd . '/' . $fileName);
+        $writer->save($fp);
+
+        $link = url('/storage/' . $fd . '/' . $fileName);
+
+        // Создаём запись в таблице files
+        File::query()->create([
+            'link' => $link,
+            'path' => $fp,
+            'user_id' => $userId,
+            'original_name' => $fileName,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        ]);
+
+        return $link;
+    }
+}

+ 163 - 0
app/Services/Import/ImportAreasService.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+
+class ImportAreasService
+{
+    private array $logs = [];
+    private array $districtCache = [];
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): array
+    {
+        try {
+            $this->log("Начало импорта справочника районов");
+
+            // Загружаем кэш округов
+            $this->loadDistrictCache();
+
+            DB::beginTransaction();
+
+            try {
+                $spreadsheet = IOFactory::load($this->filePath);
+                $sheet = $spreadsheet->getActiveSheet();
+
+                $highestRow = $sheet->getHighestRow();
+                $imported = 0;
+                $updated = 0;
+                $skipped = 0;
+
+                for ($row = 2; $row <= $highestRow; $row++) {
+                    $id = trim($sheet->getCell('A' . $row)->getValue());
+                    $name = trim($sheet->getCell('B' . $row)->getValue());
+                    $districtShortname = trim($sheet->getCell('C' . $row)->getValue());
+                    $districtId = trim($sheet->getCell('D' . $row)->getValue());
+
+                    // Пропускаем пустые строки
+                    if (empty($name)) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    // Определяем округ
+                    $foundDistrictId = null;
+
+                    // Сначала по ID округа
+                    if (!empty($districtId) && is_numeric($districtId)) {
+                        $foundDistrictId = (int) $districtId;
+                        if (!isset($this->districtCache[$foundDistrictId])) {
+                            $this->log("Строка {$row}: округ с ID {$districtId} не найден");
+                            $foundDistrictId = null;
+                        }
+                    }
+
+                    // Потом по сокращению
+                    if (!$foundDistrictId && !empty($districtShortname)) {
+                        foreach ($this->districtCache as $dId => $dShortname) {
+                            if (mb_strtolower($dShortname) === mb_strtolower($districtShortname)) {
+                                $foundDistrictId = $dId;
+                                break;
+                            }
+                        }
+                        if (!$foundDistrictId) {
+                            $this->log("Строка {$row}: округ '{$districtShortname}' не найден");
+                        }
+                    }
+
+                    if (!$foundDistrictId) {
+                        $this->log("Строка {$row}: пропущена (не найден округ)");
+                        $skipped++;
+                        continue;
+                    }
+
+                    $data = [
+                        'name' => $name,
+                        'district_id' => $foundDistrictId,
+                    ];
+
+                    // Если есть ID, ищем по нему
+                    if (!empty($id) && is_numeric($id)) {
+                        $area = Area::withTrashed()->find((int) $id);
+                        if ($area) {
+                            if ($area->trashed()) {
+                                $area->restore();
+                            }
+                            $area->update($data);
+                            $updated++;
+                            continue;
+                        }
+                    }
+
+                    // Ищем по названию и округу
+                    $area = Area::withTrashed()
+                        ->where('name', $name)
+                        ->where('district_id', $foundDistrictId)
+                        ->first();
+
+                    if ($area) {
+                        if ($area->trashed()) {
+                            $area->restore();
+                        }
+                        $area->update($data);
+                        $updated++;
+                    } else {
+                        Area::create($data);
+                        $imported++;
+                    }
+                }
+
+                DB::commit();
+                $this->log("Импорт районов: создано {$imported}, обновлено {$updated}, пропущено {$skipped}");
+
+                return [
+                    'success' => true,
+                    'imported' => $imported,
+                    'updated' => $updated,
+                    'skipped' => $skipped,
+                    'logs' => $this->logs,
+                ];
+            } catch (Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        } catch (Exception $e) {
+            $this->log("ОШИБКА: " . $e->getMessage());
+            Log::error("Ошибка импорта районов: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'logs' => $this->logs,
+            ];
+        }
+    }
+
+    private function loadDistrictCache(): void
+    {
+        $this->districtCache = District::pluck('shortname', 'id')->toArray();
+    }
+
+    private function log(string $message): void
+    {
+        $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
+        Log::info($message);
+    }
+
+    public function getLogs(): array
+    {
+        return $this->logs;
+    }
+}

+ 123 - 0
app/Services/Import/ImportDistrictsService.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\Dictionary\District;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+
+class ImportDistrictsService
+{
+    private array $logs = [];
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): array
+    {
+        try {
+            $this->log("Начало импорта справочника округов");
+
+            DB::beginTransaction();
+
+            try {
+                $spreadsheet = IOFactory::load($this->filePath);
+                $sheet = $spreadsheet->getActiveSheet();
+
+                $highestRow = $sheet->getHighestRow();
+                $imported = 0;
+                $updated = 0;
+                $skipped = 0;
+
+                for ($row = 2; $row <= $highestRow; $row++) {
+                    $id = trim($sheet->getCell('A' . $row)->getValue());
+                    $shortname = trim($sheet->getCell('B' . $row)->getValue());
+                    $name = trim($sheet->getCell('C' . $row)->getValue());
+
+                    // Пропускаем пустые строки
+                    if (empty($shortname) && empty($name)) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    if (empty($name)) {
+                        $this->log("Строка {$row}: пропущена (пустое название)");
+                        $skipped++;
+                        continue;
+                    }
+
+                    $data = [
+                        'shortname' => $shortname ?: mb_substr($name, 0, 10),
+                        'name' => $name,
+                    ];
+
+                    // Если есть ID, ищем по нему
+                    if (!empty($id) && is_numeric($id)) {
+                        $district = District::withTrashed()->find((int) $id);
+                        if ($district) {
+                            if ($district->trashed()) {
+                                $district->restore();
+                            }
+                            $district->update($data);
+                            $updated++;
+                            continue;
+                        }
+                    }
+
+                    // Ищем по сокращению
+                    $district = District::withTrashed()->where('shortname', $shortname)->first();
+                    if ($district) {
+                        if ($district->trashed()) {
+                            $district->restore();
+                        }
+                        $district->update($data);
+                        $updated++;
+                    } else {
+                        District::create($data);
+                        $imported++;
+                    }
+                }
+
+                DB::commit();
+                $this->log("Импорт округов: создано {$imported}, обновлено {$updated}, пропущено {$skipped}");
+
+                return [
+                    'success' => true,
+                    'imported' => $imported,
+                    'updated' => $updated,
+                    'skipped' => $skipped,
+                    'logs' => $this->logs,
+                ];
+            } catch (Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        } catch (Exception $e) {
+            $this->log("ОШИБКА: " . $e->getMessage());
+            Log::error("Ошибка импорта округов: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'logs' => $this->logs,
+            ];
+        }
+    }
+
+    private function log(string $message): void
+    {
+        $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
+        Log::info($message);
+    }
+
+    public function getLogs(): array
+    {
+        return $this->logs;
+    }
+}

+ 33 - 1
app/Services/ImportOrdersService.php

@@ -12,6 +12,9 @@ use Exception;
 
 class ImportOrdersService extends ImportBaseService
 {
+    /** @var array<int, string> Строки с ошибками: номер строки => описание ошибки */
+    protected array $errorRows = [];
+
     const HEADERS = [
         'Округ'                         => 'districts.name',
         'Район'                         => 'areas.name',
@@ -62,6 +65,7 @@ class ImportOrdersService extends ImportBaseService
             return false;
         }
         $strNumber = 0;
+        $this->errorRows = [];
         $result = [
             'productsCreated' => 0,
             'ordersCreated' => 0,
@@ -83,26 +87,33 @@ class ImportOrdersService extends ImportBaseService
 
                 // округ
                 if (!($districtId = $this->findId('districts.shortname', $r['districts.name']))) {
+                    if((string)$r['districts.name'] !== '')  {
+                        $this->errorRows[$strNumber] = "Округ не найден: '{$r['districts.name']}'";
+                    }
                     continue;
                 }
 
                 // район
                 if (!($areaId = $this->findId('areas.name', $r['areas.name']))) {
+                    $this->errorRows[$strNumber] = "Район не найден: {$r['areas.name']}";
                     continue;
                 }
 
                 // manager
                 if (!($userId = $this->findId('users.name', $r['users.name']))) {
+                    $this->errorRows[$strNumber] = "Менеджер не найден: {$r['users.name']}";
                     continue;
                 }
 
                 // object_type
                 if (!($objectTypeId = $this->findId('object_types.name', $r['object_types.name']))) {
+                    $this->errorRows[$strNumber] = "Тип объекта не найден: {$r['object_types.name']}";
                     continue;
                 }
 
                 // order_statuses.name
                 if (!($orderStatusId = $this->findId('order_statuses.name', $r['order_statuses.name']))) {
+                    $this->errorRows[$strNumber] = "Статус заказа не найден: {$r['order_statuses.name']}";
                     continue;
                 }
 
@@ -240,11 +251,32 @@ class ImportOrdersService extends ImportBaseService
 
                 echo $this->import->log($logMessage);
             } catch (\Exception $e) {
-                echo $this->import->log($e->getMessage(), 'WARNING');
+                $this->errorRows[$strNumber] = $e->getMessage();
+                echo $this->import->log("Row $strNumber: " . $e->getMessage(), 'WARNING');
             }
 
         }
+
+        // Вывод результатов
         echo $this->import->log(print_r($result, true));
+
+        // Вывод информации об ошибках
+        if (!empty($this->errorRows)) {
+            $errorCount = count($this->errorRows);
+            $errorRowNumbers = array_keys($this->errorRows);
+            echo $this->import->log("Количество строк с ошибками: $errorCount", 'WARNING');
+            echo $this->import->log("Номера строк с ошибками: " . implode(', ', $errorRowNumbers), 'WARNING');
+
+            // Детализация ошибок (группировка по типу)
+            $errorsByType = [];
+            foreach ($this->errorRows as $rowNum => $errorMsg) {
+                $errorsByType[$errorMsg][] = $rowNum;
+            }
+            foreach ($errorsByType as $errorMsg => $rows) {
+                echo $this->import->log("  - $errorMsg (строки: " . implode(', ', $rows) . ")", 'WARNING');
+            }
+        }
+
         $this->import->status = 'DONE';
         $this->import->save();
 

+ 77 - 0
resources/views/admin/areas/edit.blade.php

@@ -0,0 +1,77 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="px-3">
+        <div class="col-xxl-6 offset-xxl-2">
+            <h4 class="mb-4">Редактирование района</h4>
+
+            @if(session('success'))
+                <div class="alert alert-success alert-dismissible fade show" role="alert">
+                    {{ session('success') }}
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+                </div>
+            @endif
+
+            @if(session('error'))
+                <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                    {{ session('error') }}
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+                </div>
+            @endif
+
+            <form action="{{ route('admin.area.store') }}" method="post">
+                @csrf
+                <input type="hidden" name="id" value="{{ $area->id }}">
+
+                @include('partials.input', [
+                    'name' => 'name',
+                    'title' => 'Название',
+                    'required' => true,
+                    'value' => $area->name ?? '',
+                ])
+                @include('partials.select', [
+                    'name' => 'district_id',
+                    'title' => 'Округ',
+                    'options' => $districts,
+                    'value' => $area->district_id,
+                    'required' => true,
+                ])
+
+                @if(!is_null($area->deleted_at))
+                    <div class="col-12 text-center mb-3">
+                        <div class="text-danger mb-2">РАЙОН УДАЛЁН!</div>
+                        <a href="#" class="btn btn-sm btn-warning undelete">Восстановить</a>
+                    </div>
+                @else
+                    @include('partials.submit', ['delete' => ['form_id' => 'delete-area']])
+                @endif
+            </form>
+
+            <form action="{{ route('admin.area.undelete', $area->id) }}" method="post" class="d-none" id="undelete-area">
+                @csrf
+            </form>
+
+            <form action="{{ route('admin.area.destroy', $area->id) }}" method="post" class="d-none" id="delete-area">
+                @method('DELETE')
+                @csrf
+            </form>
+
+            <div class="mt-3">
+                <a href="{{ route('admin.area.index') }}" class="btn btn-sm btn-outline-secondary">
+                    &larr; Назад к списку
+                </a>
+            </div>
+        </div>
+    </div>
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('.undelete').on('click', function (e) {
+            e.preventDefault();
+            if (confirm('Восстановить район?')) {
+                $('#undelete-area').submit();
+            }
+        });
+    </script>
+@endpush

+ 129 - 0
resources/views/admin/areas/index.blade.php

@@ -0,0 +1,129 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3">
+        <div class="col-6">
+            <h3>Районы</h3>
+        </div>
+        <div class="col-6 text-end">
+            <button type="button" class="btn btn-sm btn-success me-2" data-bs-toggle="modal" data-bs-target="#importModal">
+                Импорт
+            </button>
+            <form action="{{ route('admin.area.export') }}" method="post" class="d-inline">
+                @csrf
+                <button type="submit" class="btn btn-sm btn-outline-success me-2">Экспорт</button>
+            </form>
+            <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
+                Добавить
+            </button>
+        </div>
+    </div>
+
+    @if(session('success'))
+        <div class="alert alert-success alert-dismissible fade show" role="alert">
+            {{ session('success') }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+        </div>
+    @endif
+
+    @if(session('error'))
+        <div class="alert alert-danger alert-dismissible fade show" role="alert">
+            {{ session('error') }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+        </div>
+    @endif
+
+    <!-- Фильтр по округу -->
+    <div class="row mb-3">
+        <div class="col-md-4">
+            <form action="{{ route('admin.area.index') }}" method="get" class="d-flex gap-2">
+                <select name="district_id" class="form-select form-select-sm">
+                    <option value="">Все округа</option>
+                    @foreach($districts as $id => $name)
+                        <option value="{{ $id }}" {{ $selectedDistrict == $id ? 'selected' : '' }}>{{ $name }}</option>
+                    @endforeach
+                </select>
+                <button type="submit" class="btn btn-sm btn-outline-primary">Фильтр</button>
+            </form>
+        </div>
+    </div>
+
+    <table class="table table-bordered table-striped table-hover table-sm">
+        <thead>
+            <tr>
+                @foreach($header as $key => $title)
+                    <th>{{ $title }}</th>
+                @endforeach
+                <th></th>
+            </tr>
+        </thead>
+        <tbody>
+            @foreach($areas as $area)
+                <tr>
+                    <td>{{ $area['id'] }}</td>
+                    <td>{{ $area['name'] }}</td>
+                    <td>{{ $area['district_name'] }}</td>
+                    <td class="text-end">
+                        <a href="{{ route('admin.area.show', $area['id']) }}" class="btn btn-sm btn-outline-primary">
+                            Редактировать
+                        </a>
+                    </td>
+                </tr>
+            @endforeach
+        </tbody>
+    </table>
+
+    <!-- Модальное окно добавления -->
+    <div class="modal fade" id="addModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Добавить район</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <form action="{{ route('admin.area.store') }}" method="post">
+                        @csrf
+                        @include('partials.input', ['name' => 'name', 'title' => 'Название', 'required' => true])
+                        @include('partials.select', [
+                            'name' => 'district_id',
+                            'title' => 'Округ',
+                            'options' => $districts,
+                            'required' => true,
+                        ])
+                        @include('partials.submit', ['name' => 'Добавить'])
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Модальное окно импорта -->
+    <div class="modal fade" id="importModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Импорт районов</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <form action="{{ route('admin.area.import') }}" method="post" enctype="multipart/form-data">
+                        @csrf
+                        <div class="mb-3">
+                            <p class="text-muted small">
+                                Формат файла: XLSX с колонками: ID, Название, Округ (сокращение), ID округа.<br>
+                                Первая строка — заголовки. Округ указывается по сокращению или ID.
+                            </p>
+                        </div>
+                        @include('partials.input', ['name' => 'import_file', 'type' => 'file', 'title' => 'XLSX файл', 'required' => true])
+                        @include('partials.submit', ['name' => 'Импорт'])
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    @if($errors->any())
+        @dump($errors)
+    @endif
+@endsection

+ 76 - 0
resources/views/admin/districts/edit.blade.php

@@ -0,0 +1,76 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="px-3">
+        <div class="col-xxl-6 offset-xxl-2">
+            <h4 class="mb-4">Редактирование округа</h4>
+
+            @if(session('success'))
+                <div class="alert alert-success alert-dismissible fade show" role="alert">
+                    {{ session('success') }}
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+                </div>
+            @endif
+
+            @if(session('error'))
+                <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                    {{ session('error') }}
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+                </div>
+            @endif
+
+            <form action="{{ route('admin.district.store') }}" method="post">
+                @csrf
+                <input type="hidden" name="id" value="{{ $district->id }}">
+
+                @include('partials.input', [
+                    'name' => 'shortname',
+                    'title' => 'Сокращение',
+                    'required' => true,
+                    'value' => $district->shortname ?? '',
+                ])
+                @include('partials.input', [
+                    'name' => 'name',
+                    'title' => 'Название',
+                    'required' => true,
+                    'value' => $district->name ?? '',
+                ])
+
+                @if(!is_null($district->deleted_at))
+                    <div class="col-12 text-center mb-3">
+                        <div class="text-danger mb-2">ОКРУГ УДАЛЁН!</div>
+                        <a href="#" class="btn btn-sm btn-warning undelete">Восстановить</a>
+                    </div>
+                @else
+                    @include('partials.submit', ['delete' => ['form_id' => 'delete-district']])
+                @endif
+            </form>
+
+            <form action="{{ route('admin.district.undelete', $district->id) }}" method="post" class="d-none" id="undelete-district">
+                @csrf
+            </form>
+
+            <form action="{{ route('admin.district.destroy', $district->id) }}" method="post" class="d-none" id="delete-district">
+                @method('DELETE')
+                @csrf
+            </form>
+
+            <div class="mt-3">
+                <a href="{{ route('admin.district.index') }}" class="btn btn-sm btn-outline-secondary">
+                    &larr; Назад к списку
+                </a>
+            </div>
+        </div>
+    </div>
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('.undelete').on('click', function (e) {
+            e.preventDefault();
+            if (confirm('Восстановить округ?')) {
+                $('#undelete-district').submit();
+            }
+        });
+    </script>
+@endpush

+ 110 - 0
resources/views/admin/districts/index.blade.php

@@ -0,0 +1,110 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3">
+        <div class="col-6">
+            <h3>Округа</h3>
+        </div>
+        <div class="col-6 text-end">
+            <button type="button" class="btn btn-sm btn-success me-2" data-bs-toggle="modal" data-bs-target="#importModal">
+                Импорт
+            </button>
+            <form action="{{ route('admin.district.export') }}" method="post" class="d-inline">
+                @csrf
+                <button type="submit" class="btn btn-sm btn-outline-success me-2">Экспорт</button>
+            </form>
+            <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
+                Добавить
+            </button>
+        </div>
+    </div>
+
+    @if(session('success'))
+        <div class="alert alert-success alert-dismissible fade show" role="alert">
+            {{ session('success') }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+        </div>
+    @endif
+
+    @if(session('error'))
+        <div class="alert alert-danger alert-dismissible fade show" role="alert">
+            {{ session('error') }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+        </div>
+    @endif
+
+    <table class="table table-bordered table-striped table-hover table-sm">
+        <thead>
+            <tr>
+                @foreach($header as $key => $title)
+                    <th>{{ $title }}</th>
+                @endforeach
+                <th></th>
+            </tr>
+        </thead>
+        <tbody>
+            @foreach($districts as $district)
+                <tr>
+                    <td>{{ $district['id'] }}</td>
+                    <td>{{ $district['shortname'] }}</td>
+                    <td>{{ $district['name'] }}</td>
+                    <td>{{ $district['areas_count'] }}</td>
+                    <td class="text-end">
+                        <a href="{{ route('admin.district.show', $district['id']) }}" class="btn btn-sm btn-outline-primary">
+                            Редактировать
+                        </a>
+                    </td>
+                </tr>
+            @endforeach
+        </tbody>
+    </table>
+
+    <!-- Модальное окно добавления -->
+    <div class="modal fade" id="addModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Добавить округ</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <form action="{{ route('admin.district.store') }}" method="post">
+                        @csrf
+                        @include('partials.input', ['name' => 'shortname', 'title' => 'Сокращение', 'required' => true])
+                        @include('partials.input', ['name' => 'name', 'title' => 'Название', 'required' => true])
+                        @include('partials.submit', ['name' => 'Добавить'])
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Модальное окно импорта -->
+    <div class="modal fade" id="importModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">Импорт округов</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <form action="{{ route('admin.district.import') }}" method="post" enctype="multipart/form-data">
+                        @csrf
+                        <div class="mb-3">
+                            <p class="text-muted small">
+                                Формат файла: XLSX с колонками: ID, Сокращение, Название.<br>
+                                Первая строка — заголовки.
+                            </p>
+                        </div>
+                        @include('partials.input', ['name' => 'import_file', 'type' => 'file', 'title' => 'XLSX файл', 'required' => true])
+                        @include('partials.submit', ['name' => 'Импорт'])
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    @if($errors->any())
+        @dump($errors)
+    @endif
+@endsection

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

@@ -33,6 +33,8 @@
             <ul class="dropdown-menu dropdown-menu-end">
                 <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('admin.district.index') }}">Округа</a></li>
+                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</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>

+ 24 - 0
routes/web.php

@@ -1,5 +1,7 @@
 <?php
 
+use App\Http\Controllers\Admin\AdminAreaController;
+use App\Http\Controllers\Admin\AdminDistrictController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
 use App\Http\Controllers\YearDataController;
@@ -81,6 +83,28 @@ Route::middleware('auth:web')->group(function () {
             Route::post('import', [YearDataController::class, 'import'])->name('year-data.import');
             Route::get('download/{file}', [YearDataController::class, 'download'])->name('year-data.download');
         });
+
+        // Справочник округов
+        Route::prefix('districts')->group(function (){
+            Route::get('', [AdminDistrictController::class, 'index'])->name('admin.district.index');
+            Route::post('', [AdminDistrictController::class, 'store'])->name('admin.district.store');
+            Route::post('export', [AdminDistrictController::class, 'export'])->name('admin.district.export');
+            Route::post('import', [AdminDistrictController::class, 'import'])->name('admin.district.import');
+            Route::get('{district}', [AdminDistrictController::class, 'show'])->name('admin.district.show');
+            Route::delete('{district}', [AdminDistrictController::class, 'destroy'])->name('admin.district.destroy');
+            Route::post('undelete/{district}', [AdminDistrictController::class, 'undelete'])->name('admin.district.undelete');
+        });
+
+        // Справочник районов
+        Route::prefix('areas-dict')->group(function (){
+            Route::get('', [AdminAreaController::class, 'index'])->name('admin.area.index');
+            Route::post('', [AdminAreaController::class, 'store'])->name('admin.area.store');
+            Route::post('export', [AdminAreaController::class, 'export'])->name('admin.area.export');
+            Route::post('import', [AdminAreaController::class, 'import'])->name('admin.area.import');
+            Route::get('{area}', [AdminAreaController::class, 'show'])->name('admin.area.show');
+            Route::delete('{area}', [AdminAreaController::class, 'destroy'])->name('admin.area.destroy');
+            Route::post('undelete/{area}', [AdminAreaController::class, 'undelete'])->name('admin.area.undelete');
+        });
     });
 
     // profile

+ 299 - 0
tests/Feature/AdminAreaControllerTest.php

@@ -0,0 +1,299 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class AdminAreaControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+    private District $district;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+        $this->district = District::factory()->create(['shortname' => 'ЦАО', 'name' => 'Центральный']);
+    }
+
+    // ==================== Authentication & Authorization ====================
+
+    public function test_guest_cannot_access_areas_index(): void
+    {
+        $response = $this->get(route('admin.area.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_manager_cannot_access_areas_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('admin.area.index'));
+
+        $response->assertStatus(403);
+    }
+
+    public function test_admin_can_access_areas_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.area.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('admin.areas.index');
+    }
+
+    // ==================== Index ====================
+
+    public function test_areas_index_displays_areas(): void
+    {
+        $area = Area::factory()->create([
+            'name' => 'Тестовый район',
+            'district_id' => $this->district->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.area.index'));
+
+        $response->assertStatus(200);
+        $response->assertSee('Тестовый район');
+    }
+
+    public function test_areas_index_can_filter_by_district(): void
+    {
+        $area1 = Area::factory()->create([
+            'name' => 'Район ЦАО',
+            'district_id' => $this->district->id,
+        ]);
+
+        $otherDistrict = District::factory()->create(['shortname' => 'САО']);
+        $area2 = Area::factory()->create([
+            'name' => 'Район САО',
+            'district_id' => $otherDistrict->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.area.index', ['district_id' => $this->district->id]));
+
+        $response->assertStatus(200);
+        $response->assertSee('Район ЦАО');
+        $response->assertDontSee('Район САО');
+    }
+
+    // ==================== Show ====================
+
+    public function test_admin_can_view_area_edit_form(): void
+    {
+        $area = Area::factory()->create(['district_id' => $this->district->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.area.show', $area->id));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('admin.areas.edit');
+        $response->assertSee($area->name);
+    }
+
+    public function test_manager_cannot_view_area_edit_form(): void
+    {
+        $area = Area::factory()->create(['district_id' => $this->district->id]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('admin.area.show', $area->id));
+
+        $response->assertStatus(403);
+    }
+
+    // ==================== Store ====================
+
+    public function test_admin_can_create_new_area(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.store'), [
+                'name' => 'Тестовый район',
+                'district_id' => $this->district->id,
+            ]);
+
+        $response->assertRedirect(route('admin.area.index'));
+        $this->assertDatabaseHas('areas', [
+            'name' => 'Тестовый район',
+            'district_id' => $this->district->id,
+        ]);
+    }
+
+    public function test_admin_can_update_existing_area(): void
+    {
+        $area = Area::factory()->create([
+            'name' => 'Старое название',
+            'district_id' => $this->district->id,
+        ]);
+
+        $newDistrict = District::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.store'), [
+                'id' => $area->id,
+                'name' => 'Новое название',
+                'district_id' => $newDistrict->id,
+            ]);
+
+        $response->assertRedirect(route('admin.area.index'));
+        $this->assertDatabaseHas('areas', [
+            'id' => $area->id,
+            'name' => 'Новое название',
+            'district_id' => $newDistrict->id,
+        ]);
+    }
+
+    public function test_store_area_requires_name(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.store'), [
+                'district_id' => $this->district->id,
+            ]);
+
+        $response->assertSessionHasErrors('name');
+    }
+
+    public function test_store_area_requires_district_id(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.store'), [
+                'name' => 'Тестовый район',
+            ]);
+
+        $response->assertSessionHasErrors('district_id');
+    }
+
+    public function test_store_area_validates_district_exists(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.store'), [
+                'name' => 'Тестовый район',
+                'district_id' => 99999,
+            ]);
+
+        $response->assertSessionHasErrors('district_id');
+    }
+
+    // ==================== Destroy ====================
+
+    public function test_admin_can_delete_area(): void
+    {
+        $area = Area::factory()->create(['district_id' => $this->district->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('admin.area.destroy', $area->id));
+
+        $response->assertRedirect(route('admin.area.index'));
+        $this->assertSoftDeleted('areas', ['id' => $area->id]);
+    }
+
+    // ==================== Undelete ====================
+
+    public function test_admin_can_restore_deleted_area(): void
+    {
+        $area = Area::factory()->create(['district_id' => $this->district->id]);
+        $area->delete();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.undelete', $area->id));
+
+        $response->assertRedirect(route('admin.area.index'));
+        $this->assertDatabaseHas('areas', [
+            'id' => $area->id,
+            'deleted_at' => null,
+        ]);
+    }
+
+    // ==================== Export ====================
+
+    public function test_admin_can_export_areas(): void
+    {
+        Area::factory()->count(3)->create(['district_id' => $this->district->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.export'));
+
+        $response->assertRedirect();
+    }
+
+    // ==================== Import ====================
+
+    public function test_admin_can_import_areas(): void
+    {
+        Storage::fake('upload');
+
+        $file = $this->createTestAreasXlsx();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.import'), [
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('admin.area.index'));
+    }
+
+    public function test_import_requires_file(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.import'), []);
+
+        $response->assertSessionHasErrors('import_file');
+    }
+
+    public function test_import_requires_xlsx_file(): void
+    {
+        $file = UploadedFile::fake()->create('test.txt', 100, 'text/plain');
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.area.import'), [
+                'import_file' => $file,
+            ]);
+
+        $response->assertSessionHasErrors('import_file');
+    }
+
+    // ==================== Helper Methods ====================
+
+    private function createTestAreasXlsx(): UploadedFile
+    {
+        $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Название');
+        $sheet->setCellValue('C1', 'Округ (сокращение)');
+        $sheet->setCellValue('D1', 'ID округа');
+
+        $sheet->setCellValue('A2', '');
+        $sheet->setCellValue('B2', 'Тестовый район');
+        $sheet->setCellValue('C2', 'ЦАО');
+        $sheet->setCellValue('D2', $this->district->id);
+
+        $tempPath = sys_get_temp_dir() . '/test_areas_' . uniqid() . '.xlsx';
+        $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+        $writer->save($tempPath);
+
+        return new UploadedFile(
+            $tempPath,
+            'test_areas.xlsx',
+            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            null,
+            true
+        );
+    }
+}

+ 284 - 0
tests/Feature/AdminDistrictControllerTest.php

@@ -0,0 +1,284 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class AdminDistrictControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    // ==================== Authentication & Authorization ====================
+
+    public function test_guest_cannot_access_districts_index(): void
+    {
+        $response = $this->get(route('admin.district.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_manager_cannot_access_districts_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('admin.district.index'));
+
+        $response->assertStatus(403);
+    }
+
+    public function test_admin_can_access_districts_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.district.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('admin.districts.index');
+    }
+
+    // ==================== Index ====================
+
+    public function test_districts_index_displays_districts(): void
+    {
+        $district = District::factory()->create(['name' => 'Тестовый округ']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.district.index'));
+
+        $response->assertStatus(200);
+        $response->assertSee('Тестовый округ');
+    }
+
+    public function test_districts_index_displays_areas_count(): void
+    {
+        $district = District::factory()->create();
+        Area::factory()->count(3)->create(['district_id' => $district->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.district.index'));
+
+        $response->assertStatus(200);
+        $response->assertSee('3');
+    }
+
+    // ==================== Show ====================
+
+    public function test_admin_can_view_district_edit_form(): void
+    {
+        $district = District::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('admin.district.show', $district->id));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('admin.districts.edit');
+        $response->assertSee($district->name);
+    }
+
+    public function test_manager_cannot_view_district_edit_form(): void
+    {
+        $district = District::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('admin.district.show', $district->id));
+
+        $response->assertStatus(403);
+    }
+
+    // ==================== Store ====================
+
+    public function test_admin_can_create_new_district(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.store'), [
+                'shortname' => 'ТСТ',
+                'name' => 'Тестовый округ',
+            ]);
+
+        $response->assertRedirect(route('admin.district.index'));
+        $this->assertDatabaseHas('districts', [
+            'shortname' => 'ТСТ',
+            'name' => 'Тестовый округ',
+        ]);
+    }
+
+    public function test_admin_can_update_existing_district(): void
+    {
+        $district = District::factory()->create([
+            'shortname' => 'СТР',
+            'name' => 'Старое название',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.store'), [
+                'id' => $district->id,
+                'shortname' => 'НВ',
+                'name' => 'Новое название',
+            ]);
+
+        $response->assertRedirect(route('admin.district.index'));
+        $this->assertDatabaseHas('districts', [
+            'id' => $district->id,
+            'shortname' => 'НВ',
+            'name' => 'Новое название',
+        ]);
+    }
+
+    public function test_store_district_requires_name(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.store'), [
+                'shortname' => 'ТСТ',
+            ]);
+
+        $response->assertSessionHasErrors('name');
+    }
+
+    public function test_store_district_requires_shortname(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.store'), [
+                'name' => 'Тестовый округ',
+            ]);
+
+        $response->assertSessionHasErrors('shortname');
+    }
+
+    // ==================== Destroy ====================
+
+    public function test_admin_can_delete_district_without_areas(): void
+    {
+        $district = District::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('admin.district.destroy', $district->id));
+
+        $response->assertRedirect(route('admin.district.index'));
+        $this->assertSoftDeleted('districts', ['id' => $district->id]);
+    }
+
+    public function test_admin_cannot_delete_district_with_areas(): void
+    {
+        $district = District::factory()->create();
+        Area::factory()->create(['district_id' => $district->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('admin.district.destroy', $district->id));
+
+        $response->assertRedirect(route('admin.district.index'));
+        $response->assertSessionHas('error');
+        $this->assertDatabaseHas('districts', ['id' => $district->id, 'deleted_at' => null]);
+    }
+
+    // ==================== Undelete ====================
+
+    public function test_admin_can_restore_deleted_district(): void
+    {
+        $district = District::factory()->create();
+        $district->delete();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.undelete', $district->id));
+
+        $response->assertRedirect(route('admin.district.index'));
+        $this->assertDatabaseHas('districts', [
+            'id' => $district->id,
+            'deleted_at' => null,
+        ]);
+    }
+
+    // ==================== Export ====================
+
+    public function test_admin_can_export_districts(): void
+    {
+        District::factory()->count(3)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.export'));
+
+        $response->assertRedirect();
+    }
+
+    // ==================== Import ====================
+
+    public function test_admin_can_import_districts(): void
+    {
+        Storage::fake('upload');
+
+        // Создаем тестовый XLSX файл
+        $file = $this->createTestDistrictsXlsx();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.import'), [
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('admin.district.index'));
+    }
+
+    public function test_import_requires_file(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.import'), []);
+
+        $response->assertSessionHasErrors('import_file');
+    }
+
+    public function test_import_requires_xlsx_file(): void
+    {
+        $file = UploadedFile::fake()->create('test.txt', 100, 'text/plain');
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.district.import'), [
+                'import_file' => $file,
+            ]);
+
+        $response->assertSessionHasErrors('import_file');
+    }
+
+    // ==================== Helper Methods ====================
+
+    private function createTestDistrictsXlsx(): UploadedFile
+    {
+        $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Сокращение');
+        $sheet->setCellValue('C1', 'Название');
+
+        $sheet->setCellValue('A2', '');
+        $sheet->setCellValue('B2', 'ТСТ');
+        $sheet->setCellValue('C2', 'Тестовый округ');
+
+        $tempPath = sys_get_temp_dir() . '/test_districts_' . uniqid() . '.xlsx';
+        $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+        $writer->save($tempPath);
+
+        return new UploadedFile(
+            $tempPath,
+            'test_districts.xlsx',
+            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            null,
+            true
+        );
+    }
+}

+ 177 - 0
tests/Unit/Services/Dictionary/ExportAreasServiceTest.php

@@ -0,0 +1,177 @@
+<?php
+
+namespace Tests\Unit\Services\Dictionary;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\File;
+use App\Models\User;
+use App\Services\Export\ExportAreasService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportAreasServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_export_creates_xlsx_file(): void
+    {
+        $user = User::factory()->create();
+        $district = District::factory()->create();
+
+        Area::factory()->create(['name' => 'Тверской', 'district_id' => $district->id]);
+
+        $service = new ExportAreasService();
+        $link = $service->handle($user->id);
+
+        $this->assertStringContainsString('export_areas_', $link);
+        $this->assertStringContainsString('.xlsx', $link);
+
+        // Cleanup
+        $file = File::where('user_id', $user->id)->latest()->first();
+        if ($file && file_exists($file->path)) {
+            unlink($file->path);
+        }
+    }
+
+    public function test_export_creates_file_record(): void
+    {
+        $user = User::factory()->create();
+        $district = District::factory()->create();
+
+        Area::factory()->create(['district_id' => $district->id]);
+
+        $service = new ExportAreasService();
+        $service->handle($user->id);
+
+        $this->assertDatabaseHas('files', [
+            'user_id' => $user->id,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        ]);
+
+        // Cleanup
+        $file = File::where('user_id', $user->id)->latest()->first();
+        if ($file && file_exists($file->path)) {
+            unlink($file->path);
+        }
+    }
+
+    public function test_export_includes_district_shortname(): void
+    {
+        $user = User::factory()->create();
+
+        // Clear existing areas from seeder
+        Area::query()->forceDelete();
+
+        $district = District::factory()->create(['shortname' => 'ЦАО', 'name' => 'Центральный']);
+        Area::factory()->create(['name' => 'Тверской', 'district_id' => $district->id]);
+
+        $service = new ExportAreasService();
+        $link = $service->handle($user->id);
+
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $this->assertNotNull($file);
+        $this->assertFileExists($file->path);
+
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Check headers
+        $this->assertEquals('ID', $sheet->getCell('A1')->getValue());
+        $this->assertEquals('Название', $sheet->getCell('B1')->getValue());
+        $this->assertEquals('Округ (сокращение)', $sheet->getCell('C1')->getValue());
+        $this->assertEquals('ID округа', $sheet->getCell('D1')->getValue());
+
+        // Check data
+        $this->assertEquals('Тверской', $sheet->getCell('B2')->getValue());
+        $this->assertEquals('ЦАО', $sheet->getCell('C2')->getValue());
+        $this->assertEquals($district->id, $sheet->getCell('D2')->getValue());
+
+        // Cleanup
+        unlink($file->path);
+    }
+
+    public function test_export_includes_all_areas(): void
+    {
+        $user = User::factory()->create();
+
+        // Clear existing areas from seeder
+        Area::query()->forceDelete();
+
+        $district = District::factory()->create();
+        Area::factory()->count(5)->create(['district_id' => $district->id]);
+
+        $service = new ExportAreasService();
+        $service->handle($user->id);
+
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Should have 6 rows: header + 5 areas
+        $highestRow = $sheet->getHighestRow();
+        $this->assertEquals(6, $highestRow);
+
+        // Cleanup
+        unlink($file->path);
+    }
+
+    public function test_export_does_not_include_deleted_areas(): void
+    {
+        $user = User::factory()->create();
+
+        // Clear existing areas from seeder
+        Area::query()->forceDelete();
+
+        $district = District::factory()->create();
+        Area::factory()->create(['name' => 'Активный', 'district_id' => $district->id]);
+        $deletedArea = Area::factory()->create(['name' => 'Удалённый', 'district_id' => $district->id]);
+        $deletedArea->delete();
+
+        $service = new ExportAreasService();
+        $service->handle($user->id);
+
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Should have only 2 rows: header + 1 area
+        $highestRow = $sheet->getHighestRow();
+        $this->assertEquals(2, $highestRow);
+
+        // Cleanup
+        unlink($file->path);
+    }
+
+    public function test_export_sorts_areas_by_name(): void
+    {
+        $user = User::factory()->create();
+
+        // Clear existing areas from seeder
+        Area::query()->forceDelete();
+
+        $district = District::factory()->create();
+        Area::factory()->create(['name' => 'Яузский', 'district_id' => $district->id]);
+        Area::factory()->create(['name' => 'Арбат', 'district_id' => $district->id]);
+        Area::factory()->create(['name' => 'Тверской', 'district_id' => $district->id]);
+
+        $service = new ExportAreasService();
+        $service->handle($user->id);
+
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Check order
+        $this->assertEquals('Арбат', $sheet->getCell('B2')->getValue());
+        $this->assertEquals('Тверской', $sheet->getCell('B3')->getValue());
+        $this->assertEquals('Яузский', $sheet->getCell('B4')->getValue());
+
+        // Cleanup
+        unlink($file->path);
+    }
+}

+ 123 - 0
tests/Unit/Services/Dictionary/ExportDistrictsServiceTest.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace Tests\Unit\Services\Dictionary;
+
+use App\Models\Dictionary\District;
+use App\Models\File;
+use App\Models\User;
+use App\Services\Export\ExportDistrictsService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportDistrictsServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_export_creates_xlsx_file(): void
+    {
+        $user = User::factory()->create();
+
+        District::factory()->create(['shortname' => 'ЦАО', 'name' => 'Центральный']);
+
+        $service = new ExportDistrictsService();
+        $link = $service->handle($user->id);
+
+        $this->assertStringContainsString('export_districts_', $link);
+        $this->assertStringContainsString('.xlsx', $link);
+
+        // Cleanup
+        $file = File::where('user_id', $user->id)->latest()->first();
+        if ($file && file_exists($file->path)) {
+            unlink($file->path);
+        }
+    }
+
+    public function test_export_creates_file_record(): void
+    {
+        $user = User::factory()->create();
+
+        District::factory()->create();
+
+        $service = new ExportDistrictsService();
+        $service->handle($user->id);
+
+        $this->assertDatabaseHas('files', [
+            'user_id' => $user->id,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        ]);
+
+        // Cleanup
+        $file = File::where('user_id', $user->id)->latest()->first();
+        if ($file && file_exists($file->path)) {
+            unlink($file->path);
+        }
+    }
+
+    public function test_export_includes_all_districts(): void
+    {
+        $user = User::factory()->create();
+
+        // Create additional test districts
+        District::factory()->create(['shortname' => 'ТЕСТ1', 'name' => 'Тестовый 1']);
+        District::factory()->create(['shortname' => 'ТЕСТ2', 'name' => 'Тестовый 2']);
+        District::factory()->create(['shortname' => 'ТЕСТ3', 'name' => 'Тестовый 3']);
+
+        $totalCount = District::count();
+
+        $service = new ExportDistrictsService();
+        $link = $service->handle($user->id);
+
+        // Get the file path from the link
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $this->assertNotNull($file);
+
+        // Verify the file exists and contains correct data
+        $this->assertFileExists($file->path);
+
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Check headers
+        $this->assertEquals('ID', $sheet->getCell('A1')->getValue());
+        $this->assertEquals('Сокращение', $sheet->getCell('B1')->getValue());
+        $this->assertEquals('Название', $sheet->getCell('C1')->getValue());
+
+        // Check that we have correct number of data rows
+        $highestRow = $sheet->getHighestRow();
+        $this->assertEquals($totalCount + 1, $highestRow); // +1 for header
+
+        // Cleanup
+        unlink($file->path);
+    }
+
+    public function test_export_does_not_include_deleted_districts(): void
+    {
+        $user = User::factory()->create();
+
+        $beforeCount = District::count();
+
+        District::factory()->create(['shortname' => 'ТЕСТАКТ', 'name' => 'Тестовый активный']);
+        $deletedDistrict = District::factory()->create(['shortname' => 'ТЕСТУДЛ', 'name' => 'Тестовый удалённый']);
+        $deletedDistrict->delete();
+
+        $afterCount = District::count(); // Should be beforeCount + 1
+
+        $service = new ExportDistrictsService();
+        $link = $service->handle($user->id);
+
+        $file = File::where('user_id', $user->id)->latest()->first();
+        $spreadsheet = IOFactory::load($file->path);
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Should have afterCount + 1 rows (header + all non-deleted districts)
+        $highestRow = $sheet->getHighestRow();
+        $this->assertEquals($afterCount + 1, $highestRow);
+
+        // Cleanup
+        unlink($file->path);
+    }
+}

+ 239 - 0
tests/Unit/Services/Dictionary/ImportAreasServiceTest.php

@@ -0,0 +1,239 @@
+<?php
+
+namespace Tests\Unit\Services\Dictionary;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Services\Import\ImportAreasService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportAreasServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private string $tempFilePath;
+    private District $district;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        // Используем уникальное сокращение чтобы не конфликтовать с сидером
+        $this->district = District::factory()->create(['shortname' => 'ТСТОКР', 'name' => 'Тестовый округ']);
+    }
+
+    protected function tearDown(): void
+    {
+        if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) {
+            unlink($this->tempFilePath);
+        }
+        parent::tearDown();
+    }
+
+    public function test_import_creates_new_areas(): void
+    {
+        $this->createTestFile([
+            ['', 'ТестТверской', 'ТСТОКР', $this->district->id],
+            ['', 'ТестАрбат', 'ТСТОКР', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(2, $result['imported']);
+        $this->assertDatabaseHas('areas', ['name' => 'ТестТверской', 'district_id' => $this->district->id]);
+        $this->assertDatabaseHas('areas', ['name' => 'ТестАрбат', 'district_id' => $this->district->id]);
+    }
+
+    public function test_import_updates_existing_areas_by_id(): void
+    {
+        $area = Area::factory()->create([
+            'name' => 'Старое название',
+            'district_id' => $this->district->id,
+        ]);
+
+        $newDistrict = District::factory()->create(['shortname' => 'САО']);
+
+        $this->createTestFile([
+            [$area->id, 'Новое название', 'САО', $newDistrict->id],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+        $this->assertDatabaseHas('areas', [
+            'id' => $area->id,
+            'name' => 'Новое название',
+            'district_id' => $newDistrict->id,
+        ]);
+    }
+
+    public function test_import_updates_existing_areas_by_name_and_district(): void
+    {
+        $area = Area::factory()->create([
+            'name' => 'Тверской',
+            'district_id' => $this->district->id,
+        ]);
+
+        $this->createTestFile([
+            ['', 'Тверской', 'ЦАО', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+    }
+
+    public function test_import_finds_district_by_shortname(): void
+    {
+        $this->createTestFile([
+            ['', 'ТестовыйИмп1', 'ТСТОКР', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertDatabaseHas('areas', [
+            'name' => 'ТестовыйИмп1',
+            'district_id' => $this->district->id,
+        ]);
+    }
+
+    public function test_import_finds_district_by_id(): void
+    {
+        $this->createTestFile([
+            ['', 'Тестовый', '', $this->district->id],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertDatabaseHas('areas', [
+            'name' => 'Тестовый',
+            'district_id' => $this->district->id,
+        ]);
+    }
+
+    public function test_import_skips_rows_without_district(): void
+    {
+        $this->createTestFile([
+            ['', 'Район без округа', '', ''],
+            ['', 'Район с несуществующим округом', 'НЕСУЩЕСТВУЮЩИЙ', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(0, $result['imported']);
+        $this->assertEquals(2, $result['skipped']);
+    }
+
+    public function test_import_skips_empty_rows(): void
+    {
+        $this->createTestFile([
+            ['', '', '', ''],
+            ['', 'Тестовый', 'ЦАО', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['skipped']);
+    }
+
+    public function test_import_restores_soft_deleted_areas(): void
+    {
+        $area = Area::factory()->create([
+            'name' => 'Удалённый',
+            'district_id' => $this->district->id,
+        ]);
+        $area->delete();
+
+        $this->createTestFile([
+            [$area->id, 'Восстановленный', 'ЦАО', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+        $this->assertDatabaseHas('areas', [
+            'id' => $area->id,
+            'name' => 'Восстановленный',
+            'deleted_at' => null,
+        ]);
+    }
+
+    public function test_import_is_case_insensitive_for_district_shortname(): void
+    {
+        $this->createTestFile([
+            ['', 'ТестовыйИмп2', 'тстокр', ''], // lowercase
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertDatabaseHas('areas', [
+            'name' => 'ТестовыйИмп2',
+            'district_id' => $this->district->id,
+        ]);
+    }
+
+    public function test_import_returns_logs(): void
+    {
+        $this->createTestFile([
+            ['', 'Тестовый', 'ЦАО', ''],
+        ]);
+
+        $service = new ImportAreasService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertArrayHasKey('logs', $result);
+        $this->assertNotEmpty($result['logs']);
+    }
+
+    private function createTestFile(array $rows): void
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Headers
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Название');
+        $sheet->setCellValue('C1', 'Округ (сокращение)');
+        $sheet->setCellValue('D1', 'ID округа');
+
+        // Data rows
+        $rowNum = 2;
+        foreach ($rows as $row) {
+            $sheet->setCellValue('A' . $rowNum, $row[0] ?? '');
+            $sheet->setCellValue('B' . $rowNum, $row[1] ?? '');
+            $sheet->setCellValue('C' . $rowNum, $row[2] ?? '');
+            $sheet->setCellValue('D' . $rowNum, $row[3] ?? '');
+            $rowNum++;
+        }
+
+        $this->tempFilePath = sys_get_temp_dir() . '/test_areas_' . uniqid() . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->tempFilePath);
+    }
+}

+ 193 - 0
tests/Unit/Services/Dictionary/ImportDistrictsServiceTest.php

@@ -0,0 +1,193 @@
+<?php
+
+namespace Tests\Unit\Services\Dictionary;
+
+use App\Models\Dictionary\District;
+use App\Services\Import\ImportDistrictsService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportDistrictsServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private string $tempFilePath;
+
+    protected function tearDown(): void
+    {
+        if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) {
+            unlink($this->tempFilePath);
+        }
+        parent::tearDown();
+    }
+
+    public function test_import_creates_new_districts(): void
+    {
+        $this->createTestFile([
+            ['', 'НОВ', 'Новый округ'],
+            ['', 'ТСТ', 'Тестовый округ'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(2, $result['imported']);
+        $this->assertDatabaseHas('districts', ['shortname' => 'НОВ', 'name' => 'Новый округ']);
+        $this->assertDatabaseHas('districts', ['shortname' => 'ТСТ', 'name' => 'Тестовый округ']);
+    }
+
+    public function test_import_updates_existing_districts_by_id(): void
+    {
+        $district = District::factory()->create(['shortname' => 'СТР', 'name' => 'Старый']);
+
+        $this->createTestFile([
+            [$district->id, 'НВ', 'Новое название'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+        $this->assertDatabaseHas('districts', [
+            'id' => $district->id,
+            'shortname' => 'НВ',
+            'name' => 'Новое название',
+        ]);
+    }
+
+    public function test_import_updates_existing_districts_by_shortname(): void
+    {
+        // Удаляем существующий ЦАО из сидера если есть
+        District::where('shortname', 'ТЕСТЦАО')->forceDelete();
+        $district = District::factory()->create(['shortname' => 'ТЕСТЦАО', 'name' => 'Старое']);
+
+        $this->createTestFile([
+            ['', 'ТЕСТЦАО', 'Центральный административный округ'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+        $this->assertDatabaseHas('districts', [
+            'id' => $district->id,
+            'shortname' => 'ТЕСТЦАО',
+            'name' => 'Центральный административный округ',
+        ]);
+    }
+
+    public function test_import_skips_empty_rows(): void
+    {
+        $this->createTestFile([
+            ['', '', ''],
+            ['', 'НОВТЕСТ', 'Новый округ тест'],
+            ['', '', ''],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertGreaterThanOrEqual(1, $result['skipped']);
+        $this->assertDatabaseHas('districts', ['shortname' => 'НОВТЕСТ', 'name' => 'Новый округ тест']);
+    }
+
+    public function test_import_skips_rows_without_name(): void
+    {
+        $this->createTestFile([
+            ['', 'НОВ', ''], // No name
+            ['', 'ТСТ', 'Тестовый'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['skipped']);
+    }
+
+    public function test_import_restores_soft_deleted_districts(): void
+    {
+        $district = District::factory()->create(['shortname' => 'УДЛ', 'name' => 'Удалённый']);
+        $district->delete();
+
+        $this->createTestFile([
+            ['', 'УДЛ', 'Восстановленный'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['updated']);
+        $this->assertDatabaseHas('districts', [
+            'id' => $district->id,
+            'shortname' => 'УДЛ',
+            'name' => 'Восстановленный',
+            'deleted_at' => null,
+        ]);
+    }
+
+    public function test_import_generates_shortname_if_missing(): void
+    {
+        $this->createTestFile([
+            ['', '', 'Длинное название округа'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertDatabaseHas('districts', [
+            'shortname' => 'Длинное на',
+            'name' => 'Длинное название округа',
+        ]);
+    }
+
+    public function test_import_returns_logs(): void
+    {
+        $this->createTestFile([
+            ['', 'НОВ', 'Новый округ'],
+        ]);
+
+        $service = new ImportDistrictsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertArrayHasKey('logs', $result);
+        $this->assertNotEmpty($result['logs']);
+    }
+
+    private function createTestFile(array $rows): void
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Headers
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Сокращение');
+        $sheet->setCellValue('C1', 'Название');
+
+        // Data rows
+        $rowNum = 2;
+        foreach ($rows as $row) {
+            $sheet->setCellValue('A' . $rowNum, $row[0] ?? '');
+            $sheet->setCellValue('B' . $rowNum, $row[1] ?? '');
+            $sheet->setCellValue('C' . $rowNum, $row[2] ?? '');
+            $rowNum++;
+        }
+
+        $this->tempFilePath = sys_get_temp_dir() . '/test_districts_' . uniqid() . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->tempFilePath);
+    }
+}