瀏覽代碼

implemented reclamation types

Alexander Musikhin 1 天之前
父節點
當前提交
25566944e0

+ 27 - 2
app/Http/Controllers/ReclamationController.php

@@ -15,12 +15,14 @@ use App\Models\Order;
 use App\Models\Reclamation;
 use App\Models\ReclamationDetail;
 use App\Models\ReclamationStatus;
+use App\Models\ReclamationType;
 use App\Models\ReclamationView;
 use App\Models\User;
 use App\Services\FileService;
 use App\Services\NotificationService;
 use App\Services\SparePartReservationService;
 use Illuminate\Http\Request;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Storage;
 use Throwable;
@@ -35,6 +37,7 @@ class ReclamationController extends Controller
             'id' => 'ID',
             'user_name' => 'Менеджер',
             'status_name' => 'Статус',
+            'reclamation_type_name' => 'Тип',
             'district_name' => 'Округ',
             'area_name' => 'Район',
             'object_address' => 'Адрес объекта',
@@ -73,7 +76,7 @@ class ReclamationController extends Controller
         $nav = $this->startNavigationContext($request);
         $model = new ReclamationView();
         // fill filters
-        $this->createFilters($model, 'user_name', 'status_name');
+        $this->createFilters($model, 'user_name', 'status_name', 'reclamation_type_name');
         $this->createDateFilters($model, 'create_date', 'finish_date');
 
         $q = $model::query();
@@ -82,6 +85,7 @@ class ReclamationController extends Controller
         $this->acceptSearch($q, $request);
         $this->setSortAndOrderBy($model, $request);
 
+        $this->data['tab'] = $this->applyReclamationTypeTab($q, $request);
         $this->applyReclamationVisibilityScope($q, $request->user());
 
         $this->applyStableSorting($q);
@@ -97,23 +101,26 @@ class ReclamationController extends Controller
             'withFilter' => 'nullable',
             'filters' => 'nullable|array',
             's' => 'nullable|string',
+            'tab' => 'nullable|in:dkr',
         ]);
 
         $filterRequest = $request->boolean('withFilter')
             ? new Request(array_filter([
                 'filters' => $request->input('filters', []),
                 's' => $request->input('s'),
+                'tab' => $request->input('tab'),
             ], static fn ($value) => $value !== null))
             : new Request();
 
         $model = new ReclamationView();
-        $this->createFilters($model, 'user_name', 'status_name');
+        $this->createFilters($model, 'user_name', 'status_name', 'reclamation_type_name');
         $this->createDateFilters($model, 'create_date', 'finish_date');
 
         $q = $model::query();
         $this->acceptFilters($q, $filterRequest);
         $this->acceptSearch($q, $filterRequest);
         $this->setSortAndOrderBy($model, $filterRequest);
+        $this->applyReclamationTypeTab($q, $filterRequest);
         $this->applyReclamationVisibilityScope($q, $request->user());
         $this->applyStableSorting($q);
 
