Bladeren bron

chat/responsibles/mafs: fix brigadier recipients and broken filters

Alexander Musikhin 2 weken geleden
bovenliggende
commit
878bce61fa

+ 10 - 2
app/Http/Controllers/Controller.php

@@ -222,7 +222,7 @@ class Controller extends BaseController
                         $nonNullValues = [];
                         foreach ($values as $v) {
                             if ($v == '-пусто-') {
-                                $q->orWhereNull($dbColumn);
+                                $this->applyEmptyFilterCondition($q, $dbColumn);
                             } else {
                                 $nonNullValues[] = $v;
                             }
@@ -233,7 +233,9 @@ class Controller extends BaseController
                     });
                 } else {
                     if($dbValue == '-пусто-') {
-                        $query->whereNull($dbColumn);
+                        $query->where(function ($q) use ($dbColumn) {
+                            $this->applyEmptyFilterCondition($q, $dbColumn);
+                        });
                     } else {
                         $query->where($dbColumn, $dbValue);
                     }
@@ -306,6 +308,12 @@ class Controller extends BaseController
         return [$dbColumn, $filterValue];
     }
 
+    protected function applyEmptyFilterCondition(Builder $query, string $dbColumn): void
+    {
+        $query->orWhereNull($dbColumn)
+            ->orWhereRaw("TRIM(CAST({$dbColumn} AS CHAR)) = ''");
+    }
+
     /**
      * @param Builder $query
      * @param Request $request

+ 19 - 4
app/Http/Controllers/FilterController.php

@@ -15,7 +15,7 @@ class FilterController extends Controller
         'reclamations'  => 'reclamations_view',
         'maf_order'     => 'maf_orders_view',
         'import'        => 'imports',
-        'responsibles'  => 'responsibles',
+        'responsibles'  => 'responsibles_view',
         'users'         => 'users',
         'contracts'     => 'contracts',
         'spare_parts'   => 'spare_parts_view',
@@ -49,6 +49,9 @@ class FilterController extends Controller
             'installation_price_txt' => 'installation_price',
             'total_price_txt' => 'total_price',
         ],
+        'responsibles' => [
+            'area-name' => 'area_name',
+        ],
     ];
 
     /**
@@ -110,7 +113,8 @@ class FilterController extends Controller
         $dbColumn = self::resolveDbColumn($table, $dbTable, $column);
 
         if ($dbColumn && Schema::hasColumn($dbTable, $dbColumn)) {
-            $q = DB::table($dbTable)->select($dbColumn)->distinct();
+            $normalizedColumn = self::normalizedSelectExpression($dbColumn);
+            $q = DB::table($dbTable)->selectRaw($normalizedColumn . ' as filter_value')->distinct();
             if (!in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
                 $q->where('year' , year());
             }
@@ -126,7 +130,7 @@ class FilterController extends Controller
                     $q->where(function ($query) use ($filterDbColumn, $vals) {
                         foreach (explode('||', $vals) as $val) {
                             if($val == '-пусто-') {
-                                $query->orWhereNull($filterDbColumn);
+                                self::applyEmptyFilterConditionForFilterQuery($query, $filterDbColumn);
                             } else {
                                 $query->orWhere($filterDbColumn, '=', $val);
                             }
@@ -135,7 +139,7 @@ class FilterController extends Controller
                 }
             }
 
-            $result = $q->orderBy($dbColumn)->get()->pluck($dbColumn)->toArray();
+            $result = $q->orderBy('filter_value')->get()->pluck('filter_value')->toArray();
 
             // Конвертация цен из копеек в рубли для отображения
             if (str_ends_with($dbColumn, '_price')) {
@@ -159,6 +163,17 @@ class FilterController extends Controller
         return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
     }
 
+    private static function normalizedSelectExpression(string $column): string
+    {
+        return "CASE WHEN {$column} IS NULL OR TRIM(CAST({$column} AS CHAR)) = '' THEN '-пусто-' ELSE CAST({$column} AS CHAR) END";
+    }
+
+    private static function applyEmptyFilterConditionForFilterQuery($query, string $column): void
+    {
+        $query->orWhereNull($column)
+            ->orWhereRaw("TRIM(CAST({$column} AS CHAR)) = ''");
+    }
+
     /**
      * Определяет реальный столбец БД по имени столбца из заголовка.
      * Приоритет: прямое совпадение в БД → COLUMN_MAP для конкретной таблицы.

+ 4 - 2
app/Http/Controllers/ResponsibleController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Http\Requests\StoreResponsibleRequest;
 use App\Models\Dictionary\Area;
 use App\Models\Responsible;
+use App\Models\ResponsibleView;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 
@@ -17,7 +18,7 @@ class ResponsibleController extends Controller
         'id'        => 'responsibles',
         'header'    => [
             'id'            => 'ID',
-            'area-name'     => 'Район',
+            'area_name'     => 'Район',
             'name'          => 'ФИО',
             'phone'         => 'Телефон',
             'post'          => 'Должность',
@@ -51,7 +52,8 @@ class ResponsibleController extends Controller
     {
         session(['gp_responsibles' => $request->query()]);
 
-        $model = new Responsible;
+        $model = new ResponsibleView;
+        $this->createFilters($model, 'area_name');
         $this->createDateFilters($model, 'created_at');
 
         $q = $model::query();

+ 26 - 0
app/Models/ResponsibleView.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ResponsibleView extends Model
+{
+    use SoftDeletes;
+
+    public const DEFAULT_SORT_BY = 'updated_at';
+
+    protected $table = 'responsibles_view';
+
+    protected $fillable = [
+        'name',
+        'phone',
+        'post',
+        'area_id',
+        'area_name',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+    ];
+}

+ 27 - 0
database/migrations/2026_04_11_130000_create_responsibles_view.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS responsibles_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW responsibles_view AS
+            SELECT
+                r.*,
+                a.id AS area_id,
+                a.name AS area_name
+            FROM responsibles r
+                LEFT JOIN areas a ON a.responsible_id = r.id AND a.deleted_at IS NULL
+            SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS responsibles_view');
+    }
+};

+ 78 - 0
database/migrations/2026_04_11_131000_normalize_empty_fields_in_mafs_view.php

@@ -0,0 +1,78 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS mafs_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW mafs_view AS
+            SELECT
+                ps.id,
+                ps.year,
+                ps.product_id,
+                ps.order_id,
+                ps.maf_order_id,
+                ps.status,
+                NULLIF(TRIM(ps.rfid), '') AS rfid,
+                NULLIF(TRIM(ps.factory_number), '') AS factory_number,
+                ps.manufacture_date,
+                NULLIF(TRIM(ps.statement_number), '') AS statement_number,
+                ps.statement_date,
+                NULLIF(TRIM(ps.upd_number), '') AS upd_number,
+                ps.comment,
+                ps.passport_id,
+                ps.created_at,
+                ps.updated_at,
+                ps.deleted_at,
+                ov.district_name,
+                ov.area_name,
+                ov.user_name,
+                ov.object_address,
+                mo.order_number,
+                p.article,
+                p.nomenclature_number,
+                p.name_tz,
+                p.type_tz,
+                p.`type`,
+                p.manufacturer_name,
+                f.original_name AS passport_name
+            FROM products_sku ps
+                LEFT JOIN orders_view ov ON ov.id = ps.order_id
+                LEFT JOIN maf_orders mo ON ps.maf_order_id = mo.id
+                LEFT JOIN products p ON p.id = ps.product_id
+                LEFT JOIN files f ON f.id = ps.passport_id
+            SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS mafs_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW mafs_view AS
+            SELECT ps.*,
+                ov.district_name,
+                ov.area_name,
+                ov.user_name,
+                ov.object_address,
+                mo.order_number,
+                p.article,
+                p.nomenclature_number,
+                p.name_tz,
+                p.type_tz,
+                p.`type`,
+                p.manufacturer_name,
+                f.original_name as passport_name
+            FROM products_sku ps
+                LEFT JOIN orders_view ov ON ov.id = ps.order_id
+                LEFT JOIN maf_orders mo ON ps.maf_order_id = mo.id
+                LEFT JOIN products p ON p.id = ps.product_id
+                LEFT JOIN files f ON f.id = ps.passport_id
+            SQL);
+    }
+};

+ 76 - 0
tests/Feature/ChatMessageControllerTest.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\ChatMessage;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ChatMessageControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_brigadier_can_send_order_chat_message_without_manual_recipient_selection(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $order = Order::factory()->create([
+            'user_id' => $manager->id,
+            'brigadier_id' => $brigadier->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+        ]);
+
+        $response = $this->actingAs($brigadier)
+            ->post(route('order.chat-messages.store', $order), [
+                'message' => 'Сообщение бригадира по площадке',
+                'notification_type' => ChatMessage::NOTIFICATION_RESPONSIBLES,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $message = ChatMessage::query()->where('order_id', $order->id)->first();
+        $this->assertNotNull($message);
+        $this->assertSame(ChatMessage::NOTIFICATION_RESPONSIBLES, $message->notification_type);
+        $recipientIds = $message->notifiedUsers->pluck('id')->all();
+        $this->assertContains($admin->id, $recipientIds);
+        $this->assertContains($manager->id, $recipientIds);
+        $this->assertNotContains($brigadier->id, $recipientIds);
+    }
+
+    public function test_brigadier_can_send_reclamation_chat_message_without_manual_recipient_selection(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $reclamation = Reclamation::factory()->create([
+            'user_id' => $manager->id,
+            'brigadier_id' => $brigadier->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+        ]);
+
+        $response = $this->actingAs($brigadier)
+            ->post(route('reclamations.chat-messages.store', $reclamation), [
+                'message' => 'Сообщение бригадира по рекламации',
+                'notification_type' => ChatMessage::NOTIFICATION_RESPONSIBLES,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $message = ChatMessage::query()->where('reclamation_id', $reclamation->id)->first();
+        $this->assertNotNull($message);
+        $this->assertSame(ChatMessage::NOTIFICATION_RESPONSIBLES, $message->notification_type);
+        $recipientIds = $message->notifiedUsers->pluck('id')->all();
+        $this->assertContains($admin->id, $recipientIds);
+        $this->assertContains($manager->id, $recipientIds);
+        $this->assertNotContains($brigadier->id, $recipientIds);
+    }
+}

+ 91 - 0
tests/Feature/ProductSKUControllerTest.php

@@ -96,6 +96,97 @@ class ProductSKUControllerTest extends TestCase
         $response->assertViewIs('products_sku.index');
     }
 
+    public function test_maf_filter_options_include_empty_marker_for_factory_number(): void
+    {
+        ProductSKU::factory()->create(['factory_number' => null]);
+        ProductSKU::factory()->create(['factory_number' => '   ']);
+        ProductSKU::factory()->create(['factory_number' => 'FN-123456']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('getFilters', [
+                'table' => 'product_sku',
+                'column' => 'factory_number',
+            ]));
+
+        $response->assertOk();
+        $response->assertJsonFragment(['-пусто-']);
+        $response->assertJsonFragment(['FN-123456']);
+    }
+
+    public function test_maf_empty_filter_matches_null_blank_and_null_date_values(): void
+    {
+        $orderWithNullFactory = Order::factory()->create(['object_address' => 'ул. Пустая фабрика']);
+        $orderWithBlankFactory = Order::factory()->create(['object_address' => 'ул. Пробелы фабрика']);
+        $orderWithNullDate = Order::factory()->create(['object_address' => 'ул. Пустая дата']);
+        $orderWithBlankStatement = Order::factory()->create(['object_address' => 'ул. Пустая ведомость']);
+        $orderWithFilledValue = Order::factory()->create(['object_address' => 'ул. Заполненная']);
+        $product = Product::factory()->create();
+
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithNullFactory->id,
+            'product_id' => $product->id,
+            'factory_number' => null,
+            'manufacture_date' => '2026-04-01',
+            'statement_number' => 'STAT-1',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithBlankFactory->id,
+            'product_id' => $product->id,
+            'factory_number' => '   ',
+            'manufacture_date' => '2026-04-02',
+            'statement_number' => 'STAT-2',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithNullDate->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-000001',
+            'manufacture_date' => null,
+            'statement_number' => 'STAT-3',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithBlankStatement->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-000002',
+            'manufacture_date' => '2026-04-03',
+            'statement_number' => '   ',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithFilledValue->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-999999',
+            'manufacture_date' => '2026-04-04',
+            'statement_number' => 'STAT-9',
+        ]);
+
+        $factoryResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['factory_number' => '-пусто-'],
+            ]));
+        $factoryResponse->assertOk();
+        $factoryAddresses = $factoryResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая фабрика', $factoryAddresses);
+        $this->assertContains('ул. Пробелы фабрика', $factoryAddresses);
+        $this->assertNotContains('ул. Заполненная', $factoryAddresses);
+
+        $dateResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['manufacture_date' => '-пусто-'],
+            ]));
+        $dateResponse->assertOk();
+        $dateAddresses = $dateResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая дата', $dateAddresses);
+        $this->assertNotContains('ул. Заполненная', $dateAddresses);
+
+        $statementResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['statement_number' => '-пусто-'],
+            ]));
+        $statementResponse->assertOk();
+        $statementAddresses = $statementResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая ведомость', $statementAddresses);
+        $this->assertNotContains('ул. Заполненная', $statementAddresses);
+    }
+
     // ==================== Show ====================
 
     public function test_admin_can_view_product_sku(): void

+ 86 - 0
tests/Feature/ResponsibleControllerTest.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Responsible;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ResponsibleControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+    }
+
+    public function test_responsibles_index_filters_by_area_name(): void
+    {
+        $district = District::factory()->create();
+        $matchingResponsible = Responsible::query()->create([
+            'name' => 'Иван Иванов',
+            'phone' => '+79990000001',
+            'post' => 'Куратор',
+        ]);
+        $otherResponsible = Responsible::query()->create([
+            'name' => 'Петр Петров',
+            'phone' => '+79990000002',
+            'post' => 'Куратор',
+        ]);
+
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Тверской',
+            'responsible_id' => $matchingResponsible->id,
+        ]);
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Арбат',
+            'responsible_id' => $otherResponsible->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('responsible.index', [
+                'filters' => ['area_name' => 'Тверской'],
+            ]));
+
+        $response->assertOk();
+        $response->assertSee('Иван Иванов');
+        $response->assertDontSee('Петр Петров');
+    }
+
+    public function test_responsible_area_filter_values_are_loaded_from_view(): void
+    {
+        $district = District::factory()->create();
+        $responsible = Responsible::query()->create([
+            'name' => 'Иван Иванов',
+            'phone' => '+79990000001',
+            'post' => 'Куратор',
+        ]);
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Тверской',
+            'responsible_id' => $responsible->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('getFilters', [
+                'table' => 'responsibles',
+                'column' => 'area_name',
+            ]));
+
+        $response->assertOk();
+        $response->assertJsonFragment(['Тверской']);
+    }
+}