1
0

4 Коммитууд a176b15829 ... c0da737d0d

Эзэн SHA1 Мессеж Огноо
  Alexander Musikhin c0da737d0d fix: fcm fo apple, add fcm logging 1 долоо хоног өмнө
  Alexander Musikhin 8a6bceb98d fix: notifications 1 долоо хоног өмнө
  Alexander Musikhin bcd0dd8607 fix: warehouse_head role can only view orders with brigadier and installation dat set 1 долоо хоног өмнө
  Alexander Musikhin 4a539e5f47 fix: manager can delete documents 1 долоо хоног өмнө

+ 6 - 0
app/Http/Controllers/OrderController.php

@@ -106,6 +106,11 @@ class OrderController extends Controller
             $q->where('brigadier_id', auth()->id());
         }
 
+        if(hasRole(Role::WAREHOUSE_HEAD)) {
+            $q->whereNotNull('brigadier_id');
+            $q->whereNotNull('installation_date');
+        }
+
         $this->applyStableSorting($q);
         $this->data['orders'] = $q->paginate($this->data['per_page'])->withQueryString();
 
@@ -292,6 +297,7 @@ class OrderController extends Controller
      * Привязка товаров к заказу
      * @param Order $order
      * @return RedirectResponse
+     * @throws Throwable
      */
     public function getMafToOrder(Order $order)
     {

+ 23 - 0
app/Providers/FcmServiceProvider.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Providers;
+
+use App\Services\Fcm\FcmChannel;
+use App\Services\Fcm\FcmService;
+use Illuminate\Notifications\ChannelManager;
+use Illuminate\Support\ServiceProvider;
+
+class FcmServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->app->singleton(FcmService::class);
+    }
+
+    public function boot(): void
+    {
+        $this->app->make(ChannelManager::class)->extend('fcm', function ($app) {
+            return $app->make(FcmChannel::class);
+        });
+    }
+}

+ 28 - 0
app/Services/Fcm/FcmChannel.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Services\Fcm;
+
+use Illuminate\Notifications\Notification;
+
+class FcmChannel
+{
+    public function __construct(
+        protected FcmService $fcm,
+    ) {}
+
+    public function send(object $notifiable, Notification $notification): void
+    {
+        if (!method_exists($notification, 'toFcm')) {
+            return;
+        }
+
+        $data = $notification->toFcm($notifiable);
+
+        $this->fcm->send(
+            token: $data['to'],
+            title: $data['notification']['title'] ?? '',
+            body: $data['notification']['body'] ?? '',
+            image: $data['notification']['image'] ?? null,
+        );
+    }
+}