@@ -130,6 +137,7 @@ class ReclamationController extends Controller
         $nav = $this->resolveNavToken($request);
         $reclamation = Reclamation::query()->create([
             'order_id' => $order->id,
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_DKR),
             'user_id' => $request->user()->id,
             'status_id' => Reclamation::STATUS_NEW,
             'create_date' => Carbon::now(),
@@ -151,6 +159,7 @@ class ReclamationController extends Controller
             ->pluck('name', 'id');
         $this->data['reclamation'] = $reclamation->load([
             'order',
+            'reclamationType',
             'chatMessages.user',
             'chatMessages.targetUser',
             'chatMessages.notifiedUsers',
@@ -567,6 +576,9 @@ class ReclamationController extends Controller
 
     public function generateReclamationPaymentPack(Request $request, Reclamation $reclamation)
     {
+        $this->ensureCanViewReclamation($reclamation);
+        abort_unless($reclamation->isDkr(), 403);
+
         GenerateReclamationPaymentPack::dispatch($reclamation, auth()->user()->id);
         return $this->redirectToReclamationShow($request, $reclamation)
             ->with(['success' => 'Задача генерации пакета документов на оплату создана!']);
@@ -609,6 +621,19 @@ class ReclamationController extends Controller
         };
     }
 
+    private function applyReclamationTypeTab(Builder $query, Request $request): string
+    {
+        $tab = $request->input('tab') === ReclamationType::CODE_DKR
+            ? ReclamationType::CODE_DKR
+            : 'all';
+
+        if ($tab === ReclamationType::CODE_DKR) {
+            $query->where('reclamation_type_code', ReclamationType::CODE_DKR);
+        }
+
+        return $tab;
+    }
+
     private function canViewReclamationByVisibilityScope(Reclamation $reclamation, ?User $user): bool
     {
         return match ($user?->visibilityScope('reclamations')) {

+ 11 - 0
app/Models/Reclamation.php

@@ -48,6 +48,7 @@ class Reclamation extends Model
 
     protected $fillable = [
         'order_id',
+        'reclamation_type_id',
         'user_id',
         'status_id',
         'reason',
@@ -72,6 +73,16 @@ class Reclamation extends Model
         return $this->belongsTo(ReclamationStatus::class);
     }
 
+    public function reclamationType(): BelongsTo
+    {
+        return $this->belongsTo(ReclamationType::class);
+    }
+
+    public function isDkr(): bool
+    {
+        return $this->reclamationType?->code === ReclamationType::CODE_DKR;
+    }
+
     public function skus(): BelongsToMany
     {
         return $this->belongsToMany(

+ 28 - 0
app/Models/ReclamationType.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class ReclamationType extends Model
+{
+    public const CODE_DKR = 'dkr';
+
+    public const CODE_OTHER = 'other';
+
+    protected $fillable = [
+        'code',
+        'name',
+    ];
+
+    public function reclamations(): HasMany
+    {
+        return $this->hasMany(Reclamation::class);
+    }
+
+    public static function idForCode(string $code): int
+    {
+        return (int) self::query()->where('code', $code)->firstOrFail(['id'])->getKey();
+    }
+}

+ 3 - 0
app/Models/ReclamationView.php

@@ -17,6 +17,9 @@ class ReclamationView extends Model
     protected $fillable = [
         'id',
         'order_id',
+        'reclamation_type_id',
+        'reclamation_type_code',
+        'reclamation_type_name',
         'user_id',
         'status_id',
         'create_date',

+ 5 - 2
app/Services/Export/ExportYearDataService.php

@@ -343,7 +343,7 @@ class ExportYearDataService
             ->pluck('id');
 
         $reclamations = Reclamation::whereIn('order_id', $orderIds)
-            ->with(['order', 'user', 'brigadier', 'status'])
+            ->with(['order', 'user', 'brigadier', 'status', 'reclamationType'])
             ->get();
 
         $spreadsheet = new Spreadsheet();
@@ -354,7 +354,8 @@ class ExportYearDataService
 
         $headers = [
             'id', 'order_id', 'order_address', 'user_id', 'user_name', 'status_id',
-            'status_name', 'reason', 'guarantee', 'whats_done', 'create_date',
+            'status_name', 'reclamation_type_code', 'reclamation_type_name',
+            'reason', 'guarantee', 'whats_done', 'create_date',
             'finish_date', 'start_work_date', 'work_days', 'brigadier_id',
             'brigadier_name', 'comment', 'created_at', 'updated_at'
         ];
@@ -371,6 +372,8 @@ class ExportYearDataService
                 $reclamation->user?->name,
                 $reclamation->status_id,
                 $reclamation->status?->name,
+                $reclamation->reclamationType?->code,
+                $reclamation->reclamationType?->name,
                 $reclamation->reason,
                 $reclamation->guarantee,
                 $reclamation->whats_done,

+ 4 - 0
app/Services/GenerateDocumentsService.php

@@ -452,6 +452,10 @@ class GenerateDocumentsService
      */
     public function generateReclamationPaymentPack(Reclamation $reclamation, int $userId): string
     {
+        if (!$reclamation->isDkr()) {
+            throw new \DomainException('Пакет документов на оплату доступен только для рекламаций типа ДКР.');
+        }
+
         $reclamation->loadMissing([
             'order.statements',
             'documents',

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

@@ -10,6 +10,7 @@ use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\ReclamationDetail;
+use App\Models\ReclamationType;
 use App\Models\Schedule;
 use App\Models\Ttn;
 use App\Models\User;
@@ -43,6 +44,7 @@ class ImportYearDataService
     private array $objectTypeMapping = [];
     private array $orderStatusMapping = [];
     private array $reclamationStatusMapping = [];
+    private array $reclamationTypeMapping = [];
 
     public function __construct(
         private readonly string $archivePath,
@@ -201,6 +203,12 @@ class ImportYearDataService
             $this->reclamationStatusMapping[$rs->name] = $rs->id;
         }
 
+        $reclamationTypes = ReclamationType::query()->get();
+        foreach ($reclamationTypes as $reclamationType) {
+            $this->reclamationTypeMapping[$reclamationType->code] = $reclamationType->id;
+            $this->reclamationTypeMapping[$reclamationType->name] = $reclamationType->id;
+        }
+
         $this->log("Справочники загружены");
     }
 
@@ -781,9 +789,18 @@ class ImportYearDataService
 
             $statusName = $this->getValue($row, $headerMap, 'status_name');
             $statusId = $this->reclamationStatusMapping[$statusName] ?? Reclamation::STATUS_NEW;
+            $typeValue = $this->getValue(
+                $row,
+                $headerMap,
+                'reclamation_type_code',
+                $this->getValue($row, $headerMap, 'reclamation_type_name', ReclamationType::CODE_DKR),
+            );
+            $reclamationTypeId = $this->reclamationTypeMapping[$typeValue]
+                ?? $this->reclamationTypeMapping[ReclamationType::CODE_DKR];
 
             $reclamationData = [
                 'order_id' => $newOrderId,
+                'reclamation_type_id' => $reclamationTypeId,
                 'user_id' => $userId,
                 'status_id' => $statusId,
                 'reason' => $this->getValue($row, $headerMap, 'reason'),

+ 3 - 0
app/Services/ImportReclamationsService.php

@@ -8,6 +8,7 @@ use App\Models\Order;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
+use App\Models\ReclamationType;
 use App\Models\Setting;
 use Illuminate\Support\Str;
 
@@ -53,6 +54,7 @@ class ImportReclamationsService extends ImportBaseService
             Setting::KEY_DEFAULT_MAF_ORDER_USER_ID,
             (int) config('app.default_maf_order_user_id')
         );
+        $dkrReclamationTypeId = ReclamationType::idForCode(ReclamationType::CODE_DKR);
         $errors = [
             'district_not_found' => [],
             'area_not_found' => [],
@@ -162,6 +164,7 @@ class ImportReclamationsService extends ImportBaseService
                     $reclamation = Reclamation::query()
                         ->create([
                             'order_id' => $order->id,
+                            'reclamation_type_id' => $dkrReclamationTypeId,
                             'user_id' => $userId,
                             'status_id' => $statusId,
                             'create_date' => $createDate,

+ 16 - 0
database/factories/ReclamationFactory.php

@@ -4,6 +4,7 @@ namespace Database\Factories;
 
 use App\Models\Order;
 use App\Models\Reclamation;
+use App\Models\ReclamationType;
 use App\Models\User;
 use Illuminate\Database\Eloquent\Factories\Factory;
 
@@ -18,6 +19,7 @@ class ReclamationFactory extends Factory
     {
         return [
             'order_id' => Order::factory(),
+            'reclamation_type_id' => fn (): int => ReclamationType::idForCode(ReclamationType::CODE_DKR),
             'user_id' => User::factory(),
             'status_id' => Reclamation::STATUS_NEW,
             'reason' => fake()->sentence(),
@@ -40,6 +42,20 @@ class ReclamationFactory extends Factory
         ]);
     }
 
+    public function dkr(): static
+    {
+        return $this->state(fn (): array => [
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_DKR),
+        ]);
+    }
+
+    public function other(): static
+    {
+        return $this->state(fn (): array => [
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_OTHER),
+        ]);
+    }
+
     public function inWork(): static
     {
         return $this->state(fn (array $attributes) => [

+ 107 - 0
database/migrations/2026_07_17_000001_create_reclamation_types_table.php

@@ -0,0 +1,107 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::unprepared('DROP VIEW IF EXISTS reclamations_view');
+
+        Schema::create('reclamation_types', function (Blueprint $table): void {
+            $table->id();
+            $table->string('code')->unique();
+            $table->string('name');
+            $table->timestamps();
+        });
+
+        $now = now();
+        DB::table('reclamation_types')->insert([
+            ['code' => 'dkr', 'name' => 'ДКР', 'created_at' => $now, 'updated_at' => $now],
+            ['code' => 'other', 'name' => 'Прочее', 'created_at' => $now, 'updated_at' => $now],
+        ]);
+
+        Schema::table('reclamations', function (Blueprint $table): void {
+            $table->unsignedBigInteger('reclamation_type_id')->nullable()->after('order_id');
+        });
+
+        $dkrTypeId = DB::table('reclamation_types')->where('code', 'dkr')->value('id');
+        DB::table('reclamations')->update(['reclamation_type_id' => $dkrTypeId]);
+
+        Schema::table('reclamations', function (Blueprint $table): void {
+            $table->unsignedBigInteger('reclamation_type_id')->nullable(false)->change();
+            $table->foreign('reclamation_type_id')
+                ->references('id')
+                ->on('reclamation_types')
+                ->restrictOnDelete();
+        });
+
+        $this->createReclamationsViewWithType();
+    }
+
+    public function down(): void
+    {
+        DB::unprepared('DROP VIEW IF EXISTS reclamations_view');
+
+        Schema::table('reclamations', function (Blueprint $table): void {
+            $table->dropForeign(['reclamation_type_id']);
+            $table->dropColumn('reclamation_type_id');
+        });
+
+        Schema::dropIfExists('reclamation_types');
+
+        $this->createPreviousReclamationsView();
+    }
+
+    private function createReclamationsViewWithType(): void
+    {
+        DB::unprepared(<<<'SQL'
+            CREATE VIEW reclamations_view AS
+                SELECT r.*,
+                    u.name AS user_name,
+                    u1.name AS brigadier_name,
+                    o.year,
+                    o.name AS order_name,
+                    o.object_address,
+                    o.district_name,
+                    o.area_name,
+                    o.object_type_name,
+                    rs.name AS status_name,
+                    o.year AS maf_installation_year,
+                    rt.code AS reclamation_type_code,
+                    rt.name AS reclamation_type_name
+                FROM reclamations r
+                    LEFT JOIN users u ON r.user_id = u.id
+                    LEFT JOIN users u1 ON r.brigadier_id = u1.id
+                    LEFT JOIN reclamation_statuses rs ON r.status_id = rs.id
+                    LEFT JOIN orders_view o ON r.order_id = o.id
+                    INNER JOIN reclamation_types rt ON r.reclamation_type_id = rt.id
+            SQL);
+    }
+
+    private function createPreviousReclamationsView(): void
+    {
+        DB::unprepared(<<<'SQL'
+            CREATE VIEW reclamations_view AS
+                SELECT r.*,
+                    u.name AS user_name,
+                    u1.name AS brigadier_name,
+                    o.year,
+                    o.name AS order_name,
+                    o.object_address,
+                    o.district_name,
+                    o.area_name,
+                    o.object_type_name,
+                    rs.name AS status_name,
+                    o.year AS maf_installation_year
+                FROM reclamations r
+                    LEFT JOIN users u ON r.user_id = u.id
+                    LEFT JOIN users u1 ON r.brigadier_id = u1.id
+                    LEFT JOIN reclamation_statuses rs ON r.status_id = rs.id
+                    LEFT JOIN orders_view o ON r.order_id = o.id
+            SQL);
+    }
+};

+ 1 - 0
database/seeders/DatabaseSeeder.php

@@ -28,6 +28,7 @@ class DatabaseSeeder extends Seeder
         $this->call(OrderStatusSeeder::class);
         $this->call(BrigadierSeeder::class);
         $this->call(ReclamationStatusSeeder::class);
+        $this->call(ReclamationTypeSeeder::class);
         $this->call(RbacSeeder::class);
     }
 }

+ 27 - 0
database/seeders/ReclamationTypeSeeder.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\ReclamationType;
+use Illuminate\Database\Seeder;
+
+class ReclamationTypeSeeder extends Seeder
+{
+    public function run(): void
+    {
+        foreach ($this->types() as $code => $name) {
+            ReclamationType::query()->updateOrCreate(
+                ['code' => $code],
+                ['name' => $name],
+            );
+        }
+    }
+
+    private function types(): array
+    {
+        return [
+            ReclamationType::CODE_DKR => 'ДКР',
+            ReclamationType::CODE_OTHER => 'Прочее',
+        ];
+    }
+}

+ 3 - 3
docs/refactor/menu.md

@@ -77,7 +77,7 @@ Manager
 
 ### Графики
 
-Статус: **Частично**.
+Статус: **Текущие рекламации реализованы; интеграция с графиком заказов отложена**.
 
 Нужна новая верхняя группа `Графики`. На первом этапе недостающие пункты можно добавить как заглушки.
 
@@ -91,12 +91,12 @@ Manager
 
 Статус: **Частично**.
 
-Текущий раздел рекламаций есть, но в новом интерфейсе должен иметь отдельные вкладки `Все` и `ДКР`. Вкладка `ДКР` обязательна для отделения рекламаций, по которым формируется документация для оплаты.
+В текущем разделе реализованы отдельные вкладки `Все` и `ДКР`. Вкладка `ДКР` отделяет рекламации, по которым разрешено формирование документации для оплаты.
 
 | Пункт | Статус | Текущий маршрут | Комментарий |
 |---|---|---|---|
 | Все | Есть | `reclamations.index` | Общий список всех рекламаций. |
-| ДКР | Частично | `reclamations.index` | Отдельная вкладка текущего списка с обязательным фильтром по типу `dkr` (`ДКР`); только здесь доступны рекламации для формирования платежных документов. |
+| ДКР | Есть | `reclamations.index` | Отдельная вкладка текущего списка с обязательным фильтром по типу `dkr` (`ДКР`); только для этого типа доступны платежные документы. |
 
 Рекламации из `График заказов` создаются из карточки заказа после выбора одного или нескольких МАФ. Открывается текущая форма создания, назначается тип `other` (`Прочее`) из справочника `reclamation_types`; такие рекламации не выводятся во вкладке `ДКР`, и для них недоступна документация для оплаты.
 

+ 12 - 12
docs/refactor/plan.md

@@ -70,8 +70,8 @@
 | Этап | Статус | Почему |
 |---|---|---|
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
-| 2. Адаптация существующих разделов после переноса | Реализован, ожидает пользовательской проверки | Рабочие разделы проверены, навигация запчастей и доступ к справочнику уточнены, регрессионные тесты проходят. |
-| 3. Рекламации: вкладки и тип | Готов к реализации | Решены вкладки `Все`/`ДКР`, справочник типов, начальные значения и запрет платежных документов для `Прочее`. |
+| 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
+| 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
 | 4. Общий каталог: ядро | Готов к реализации | Есть маршрутный префикс, техническое имя, шаблон Excel, состав колонок и предварительные типы данных. |
 | 5. Документация | Готов к реализации | Решены дерево папок, наследование прав, версии и хранение файлов. |
 | 6. Склад наличие: ядро | Готов после ядра общего каталога | Логика берется из `Запчасти`, но позиции должны браться из `Каталог общий`. PDF-экспорт остается отдельным открытым подпунктом. |
@@ -104,7 +104,7 @@
 
 ## Этап 2. Адаптация существующих разделов после переноса
 
-Статус: **реализован, ожидает пользовательской проверки**.
+Статус: **реализован и проверен пользователем**.
 
 Задача этапа — проверить существующие рабочие разделы после изменения меню и внести точечные правки без изменения бизнес-логики.
 
@@ -121,18 +121,18 @@
 
 ## Этап 3. Рекламации: вкладки и тип
 
-Статус: **готов к реализации для текущих рекламаций**.
+Статус: **реализован для текущих рекламаций, ожидает пользовательской проверки**.
 
 Создание рекламации из графика заказов вынесено в отдельный этап, потому что зависит от реализации графика заказов.
 
-- [ ] Реализовать отдельные вкладки `Все` и `ДКР`.
-- [ ] Создать справочник `reclamation_types` и заполнить его типами `ДКР` / `Прочее` через сидер.
-- [ ] Добавить обязательную связь `reclamations.reclamation_type_id` и перенести существующие записи на тип `ДКР`.
-- [ ] Реализовать вкладку `ДКР` на текущем списке с обязательным фильтром по типу `dkr`.
-- [ ] Проверить создание рекламаций по площадкам ДКР.
-- [ ] Запретить формирование документации для оплаты для рекламаций типа `Прочее`.
-- [ ] Проверить формирование запросов на запчасти из рекламации.
-- [ ] Сохранить текущую рабочую логику рекламаций.
+- [x] Реализовать отдельные вкладки `Все` и `ДКР`.
+- [x] Создать справочник `reclamation_types` и заполнить его типами `ДКР` / `Прочее` через сидер.
+- [x] Добавить обязательную связь `reclamations.reclamation_type_id` и перенести существующие записи на тип `ДКР`.
+- [x] Реализовать вкладку `ДКР` на текущем списке с обязательным фильтром по типу `dkr`.
+- [x] Проверить создание рекламаций по площадкам ДКР.
+- [x] Запретить формирование документации для оплаты для рекламаций типа `Прочее`.
+- [x] Проверить формирование запросов на запчасти из рекламации.
+- [x] Сохранить текущую рабочую логику рекламаций.
 
 ## Этап 4. Каталог общий: ядро
 

+ 17 - 17
docs/refactor/tz-reclamations.md

@@ -28,7 +28,7 @@
 
 ## 2. Статус
 
-Статус модуля: **частично реализован**.
+Статус модуля: **реализован для текущих рекламаций, ожидает пользовательской проверки**.
 
 В текущей CRM уже есть:
 
@@ -44,7 +44,7 @@
 - связь с запчастями и резервами;
 - создание записи в графике монтажей из рекламации.
 
-На первом этапе сохраняется текущая бизнес-логика рекламаций и добавляется явное разделение интерфейса на вкладки `Все` / `ДКР`.
+Текущая бизнес-логика рекламаций сохранена, добавлены типы и явное разделение интерфейса на вкладки `Все` / `ДКР`. Создание рекламаций типа `Прочее` из графика заказов остается отложенным до реализации самого графика.
 
 ## 3. Место в меню
 
@@ -249,24 +249,24 @@
 
 ## 12. Этапы реализации
 
-- [ ] Проверить текущие маршруты `reclamations.*`.
-- [ ] Проверить текущие permissions рекламаций.
-- [ ] Добавить верхнюю группу меню `Рекламации`.
-- [ ] Добавить отдельные вкладки `Все` и `ДКР`.
-- [ ] Создать таблицу `reclamation_types` и модель `ReclamationType`.
-- [ ] Создать и зарегистрировать `ReclamationTypeSeeder` с типами `dkr` (`ДКР`) и `other` (`Прочее`).
-- [ ] Добавить `reclamations.reclamation_type_id`, внешний ключ, индекс и связи моделей.
-- [ ] Назначить существующим рекламациям тип `dkr` и сделать поле типа обязательным.
-- [ ] Реализовать вкладку `ДКР` на текущем списке с обязательным фильтром по типу `dkr`.
-- [ ] Проверить открытие карточки рекламации.
-- [ ] Проверить создание рекламации по площадке ДКР.
+- [x] Проверить текущие маршруты `reclamations.*`.
+- [x] Проверить текущие permissions рекламаций.
+- [x] Добавить верхнюю группу меню `Рекламации`.
+- [x] Добавить отдельные вкладки `Все` и `ДКР`.
+- [x] Создать таблицу `reclamation_types` и модель `ReclamationType`.
+- [x] Создать и зарегистрировать `ReclamationTypeSeeder` с типами `dkr` (`ДКР`) и `other` (`Прочее`).
+- [x] Добавить `reclamations.reclamation_type_id`, внешний ключ, индекс и связи моделей.
+- [x] Назначить существующим рекламациям тип `dkr` и сделать поле типа обязательным.
+- [x] Реализовать вкладку `ДКР` на текущем списке с обязательным фильтром по типу `dkr`.
+- [x] Проверить открытие карточки рекламации.
+- [x] Проверить создание рекламации по площадке ДКР.
 - [ ] Реализовать создание рекламации из графика заказов с выбором одного или нескольких МАФ.
 - [ ] Передавать заказ и выбранные МАФ в текущую форму создания рекламации.
 - [ ] Автоматически назначать тип рекламации в зависимости от сценария создания.
-- [ ] Запретить формирование документации для оплаты для типа `Прочее`.
-- [ ] Проверить связь с графиком монтажей.
-- [ ] Проверить блок запчастей и резервов.
-- [ ] Проверить экспорт рекламаций.
+- [x] Запретить формирование документации для оплаты для типа `Прочее`.
+- [x] Проверить связь с графиком монтажей.
+- [x] Проверить блок запчастей и резервов.
+- [x] Проверить экспорт рекламаций.
 
 ## 13. Критерии приемки
 

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

@@ -82,8 +82,8 @@
                 Рекламации
             </a>
             <ul class="dropdown-menu">
-                <li><a class="dropdown-item @if(($active ?? '') === 'reclamations' && request('tab') !== 'dkr') active @endif" href="{{ route('reclamations.index', session('gp_reclamations')) }}">Все</a></li>
-                <li><a class="dropdown-item @if(($active ?? '') === 'reclamations' && request('tab') === 'dkr') active @endif" href="{{ route('reclamations.index', array_merge((array) session('gp_reclamations', []), ['tab' => 'dkr'])) }}">ДКР</a></li>
+                <li><a class="dropdown-item @if(($active ?? '') === 'reclamations' && request('tab') !== 'dkr') active @endif" href="{{ route('reclamations.index', \Illuminate\Support\Arr::except((array) session('gp_reclamations', []), ['tab', 'page'])) }}">Все</a></li>
+                <li><a class="dropdown-item @if(($active ?? '') === 'reclamations' && request('tab') === 'dkr') active @endif" href="{{ route('reclamations.index', array_merge(\Illuminate\Support\Arr::except((array) session('gp_reclamations', []), ['page']), ['tab' => 'dkr'])) }}">ДКР</a></li>
             </ul>
         </li>
     @endif

+ 3 - 0
resources/views/reclamations/edit.blade.php

@@ -27,6 +27,8 @@
                 @if(hasPermission('reclamations.update'))
                     <a href="{{ route('order.generate-reclamation-pack', ['reclamation' => $reclamation, 'nav' => $nav ?? null]) }}"
                        class="btn btn-primary btn-sm">Пакет документов рекламации</a>
+                @endif
+                @if(hasPermission('reclamations.documents.generate') && $reclamation->isDkr())
                     <a href="{{ route('reclamation.generate-reclamation-payment-pack', ['reclamation' => $reclamation, 'nav' => $nav ?? null]) }}"
                        class="btn btn-primary btn-sm">Пакет документов на оплату</a>
                 @endif
@@ -43,6 +45,7 @@
                     <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
 
                     @include('partials.link', ['title' => 'Площадка', 'href' => route('order.show', ['order' => $reclamation->order_id, 'sync_year' => 1, 'nav' => $nav ?? null]), 'text' => $reclamation->order->common_name ?? ''])
+                    @include('partials.input', ['name' => 'reclamation_type', 'title' => 'Тип рекламации', 'type' => 'text', 'value' => $reclamation->reclamationType?->name ?? '-', 'disabled' => true])
                     @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.input', ['name' => 'maf_installation_year', 'title' => 'Год установки МАФ', 'type' => 'text', 'value' => $reclamation->order->year, 'disabled' => true])

+ 17 - 0
resources/views/reclamations/index.blade.php

@@ -17,6 +17,20 @@
         </div>
     </div>
 
+    @php
+        $tabQuery = \Illuminate\Support\Arr::except(request()->query(), ['tab', 'page']);
+    @endphp
+    <ul class="nav nav-tabs mb-3">
+        <li class="nav-item">
+            <a class="nav-link @if(($tab ?? 'all') === 'all') active @endif"
+               href="{{ route('reclamations.index', $tabQuery) }}">Все</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @if(($tab ?? 'all') === 'dkr') active @endif"
+               href="{{ route('reclamations.index', array_merge($tabQuery, ['tab' => 'dkr'])) }}">ДКР</a>
+        </li>
+    </ul>
+
     @if(hasPermission('reclamations.export'))
         <div class="modal fade" id="exportReclamationsModal" tabindex="-1" aria-labelledby="exportReclamationsModalLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
@@ -30,6 +44,9 @@
                             @csrf
                             @include('partials.checkbox', ['title' => 'С учётом текущего фильтра и поиска', 'name' => 'withFilter', 'type' => 'checkbox', 'value' => 'yes', 'checked' => false])
                             <div class="d-none">
+                                @if(($tab ?? 'all') === 'dkr')
+                                    <input type="hidden" name="tab" value="dkr">
+                                @endif
                                 @if(request()->s)
                                     @include('partials.input', ['name' => 's', 'title' => 'поиск', 'value' => request()->s])
                                 @endif

+ 121 - 0
tests/Feature/ReclamationControllerTest.php

@@ -2,12 +2,15 @@
 
 namespace Tests\Feature;
 
+use App\Jobs\ExportReclamationsJob;
+use App\Jobs\GenerateReclamationPaymentPack;
 use App\Models\File;
 use App\Models\Order;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\ReclamationDetail;
+use App\Models\ReclamationType;
 use App\Models\Reservation;
 use App\Models\Role;
 use App\Models\SparePart;
@@ -15,7 +18,9 @@ use App\Models\SparePartOrder;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
 use Illuminate\Support\Facades\Storage;
+use ReflectionProperty;
 use Tests\TestCase;
 
 class ReclamationControllerTest extends TestCase
@@ -67,6 +72,55 @@ class ReclamationControllerTest extends TestCase
         $response->assertStatus(200);
     }
 
+    public function test_all_tab_displays_reclamations_of_every_type(): void
+    {
+        $dkr = Reclamation::factory()->dkr()->create(['reason' => 'Рекламация ДКР для общего списка']);
+        $other = Reclamation::factory()->other()->create(['reason' => 'Прочая рекламация для общего списка']);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('reclamations.index'));
+
+        $response->assertOk()
+            ->assertViewHas('tab', 'all')
+            ->assertSee($dkr->reason)
+            ->assertSee($other->reason);
+    }
+
+    public function test_dkr_tab_displays_only_dkr_reclamations(): void
+    {
+        $dkr = Reclamation::factory()->dkr()->create(['reason' => 'Рекламация только вкладки ДКР']);
+        $other = Reclamation::factory()->other()->create(['reason' => 'Рекламация типа Прочее']);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('reclamations.index', ['tab' => 'dkr']));
+
+        $response->assertOk()
+            ->assertViewHas('tab', ReclamationType::CODE_DKR)
+            ->assertSee($dkr->reason)
+            ->assertDontSee($other->reason);
+    }
+
+    public function test_export_with_dkr_tab_filter_contains_only_dkr_reclamations(): void
+    {
+        Bus::fake();
+
+        $dkr = Reclamation::factory()->dkr()->create();
+        Reclamation::factory()->other()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('reclamations.export'), [
+                'withFilter' => '1',
+                'tab' => ReclamationType::CODE_DKR,
+            ]);
+
+        $response->assertRedirect();
+        Bus::assertDispatched(ExportReclamationsJob::class, function (ExportReclamationsJob $job) use ($dkr) {
+            $property = new ReflectionProperty($job, 'reclamationIds');
+
+            return $property->getValue($job) === [$dkr->id];
+        });
+    }
+
     public function test_brigadier_sees_only_assigned_reclamations_with_allowed_statuses(): void
     {
         $visibleReclamation = Reclamation::factory()->create([
@@ -116,11 +170,33 @@ class ReclamationControllerTest extends TestCase
 
         $this->assertDatabaseHas('reclamations', [
             'order_id' => $order->id,
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_DKR),
             'user_id' => $this->managerUser->id,
             'status_id' => Reclamation::STATUS_NEW,
         ]);
     }
 
+    public function test_creating_reclamation_from_dkr_order_ignores_spoofed_type(): void
+    {
+        $order = Order::factory()->create();
+        $productSku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => Product::factory(),
+        ]);
+
+        $this->actingAs($this->managerUser)
+            ->post(route('reclamations.create', $order), [
+                'skus' => [$productSku->id],
+                'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_OTHER),
+            ])
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('reclamations', [
+            'order_id' => $order->id,
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_DKR),
+        ]);
+    }
+
     public function test_creating_reclamation_from_order_preserves_nav_token(): void
     {
         $order = Order::factory()->create();
@@ -187,6 +263,26 @@ class ReclamationControllerTest extends TestCase
         $response->assertViewIs('reclamations.edit');
     }
 
+    public function test_reclamation_card_displays_type_and_payment_action_only_for_dkr(): void
+    {
+        $dkr = Reclamation::factory()->dkr()->create();
+        $other = Reclamation::factory()->other()->create();
+
+        $this->actingAs($this->managerUser)
+            ->get(route('reclamations.show', $dkr))
+            ->assertOk()
+            ->assertSee('Тип рекламации')
+            ->assertSee('ДКР')
+            ->assertSee('Пакет документов на оплату');
+
+        $this->actingAs($this->managerUser)
+            ->get(route('reclamations.show', $other))
+            ->assertOk()
+            ->assertSee('Тип рекламации')
+            ->assertSee('Прочее')
+            ->assertDontSee('Пакет документов на оплату');
+    }
+
     public function test_reclamation_show_uses_nav_context_for_back_url(): void
     {
         $reclamation = Reclamation::factory()->create();
@@ -849,6 +945,31 @@ class ReclamationControllerTest extends TestCase
         $response->assertSessionHas('success');
     }
 
+    public function test_can_generate_reclamation_payment_pack_for_dkr(): void
+    {
+        Bus::fake([GenerateReclamationPaymentPack::class]);
+        $reclamation = Reclamation::factory()->dkr()->create();
+
+        $this->actingAs($this->managerUser)
+            ->get(route('reclamation.generate-reclamation-payment-pack', $reclamation))
+            ->assertRedirect()
+            ->assertSessionHas('success');
+
+        Bus::assertDispatched(GenerateReclamationPaymentPack::class);
+    }
+
+    public function test_cannot_generate_reclamation_payment_pack_for_other_type(): void
+    {
+        Bus::fake([GenerateReclamationPaymentPack::class]);
+        $reclamation = Reclamation::factory()->other()->create();
+
+        $this->actingAs($this->managerUser)
+            ->get(route('reclamation.generate-reclamation-payment-pack', $reclamation))
+            ->assertForbidden();
+
+        Bus::assertNotDispatched(GenerateReclamationPaymentPack::class);
+    }
+
     public function test_can_generate_photos_before_pack(): void
     {
         $reclamation = Reclamation::factory()->create();

+ 34 - 0
tests/Unit/Models/ReclamationTest.php

@@ -4,6 +4,7 @@ namespace Tests\Unit\Models;
 
 use App\Models\Order;
 use App\Models\Reclamation;
+use App\Models\ReclamationType;
 use App\Models\Reservation;
 use App\Models\Shortage;
 use App\Models\SparePart;
@@ -11,6 +12,7 @@ use App\Models\SparePartOrder;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Tests\TestCase;
+use Database\Seeders\ReclamationTypeSeeder;
 
 class ReclamationTest extends TestCase
 {
@@ -74,6 +76,38 @@ class ReclamationTest extends TestCase
         $this->assertEquals($brigadier->id, $reclamation->brigadier->id);
     }
 
+    public function test_reclamation_belongs_to_type(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $this->assertInstanceOf(ReclamationType::class, $reclamation->reclamationType);
+        $this->assertTrue($reclamation->isDkr());
+    }
+
+    public function test_other_reclamation_is_not_dkr(): void
+    {
+        $reclamation = Reclamation::factory()->other()->create();
+
+        $this->assertSame(ReclamationType::CODE_OTHER, $reclamation->reclamationType->code);
+        $this->assertFalse($reclamation->isDkr());
+    }
+
+    public function test_reclamation_type_seeder_is_idempotent(): void
+    {
+        $this->seed(ReclamationTypeSeeder::class);
+        $this->seed(ReclamationTypeSeeder::class);
+
+        $this->assertDatabaseCount('reclamation_types', 2);
+        $this->assertDatabaseHas('reclamation_types', [
+            'code' => ReclamationType::CODE_DKR,
+            'name' => 'ДКР',
+        ]);
+        $this->assertDatabaseHas('reclamation_types', [
+            'code' => ReclamationType::CODE_OTHER,
+            'name' => 'Прочее',
+        ]);
+    }
+
     public function test_reclamation_has_many_spare_part_reservations(): void
     {
         // Arrange

+ 10 - 0
tests/Unit/Services/GenerateDocumentsServiceTest.php

@@ -371,6 +371,16 @@ class GenerateDocumentsServiceTest extends TestCase
         }
     }
 
+    public function test_payment_pack_rejects_other_reclamation_type(): void
+    {
+        $reclamation = Reclamation::factory()->other()->create();
+
+        $this->expectException(\DomainException::class);
+        $this->expectExceptionMessage('только для рекламаций типа ДКР');
+
+        $this->service->generateReclamationPaymentPack($reclamation, User::factory()->create()->id);
+    }
+
     private function putLargePublicFile(string $path, int $bytes): void
     {
         Storage::disk('public')->makeDirectory(dirname($path));

+ 4 - 2
tests/Unit/Services/Import/ImportReclamationsServiceTest.php

@@ -8,6 +8,7 @@ use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\ReclamationStatus;
+use App\Models\ReclamationType;
 use App\Models\User;
 use App\Services\ImportReclamationsService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -211,9 +212,10 @@ class ImportReclamationsServiceTest extends TestCase
         $this->assertTrue($result);
 
         $this->assertDatabaseHas('reclamations', [
-            'order_id'  => $order->id,
+            'order_id' => $order->id,
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_DKR),
             'status_id' => $status->id,
-            'reason'    => 'Вандализм',
+            'reason' => 'Вандализм',
         ]);
     }