+ 132 - 0
app/Services/Fcm/FcmService.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace App\Services\Fcm;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class FcmService
+{
+    protected string $projectId;
+    protected string $accessToken;
+
+    public function __construct()
+    {
+        $this->projectId = config('fcm.project_id');
+        $this->accessToken = $this->getAccessToken();
+    }
+
+    public function send(string $token, string $title, string $body, ?string $image = null): ?array
+    {
+        $url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
+
+        $message = [
+            'message' => [
+                'token' => $token,
+                'notification' => [
+                    'title' => $title,
+                    'body' => $body,
+                    'image' => $image,
+                ],
+                'android' => [
+                    'priority' => 'high',
+                ],
+                'apns' => [
+                    'headers' => [
+                        'apns-priority' => '10',
+                    ],
+                    'payload' => [
+                        'aps' => [
+                            'sound' => 'default',
+                            'badge' => 1,
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        try {
+            $response = Http::withToken($this->accessToken)->post($url, $message);
+            $result = $response->json();
+
+            Log::channel('fcm')->info('FCM send', [
+                'token' => substr($token, 0, 20) . '...',
+                'title' => $title,
+                'status' => $response->status(),
+                'response' => $result,
+            ]);
+
+            if ($response->failed()) {
+                Log::channel('fcm')->error('FCM send failed', [
+                    'token' => substr($token, 0, 20) . '...',
+                    'status' => $response->status(),
+                    'error' => $result,
+                ]);
+            }
+
+            return $result;
+        } catch (Throwable $e) {
+            Log::channel('fcm')->error('FCM exception', [
+                'token' => substr($token, 0, 20) . '...',
+                'message' => $e->getMessage(),
+            ]);
+
+            return null;
+        }
+    }
+
+    protected function getAccessToken(): string
+    {
+        $clientEmail = config('fcm.client_email');
+        $privateKey = config('fcm.private_key');
+
+        $jwt = $this->createJwt($clientEmail, $privateKey);
+
+        return $this->exchangeJwtForAccessToken($jwt);
+    }
+
+    protected function createJwt(string $clientEmail, string $privateKey): string
+    {
+        $header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
+        $now = time();
+
+        $payload = json_encode([
+            'iss' => $clientEmail,
+            'sub' => $clientEmail,
+            'aud' => 'https://oauth2.googleapis.com/token',
+            'iat' => $now,
+            'exp' => $now + 3600,
+            'scope' => 'https://www.googleapis.com/auth/cloud-platform',
+        ]);
+
+        $base64Header = $this->base64UrlEncode($header);
+        $base64Payload = $this->base64UrlEncode($payload);
+
+        $signature = '';
+        openssl_sign("{$base64Header}.{$base64Payload}", $signature, $privateKey, 'sha256');
+
+        return "{$base64Header}.{$base64Payload}." . $this->base64UrlEncode($signature);
+    }
+
+    protected function exchangeJwtForAccessToken(string $jwt): string
+    {
+        $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
+            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+            'assertion' => $jwt,
+        ]);
+
+        $data = $response->json();
+
+        if (isset($data['access_token'])) {
+            return $data['access_token'];
+        }
+
+        throw new \RuntimeException('FCM: Failed to obtain access token: ' . $response->body());
+    }
+
+    protected function base64UrlEncode(string $data): string
+    {
+        return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
+    }
+}

+ 51 - 19
app/Services/NotificationService.php

@@ -133,24 +133,56 @@ class NotificationService
         $brigadierName = $schedule->brigadier?->name ?? '-';
         $date = DateHelper::getHumanDate((string)$schedule->installation_date, true);
 
-        $message = sprintf(
-            '%s добавлено в график монтажей на %s, Бригадир %s.',
-            $schedule->object_address,
-            $date,
-            $brigadierName,
-        );
+        if ((string)$schedule->source === 'Рекламации') {
+            $reclamationId = $this->extractReclamationId((string)$schedule->address_code);
 
-        $orderLink = $schedule->order_id
-            ? route('order.show', ['order' => $schedule->order_id])
-            : route('schedule.index');
+            $message = sprintf(
+                'Рекламация №%s по адресу %s добавлена в график на %s, Бригадир %s.',
+                $reclamationId ?? '—',
+                $schedule->object_address,
+                $date,
+                $brigadierName,
+            );
 
-        $messageHtml = sprintf(
-            '<a href="%s">%s</a> добавлено в график монтажей на %s, Бригадир %s.',
-            $orderLink,
-            e($schedule->object_address),
-            e($date),
-            e($brigadierName),
-        );
+            $reclamationLink = $reclamationId
+                ? route('reclamations.show', ['reclamation' => $reclamationId])
+                : route('schedule.index');
+            $orderLink = $schedule->order_id
+                ? route('order.show', ['order' => $schedule->order_id])
+                : null;
+
+            $addressHtml = $orderLink
+                ? sprintf('<a href="%s">%s</a>', $orderLink, e($schedule->object_address))
+                : e($schedule->object_address);
+
+            $messageHtml = sprintf(
+                '<a href="%s">Рекламация №%s</a> по адресу %s добавлена в график на %s, Бригадир %s.',
+                $reclamationLink,
+                $reclamationId ?? '—',
+                $addressHtml,
+                e($date),
+                e($brigadierName),
+            );
+        } else {
+            $message = sprintf(
+                '%s добавлено в график монтажей на %s, Бригадир %s.',
+                $schedule->object_address,
+                $date,
+                $brigadierName,
+            );
+
+            $orderLink = $schedule->order_id
+                ? route('order.show', ['order' => $schedule->order_id])
+                : route('schedule.index');
+
+            $messageHtml = sprintf(
+                '<a href="%s">%s</a> добавлено в график монтажей на %s, Бригадир %s.',
+                $orderLink,
+                e($schedule->object_address),
+                e($date),
+                e($brigadierName),
+            );
+        }
 
         $users = $this->scheduleRecipients($schedule);
         foreach ($users as $user) {
@@ -225,8 +257,8 @@ class NotificationService
                     return;
                 }
 
-                Mail::raw($notification->message, function ($mail) use ($email, $notification) {
-                    $mail->to($email)
+                Mail::html($notification->message_html, function ($message) use ($email, $notification) {
+                    $message->to($email)
                         ->subject($notification->title);
                 });
 
@@ -414,7 +446,7 @@ class NotificationService
         $query = User::query()
             ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
 
-        $managerId = $reclamation->order?->user_id ?: $reclamation->user_id;
+        $managerId = $reclamation->user_id;
         if ($managerId) {
             $query->orWhere('id', $managerId);
         }

+ 1 - 0
bootstrap/providers.php

@@ -6,4 +6,5 @@ return [
     App\Providers\AppServiceProvider::class,
     BroadcastServiceProvider::class,
     App\Providers\HelperServiceProvider::class,
+    App\Providers\FcmServiceProvider::class,
 ];

+ 3 - 1
composer.json

@@ -60,7 +60,9 @@
     },
     "extra": {
         "laravel": {
-            "dont-discover": []
+            "dont-discover": [
+                "syntech/syntechfcm"
+            ]
         }
     },
     "config": {

+ 7 - 0
config/fcm.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+    'project_id' => env('FCM_PROJECT_ID'),
+    'client_email' => env('FCM_CLIENT_EMAIL'),
+    'private_key' => env('FCM_PRIVATE_KEY'),
+];

+ 8 - 0
config/logging.php

@@ -127,6 +127,14 @@ return [
             'path' => storage_path('logs/laravel.log'),
         ],
 
+        'fcm' => [
+            'driver' => 'daily',
+            'path' => storage_path('logs/fcm.log'),
+            'level' => 'debug',
+            'days' => 14,
+            'replace_placeholders' => true,
+        ],
+
     ],
 
 ];

+ 3 - 2
routes/web.php

@@ -169,6 +169,8 @@ Route::middleware('auth:web')->group(function () {
         Route::post('order/{order}/upload-document', [OrderController::class, 'uploadDocument'])->name('order.upload-document');
         Route::post('order/{order}/upload-statement', [OrderController::class, 'uploadStatement'])->name('order.upload-statement');
         Route::delete('order/delete-photo/{order}/{file}', [OrderController::class, 'deletePhoto'])->name('order.delete-photo');
+        Route::delete('order/delete-document/{order}/{file}', [OrderController::class, 'deleteDocument'])->name('order.delete-document');
+        Route::delete('order/delete-statement/{order}/{file}', [OrderController::class, 'deleteStatement'])->name('order.delete-statement');
 
         Route::post('catalog/{product}/upload-certificate', [ProductController::class, 'uploadCertificate'])->name('catalog.upload-certificate');
         Route::post('catalog/{product}/upload-thumbnail', [ProductController::class, 'uploadThumbnail'])->name('catalog.upload-thumbnail');
@@ -248,8 +250,7 @@ Route::middleware('auth:web')->group(function () {
         Route::post('order/move-maf', [OrderController::class, 'moveMaf'])->name('order.move-maf');
         Route::post('order/create-ttn', [OrderController::class, 'createTtn'])->name('order.create-ttn');
 
-        Route::delete('order/delete-document/{order}/{file}', [OrderController::class, 'deleteDocument'])->name('order.delete-document');
-        Route::delete('order/delete-statement/{order}/{file}', [OrderController::class, 'deleteStatement'])->name('order.delete-statement');
+
         Route::delete('order/delete-all-photos/{order}', [OrderController::class, 'deleteAllPhotos'])->name('order.delete-all-photos');
         Route::delete('order/delete-all-documents/{order}', [OrderController::class, 'deleteAllDocuments'])->name('order.delete-all-documents');
         Route::delete('order/delete-all-statements/{order}', [OrderController::class, 'deleteAllStatements'])->name('order.delete-all-statements');

+ 63 - 0
tests/Feature/WarehouseHeadOrderVisibilityTest.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Order;
+use App\Models\OrderView;
+use App\Models\Role;
+use App\Models\User;
+use Database\Seeders\OrderStatusSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class WarehouseHeadOrderVisibilityTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->seed(OrderStatusSeeder::class);
+    }
+
+    public function test_warehouse_head_sees_only_orders_with_brigadier_and_installation_date()
+    {
+        // 1. Create a user with the "warehouse_head" role.
+        $warehouseHead = User::factory()->create(['role' => Role::WAREHOUSE_HEAD]);
+
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        // 2. Create orders
+        $orderVisible = Order::factory()->create([
+            'brigadier_id' => $brigadier->id,
+            'installation_date' => now(),
+        ]);
+
+        $orderNotVisible1 = Order::factory()->create([
+            'brigadier_id' => $brigadier->id,
+            'installation_date' => null,
+        ]);
+
+        $orderNotVisible2 = Order::factory()->create([
+            'brigadier_id' => null,
+            'installation_date' => now(),
+        ]);
+
+        $orderNotVisible3 = Order::factory()->create([
+            'brigadier_id' => null,
+            'installation_date' => null,
+        ]);
+
+        // 3. Authenticate as the "warehouse_head" user.
+        $this->actingAs($warehouseHead);
+
+        // 4. Make a request to the `order.index` route.
+        $response = $this->get(route('order.index'));
+
+        // 5. Assert that the response only contains the order that has both a brigadier and an installation date.
+        $response->assertStatus(200);
+        $orders = $response->viewData('orders');
+        $this->assertCount(1, $orders);
+        $this->assertEquals($orderVisible->id, $orders->first()->id);
+    }
+}