Explorar el Código

add production order files and chat

Alexander Musikhin hace 2 semanas
padre
commit
219c5de95f

+ 78 - 0
app/Http/Controllers/ProductionOrderChatMessageController.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Models\ChatMessage;
+use App\Models\ProductionOrder;
+use App\Services\FileService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+class ProductionOrderChatMessageController extends Controller
+{
+    public function store(
+        Request $request,
+        ProductionOrder $productionOrder,
+        FileService $fileService,
+    ): RedirectResponse {
+        $validated = $request->validate([
+            'message' => ['nullable', 'string'],
+            'attachments' => ['nullable', 'array', 'max:5'],
+            'attachments.*' => ['file', 'max:10240'],
+        ]);
+        $message = trim((string) ($validated['message'] ?? ''));
+        $attachments = $request->file('attachments', []);
+
+        if ($message === '' && $attachments === []) {
+            return back()->with('danger', 'Нужно указать сообщение или добавить файл.');
+        }
+
+        try {
+            $chatMessage = ChatMessage::query()->create([
+                'production_order_id' => $productionOrder->id,
+                'user_id' => (int) $request->user()->id,
+                'notification_type' => ChatMessage::NOTIFICATION_NONE,
+                'message' => $message !== '' ? $message : null,
+            ]);
+
+            $files = collect($attachments)
+                ->map(fn ($attachment) => $fileService->saveUploadedFile(
+                    "chat/production-orders/{$productionOrder->id}",
+                    $attachment,
+                ));
+            $chatMessage->files()->syncWithoutDetaching($files->pluck('id'));
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return back()->with('error', 'Не удалось отправить сообщение в чат.');
+        }
+
+        return back()->with('success', 'Сообщение отправлено.');
+    }
+
+    public function destroy(
+        ProductionOrder $productionOrder,
+        ChatMessage $chatMessage,
+        FileService $fileService,
+    ): RedirectResponse {
+        abort_unless((int) $chatMessage->production_order_id === (int) $productionOrder->id, 404);
+        $files = $chatMessage->files()->get();
+
+        DB::transaction(function () use ($chatMessage): void {
+            $chatMessage->notifiedUsers()->detach();
+            $chatMessage->files()->detach();
+            $chatMessage->delete();
+        });
+
+        foreach ($files as $file) {
+            $fileService->deleteFileWithThumbnail($file);
+            $file->delete();
+        }
+
+        return back()->with('success', 'Сообщение удалено.');
+    }
+}

+ 30 - 0
app/Http/Controllers/ProductionOrderController.php

@@ -139,9 +139,16 @@ class ProductionOrderController extends Controller
             $productionOrder->load([
                 'manager',
                 'items.catalogItem.imageFile',
+                'items.passportFile',
                 'deliveries.driver',
                 'installations.brigadier',
                 'activities.user',
+                'documents',
+                'photos',
+                'chatMessages.user',
+                'chatMessages.targetUser',
+                'chatMessages.notifiedUsers',
+                'chatMessages.files',
             ]),
         );
     }
@@ -257,6 +264,18 @@ class ProductionOrderController extends Controller
             ->filter()
             ->unique()
             ->values();
+        $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
+        $responsibleUserIds = User::query()
+            ->withPermission('schedule-orders.create')
+            ->pluck('id')
+            ->map(static fn ($id): int => (int) $id)
+            ->merge([$order?->manager_id])
+            ->merge($order?->deliveries->pluck('driver_id') ?? [])
+            ->merge($order?->installations->pluck('brigadier_id') ?? [])
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
 
         return view('production_orders.edit', [
             'active' => 'schedule_orders',
@@ -278,6 +297,11 @@ class ProductionOrderController extends Controller
             'canManageItems' => $request->user()->hasPermission('schedule-orders.items.manage'),
             'canManageDeliveries' => $request->user()->hasPermission('schedule-orders.deliveries.manage'),
             'canManageInstallations' => $request->user()->hasPermission('schedule-orders.installations.manage'),
+            'canManageDocuments' => $request->user()->hasPermission('schedule-orders.documents.manage'),
+            'canManagePhotos' => $request->user()->hasPermission('schedule-orders.photos.manage'),
+            'canManagePassports' => $request->user()->hasPermission('schedule-orders.passports.manage'),
+            'canCreateChatMessages' => $request->user()->hasPermission('schedule-orders.chat.create'),
+            'canDeleteChatMessages' => $request->user()->hasPermission('schedule-orders.chat.delete'),
             'canUpdateFactoryNumber' => $request->user()->canUpdateField('schedule-orders', 'factory_number'),
             'canUpdateManufactureDate' => $request->user()->canUpdateField('schedule-orders', 'manufacture_date'),
             'managers' => User::query()
@@ -298,6 +322,12 @@ class ProductionOrderController extends Controller
                 ->keyBy('id'),
             'statuses' => ProductionOrderStatus::options(),
             'executionTypes' => ProductionOrderExecutionType::options(),
+            'chatUsers' => $chatUsers->map(static fn (User $user): array => [
+                'id' => $user->id,
+                'name' => $user->name,
+                'role' => $user->role,
+            ])->keyBy('id'),
+            'chatResponsibleUserIds' => $responsibleUserIds,
         ]);
     }
 }

+ 149 - 0
app/Http/Controllers/ProductionOrderFileController.php

@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Models\File;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Services\FileService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Throwable;
+
+class ProductionOrderFileController extends Controller
+{
+    public function storeDocuments(
+        Request $request,
+        ProductionOrder $productionOrder,
+        FileService $fileService,
+    ): RedirectResponse {
+        $validated = $request->validate([
+            'documents' => ['required', 'array', 'max:5'],
+            'documents.*' => ['required', 'file', 'max:10240'],
+        ]);
+
+        try {
+            $files = collect($validated['documents'])
+                ->map(fn ($document): File => $fileService->saveUploadedFile(
+                    "production-orders/{$productionOrder->id}/documents",
+                    $document,
+                ));
+            $productionOrder->documents()->syncWithoutDetaching($files->pluck('id'));
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return back()->with('error', 'Не удалось загрузить документы.');
+        }
+
+        return back()->with('success', 'Документы загружены.');
+    }
+
+    public function destroyDocument(
+        ProductionOrder $productionOrder,
+        File $file,
+        FileService $fileService,
+    ): RedirectResponse {
+        abort_unless($productionOrder->documents()->whereKey($file->id)->exists(), 404);
+
+        $productionOrder->documents()->detach($file->id);
+        $fileService->deleteFileWithThumbnail($file);
+        $file->delete();
+
+        return back()->with('success', 'Документ удалён.');
+    }
+
+    public function storePhotos(
+        Request $request,
+        ProductionOrder $productionOrder,
+        FileService $fileService,
+    ): RedirectResponse {
+        $validated = $request->validate([
+            'photos' => ['required', 'array', 'max:10'],
+            'photos.*' => ['required', 'file', 'mimes:jpeg,jpg,png,webp', 'max:10240'],
+        ]);
+
+        try {
+            $files = collect($validated['photos'])
+                ->map(fn ($photo): File => $fileService->saveUploadedFile(
+                    "production-orders/{$productionOrder->id}/photos",
+                    $photo,
+                ));
+            $productionOrder->photos()->syncWithoutDetaching($files->pluck('id'));
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return back()->with('error', 'Не удалось загрузить фотографии.');
+        }
+
+        return back()->with('success', 'Фотографии загружены.');
+    }
+
+    public function destroyPhoto(
+        ProductionOrder $productionOrder,
+        File $file,
+        FileService $fileService,
+    ): RedirectResponse {
+        abort_unless($productionOrder->photos()->whereKey($file->id)->exists(), 404);
+
+        $productionOrder->photos()->detach($file->id);
+        $fileService->deleteFileWithThumbnail($file);
+        $file->delete();
+
+        return back()->with('success', 'Фотография удалена.');
+    }
+
+    public function storePassport(
+        Request $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderItem $item,
+        FileService $fileService,
+    ): RedirectResponse {
+        $this->ensureItemBelongsToOrder($productionOrder, $item);
+        $validated = $request->validate([
+            'passport' => ['required', 'file', 'max:10240'],
+        ]);
+        $previousPassport = $item->passportFile;
+
+        try {
+            $file = $fileService->saveUploadedFile(
+                "production-orders/{$productionOrder->id}/items/{$item->id}/passport",
+                $validated['passport'],
+            );
+            $item->update(['passport_file_id' => $file->id]);
+
+            if ($previousPassport) {
+                $fileService->deleteFileWithThumbnail($previousPassport);
+                $previousPassport->delete();
+            }
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return back()->with('error', 'Не удалось загрузить паспорт.');
+        }
+
+        return back()->with('success', 'Паспорт загружен.');
+    }
+
+    public function destroyPassport(
+        ProductionOrder $productionOrder,
+        ProductionOrderItem $item,
+        FileService $fileService,
+    ): RedirectResponse {
+        $this->ensureItemBelongsToOrder($productionOrder, $item);
+        $file = $item->passportFile;
+        abort_unless($file, 404);
+
+        $item->update(['passport_file_id' => null]);
+        $fileService->deleteFileWithThumbnail($file);
+        $file->delete();
+
+        return back()->with('success', 'Паспорт удалён.');
+    }
+
+    private function ensureItemBelongsToOrder(ProductionOrder $productionOrder, ProductionOrderItem $item): void
+    {
+        abort_unless((int) $item->production_order_id === (int) $productionOrder->id, 404);
+    }
+}

+ 6 - 0
app/Models/ChatMessage.php

@@ -26,6 +26,7 @@ class ChatMessage extends Model
     protected $fillable = [
         'order_id',
         'reclamation_id',
+        'production_order_id',
         'user_id',
         'target_user_id',
         'notification_type',
@@ -42,6 +43,11 @@ class ChatMessage extends Model
         return $this->belongsTo(Reclamation::class);
     }
 
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
     public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);

+ 23 - 0
app/Models/File.php

@@ -74,4 +74,27 @@ class File extends Model
         );
     }
 
+    public function isImage(): bool
+    {
+        return Str::startsWith(Str::lower((string) $this->mime_type), 'image/');
+    }
+
+    public function iconClass(): string
+    {
+        $mimeType = Str::lower((string) $this->mime_type);
+
+        return match (true) {
+            Str::startsWith($mimeType, 'image/') => 'bi bi-file-earmark-image',
+            $mimeType === 'application/pdf' => 'bi bi-file-earmark-pdf',
+            $mimeType === 'application/msword', Str::contains($mimeType, 'wordprocessingml') => 'bi bi-file-earmark-word',
+            $mimeType === 'application/vnd.ms-excel', Str::contains($mimeType, 'spreadsheetml') => 'bi bi-file-earmark-excel',
+            $mimeType === 'application/vnd.ms-powerpoint', Str::contains($mimeType, 'presentationml') => 'bi bi-file-earmark-ppt',
+            Str::startsWith($mimeType, 'audio/') => 'bi bi-file-earmark-music',
+            Str::startsWith($mimeType, 'video/') => 'bi bi-file-earmark-play',
+            Str::startsWith($mimeType, 'text/'), Str::contains($mimeType, 'json'), Str::contains($mimeType, 'xml') => 'bi bi-file-earmark-code',
+            Str::contains($mimeType, 'zip'), Str::contains($mimeType, 'rar'), Str::contains($mimeType, '7z'), Str::contains($mimeType, 'gzip') => 'bi bi-file-earmark-zip',
+            default => 'bi bi-file-earmark',
+        };
+    }
+
 }

+ 16 - 0
app/Models/ProductionOrder.php

@@ -11,6 +11,7 @@ use Database\Factories\ProductionOrderFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
@@ -102,6 +103,21 @@ class ProductionOrder extends Model
         return $this->hasMany(ProductionOrderActivity::class)->latest('created_at')->latest('id');
     }
 
+    public function documents(): BelongsToMany
+    {
+        return $this->belongsToMany(File::class, 'production_order_document')->withTimestamps();
+    }
+
+    public function photos(): BelongsToMany
+    {
+        return $this->belongsToMany(File::class, 'production_order_photo')->withTimestamps();
+    }
+
+    public function chatMessages(): HasMany
+    {
+        return $this->hasMany(ChatMessage::class)->oldest('created_at')->oldest('id');
+    }
+
     public function statusLabel(): string
     {
         return $this->status->label();

+ 1 - 0
config/access.php

@@ -81,6 +81,7 @@ return [
             'photos.manage' => 'Управление фотографиями',
             'passports.manage' => 'Управление паспортами МАФ',
             'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
             'export' => 'Экспорт',
             'import' => 'Импорт',
             'documents.generate' => 'Генерация документов',

+ 8 - 0
config/access_routes.php

@@ -106,6 +106,14 @@ return [
             'installations.store' => 'schedule-orders.installations.manage',
             'installations.update' => 'schedule-orders.installations.manage',
             'installations.destroy' => 'schedule-orders.installations.manage',
+            'documents.store' => 'schedule-orders.documents.manage',
+            'documents.destroy' => 'schedule-orders.documents.manage',
+            'photos.store' => 'schedule-orders.photos.manage',
+            'photos.destroy' => 'schedule-orders.photos.manage',
+            'items.passport.store' => 'schedule-orders.passports.manage',
+            'items.passport.destroy' => 'schedule-orders.passports.manage',
+            'chat-messages.store' => 'schedule-orders.chat.create',
+            'chat-messages.destroy' => 'schedule-orders.chat.delete',
         ],
         'contract.' => [
             'index' => 'contracts.view',

+ 50 - 0
database/migrations/2026_09_03_000004_add_files_and_chat_to_production_orders.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('production_order_document', function (Blueprint $table): void {
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->foreignId('file_id')->constrained('files')->cascadeOnDelete();
+            $table->timestamps();
+
+            $table->primary(['production_order_id', 'file_id']);
+        });
+
+        Schema::create('production_order_photo', function (Blueprint $table): void {
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->foreignId('file_id')->constrained('files')->cascadeOnDelete();
+            $table->timestamps();
+
+            $table->primary(['production_order_id', 'file_id']);
+        });
+
+        Schema::table('chat_messages', function (Blueprint $table): void {
+            $table->foreignId('production_order_id')
+                ->nullable()
+                ->after('reclamation_id')
+                ->constrained('production_orders')
+                ->cascadeOnDelete();
+            $table->index(['production_order_id', 'created_at']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('chat_messages', function (Blueprint $table): void {
+            $table->dropForeign(['production_order_id']);
+            $table->dropIndex(['production_order_id', 'created_at']);
+            $table->dropColumn('production_order_id');
+        });
+
+        Schema::dropIfExists('production_order_photo');
+        Schema::dropIfExists('production_order_document');
+    }
+};

+ 5 - 0
database/seeders/RbacSeeder.php

@@ -133,6 +133,7 @@ class RbacSeeder extends Seeder
             'catalog.search',
             'common-catalog.view',
             'schedule-orders.view',
+            'schedule-orders.chat.create',
             'documents.view',
             'stock.view',
             'stock.reserve',
@@ -228,6 +229,10 @@ class RbacSeeder extends Seeder
                 'schedule-orders.update',
                 'schedule-orders.deliveries.manage',
                 'schedule-orders.installations.manage',
+                'schedule-orders.documents.manage',
+                'schedule-orders.photos.manage',
+                'schedule-orders.passports.manage',
+                'schedule-orders.chat.create',
             ],
             $this->fieldPermissions('schedule-orders', ['view'], $permissions),
             [

+ 3 - 3
docs/refactor/plan.md

@@ -271,12 +271,12 @@
 6. **9.6. Обмен данными** — экспорт списка, XLS-импорт после уточнения формата, API 1С с журналом загрузок и отдельная миграция старых данных.
 7. **9.7. Рекламации и приёмка** — связь с этапом 10, полные автотесты и пользовательская проверка.
 
-Текущий подэтап: **9.4. Файлы и коммуникации**.
+Текущий подэтап: **9.5. События и документы**.
 
 - [x] **9.1. Основа данных и права:** добавлены нормализованные таблицы и модели, enum статусов/типов, производственный календарь, роль `driver`, действия и permissions отдельных полей.
 - [x] **9.2. Ручной заказ:** реализованы список с фильтрами, цветовой статус, карточка, создание/редактирование, расчёт договорной даты отгрузки и отдельные экземпляры МАФ из общего каталога.
 - [x] **9.3. Доставки и монтажи:** реализованы несколько доставок/монтажей на заказ, выбор водителя/бригадира, недельный график доставок и синхронизация с действующим графиком монтажей.
-- [ ] **9.4. Файлы и коммуникации:** реализовать документы, фотографии, чат и паспорт отдельного МАФ.
+- [x] **9.4. Файлы и коммуникации:** реализованы документы, фотографии, чат и скан паспорта каждого экземпляра МАФ; для файлов используются MIME-иконки и просмотр изображений.
 - [ ] **9.5. События и документы:** реализовать аудит, уведомления и согласованные экспорты/пакеты.
 - [ ] **9.6. Обмен данными:** реализовать согласованную часть XLS/1С и отдельный перенос истории.
 - [ ] **9.7. Рекламации и приёмка:** связать этап 10 и выполнить итоговую проверку.
@@ -301,7 +301,7 @@
 - [ ] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
 - [ ] Подготовить отдельную миграцию исторических данных старого Manager с отчётом о конфликтах и дублях.
 - [x] Добавить карточку заказа с выбором одного или нескольких МАФ.
-- [ ] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал действий.
+- [x] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал согласованных действий.
 - [ ] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
 - [ ] Реализовать импорт/экспорт списка заказов и экспорт МАФ одного заказа.
 - [ ] Реализовать пакеты документов для монтажа, доставки и технической документации.

+ 5 - 2
resources/views/partials/chat.blade.php

@@ -9,8 +9,9 @@
     $contextKey = $contextKey ?? 'chat';
     $title = $title ?? 'Чат';
     $submitLabel = $submitLabel ?? 'Отправить';
-    $canSendNotifications = hasPermission('chat_messages.notify');
-    $canDeleteMessages = hasAnyPermission(['orders.chat.delete', 'reclamations.chat.delete']) && !empty($action);
+    $canCreateMessages = $canCreateMessages ?? true;
+    $canSendNotifications = $canCreateMessages && ($canSendNotifications ?? hasPermission('chat_messages.notify'));
+    $canDeleteMessages = ($canDeleteMessages ?? hasAnyPermission(['orders.chat.delete', 'reclamations.chat.delete'])) && !empty($action);
     $notificationValue = old('notification_type', \App\Models\ChatMessage::NOTIFICATION_NONE);
     $notificationEnabled = $canSendNotifications && $notificationValue !== \App\Models\ChatMessage::NOTIFICATION_NONE;
     $showAllUsers = $notificationValue === \App\Models\ChatMessage::NOTIFICATION_ALL;
@@ -88,6 +89,7 @@
         </div>
     </div>
 
+    @if($canCreateMessages)
     <form action="{{ $action }}" method="post" enctype="multipart/form-data" class="mt-3" data-chat-form>
         @csrf
 
@@ -153,6 +155,7 @@
             @endforeach
         </div>
     </form>
+    @endif
 
     @if($canSendNotifications)
         <div class="modal fade" id="chatRecipientsModal-{{ $contextKey }}" tabindex="-1" aria-labelledby="chatRecipientsModalLabel-{{ $contextKey }}" aria-hidden="true">

+ 130 - 1
resources/views/production_orders/edit.blade.php

@@ -13,6 +13,7 @@
                     'order_item_number' => $item->order_item_number,
                     'factory_number' => $item->factory_number,
                     'manufacture_date' => $item->manufacture_date?->toDateString(),
+                    'passport_file_id' => $item->passport_file_id,
                 ])->values()->all()
                 : [];
         }
@@ -155,6 +156,7 @@
                                 <th>Номер заказа МАФ</th>
                                 <th>Заводской номер</th>
                                 <th>Дата производства</th>
+                                <th>Паспорт</th>
                                 @if($canManageItems)<th></th>@endif
                             </tr>
                             </thead>
@@ -178,6 +180,133 @@
         </form>
 
         @if($order)
+            @foreach($order->items as $orderItem)
+                @if($canManagePassports)
+                    <form id="production-order-passport-upload-{{ $orderItem->id }}"
+                          action="{{ route('schedule.orders.items.passport.store', [$order, $orderItem]) }}"
+                          method="POST" enctype="multipart/form-data" class="visually-hidden">
+                        @csrf
+                    </form>
+                    <form id="production-order-passport-delete-{{ $orderItem->id }}"
+                          action="{{ route('schedule.orders.items.passport.destroy', [$order, $orderItem]) }}"
+                          method="POST" class="visually-hidden">
+                        @csrf
+                        @method('DELETE')
+                    </form>
+                @endif
+            @endforeach
+
+            <div class="row g-3 mt-1">
+                <div class="col-xl-6">
+                    <div class="card h-100">
+                        <div class="card-header d-flex justify-content-between align-items-center">
+                            <strong>Документы</strong>
+                            @if($canManageDocuments)
+                                <label class="btn btn-sm btn-outline-primary mb-0">
+                                    <i class="bi bi-upload"></i> Загрузить
+                                    <input type="file" name="documents[]" multiple class="visually-hidden"
+                                           form="production-order-documents-upload"
+                                           onchange="this.form.requestSubmit()">
+                                </label>
+                            @endif
+                        </div>
+                        <div class="card-body">
+                            @forelse($order->documents as $file)
+                                <div class="d-flex justify-content-between align-items-start gap-2 border-bottom py-2">
+                                    <div class="text-break">
+                                        @include('production_orders.partials.file-link', [
+                                            'file' => $file,
+                                            'gallery' => 'production-order-documents-'.$order->id,
+                                        ])
+                                    </div>
+                                    @if($canManageDocuments)
+                                        <form action="{{ route('schedule.orders.documents.destroy', [$order, $file]) }}" method="POST">
+                                            @csrf
+                                            @method('DELETE')
+                                            <button type="submit" class="btn btn-sm text-danger p-0" title="Удалить">
+                                                <i class="bi bi-x-circle-fill"></i>
+                                            </button>
+                                        </form>
+                                    @endif
+                                </div>
+                            @empty
+                                <span class="text-muted">Документы не загружены.</span>
+                            @endforelse
+                        </div>
+                    </div>
+                </div>
+                <div class="col-xl-6">
+                    <div class="card h-100">
+                        <div class="card-header d-flex justify-content-between align-items-center">
+                            <strong>Фотографии</strong>
+                            @if($canManagePhotos)
+                                <label class="btn btn-sm btn-outline-primary mb-0">
+                                    <i class="bi bi-upload"></i> Загрузить
+                                    <input type="file" name="photos[]" multiple accept=".jpg,.jpeg,.png,.webp"
+                                           class="visually-hidden" form="production-order-photos-upload"
+                                           onchange="this.form.requestSubmit()">
+                                </label>
+                            @endif
+                        </div>
+                        <div class="card-body">
+                            <div class="row g-2">
+                                @forelse($order->photos as $file)
+                                    <div class="col-6 col-md-4 position-relative">
+                                        <a href="{{ $file->link }}" data-toggle="lightbox"
+                                           data-gallery="production-order-photos-{{ $order->id }}" data-size="fullscreen">
+                                            <img src="{{ $file->thumbnail_link }}" alt="{{ $file->original_name }}"
+                                                 class="img-thumbnail w-100">
+                                        </a>
+                                        @if($canManagePhotos)
+                                            <form action="{{ route('schedule.orders.photos.destroy', [$order, $file]) }}"
+                                                  method="POST" class="position-absolute top-0 end-0 me-2 mt-1">
+                                                @csrf
+                                                @method('DELETE')
+                                                <button type="submit" class="btn btn-sm text-danger bg-white p-0" title="Удалить">
+                                                    <i class="bi bi-x-circle-fill"></i>
+                                                </button>
+                                            </form>
+                                        @endif
+                                    </div>
+                                @empty
+                                    <div class="col-12 text-muted">Фотографии не загружены.</div>
+                                @endforelse
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            @if($canManageDocuments)
+                <form id="production-order-documents-upload"
+                      action="{{ route('schedule.orders.documents.store', $order) }}"
+                      method="POST" enctype="multipart/form-data" class="visually-hidden">@csrf</form>
+            @endif
+            @if($canManagePhotos)
+                <form id="production-order-photos-upload"
+                      action="{{ route('schedule.orders.photos.store', $order) }}"
+                      method="POST" enctype="multipart/form-data" class="visually-hidden">@csrf</form>
+            @endif
+
+            <div class="card mt-3">
+                <div class="card-body pt-1">
+                    @include('partials.chat', [
+                        'title' => 'Чат площадки',
+                        'messages' => $order->chatMessages,
+                        'users' => $chatUsers,
+                        'responsibleUserIds' => $chatResponsibleUserIds,
+                        'managerUserId' => $order->manager_id,
+                        'brigadierUserId' => null,
+                        'action' => route('schedule.orders.chat-messages.store', $order),
+                        'contextKey' => 'production-order-'.$order->id,
+                        'submitLabel' => 'Отправить в чат',
+                        'canCreateMessages' => $canCreateChatMessages,
+                        'canDeleteMessages' => $canDeleteChatMessages,
+                        'canSendNotifications' => false,
+                    ])
+                </div>
+            </div>
+
             @include('production_orders.partials.planning')
         @endif
 
@@ -185,7 +314,7 @@
             <template id="production-order-item-template">
                 @include('production_orders.partials.item-row', [
                     'index' => '__INDEX__',
-                    'itemRow' => ['common_catalog_item_id' => '', 'order_item_number' => '', 'factory_number' => '', 'manufacture_date' => ''],
+                    'itemRow' => ['common_catalog_item_id' => '', 'order_item_number' => '', 'factory_number' => '', 'manufacture_date' => '', 'passport_file_id' => null],
                 ])
             </template>
         @endif

+ 15 - 0
resources/views/production_orders/partials/file-link.blade.php

@@ -0,0 +1,15 @@
+@php
+    /** @var \App\Models\File $file */
+    $gallery = $gallery ?? 'production-order-files';
+@endphp
+
+@if($file->isImage())
+    <a href="{{ $file->link }}" target="_blank" rel="noopener" data-toggle="lightbox"
+       data-gallery="{{ $gallery }}" title="Открыть изображение">
+        <i class="{{ $file->iconClass() }}"></i> {{ $file->original_name }}
+    </a>
+@else
+    <a href="{{ $file->link }}" target="_blank" rel="noopener">
+        <i class="{{ $file->iconClass() }}"></i> {{ $file->original_name }}
+    </a>
+@endif

+ 26 - 0
resources/views/production_orders/partials/item-row.blade.php

@@ -1,3 +1,4 @@
+@php($orderItem = !empty($itemRow['id']) && $order ? $order->items->firstWhere('id', (int) $itemRow['id']) : null)
 <tr>
     <td style="min-width: 320px">
         @if(!empty($itemRow['id']))
@@ -42,6 +43,31 @@
             {{ $itemRow['manufacture_date'] ?? '' }}
         @endif
     </td>
+    <td style="min-width: 190px">
+        @if($orderItem?->passportFile)
+            @include('production_orders.partials.file-link', [
+                'file' => $orderItem->passportFile,
+                'gallery' => 'production-order-passports-'.$order->id,
+            ])
+            @if($canManagePassports)
+                <button type="submit" form="production-order-passport-delete-{{ $orderItem->id }}"
+                        class="btn btn-sm text-danger p-0 ms-1" title="Удалить паспорт">
+                    <i class="bi bi-x-circle-fill"></i>
+                </button>
+            @endif
+        @else
+            <span class="text-muted"><i class="bi bi-x-circle"></i> нет</span>
+        @endif
+
+        @if($canManagePassports && $orderItem)
+            <label class="btn btn-sm btn-outline-primary ms-1 mb-0" title="Загрузить скан паспорта">
+                <i class="bi bi-upload"></i>
+                <input type="file" name="passport" class="visually-hidden"
+                       form="production-order-passport-upload-{{ $orderItem->id }}"
+                       onchange="this.form.requestSubmit()">
+            </label>
+        @endif
+    </td>
     @if($canManageItems)
         <td class="text-end">
             <button type="button" class="btn btn-sm btn-outline-danger remove-production-order-item" title="Удалить">

+ 18 - 0
routes/web.php

@@ -19,8 +19,10 @@ use App\Http\Controllers\OrderController;
 use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
 use App\Http\Controllers\ProductSKUController;
+use App\Http\Controllers\ProductionOrderChatMessageController;
 use App\Http\Controllers\ProductionOrderController;
 use App\Http\Controllers\ProductionOrderDeliveryController;
+use App\Http\Controllers\ProductionOrderFileController;
 use App\Http\Controllers\ProductionOrderInstallationController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
@@ -199,6 +201,22 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             ->name('orders.installations.update');
         Route::delete('orders/{productionOrder}/installations/{installation}', [ProductionOrderInstallationController::class, 'destroy'])
             ->name('orders.installations.destroy');
+        Route::post('orders/{productionOrder}/documents', [ProductionOrderFileController::class, 'storeDocuments'])
+            ->name('orders.documents.store');
+        Route::delete('orders/{productionOrder}/documents/{file}', [ProductionOrderFileController::class, 'destroyDocument'])
+            ->name('orders.documents.destroy');
+        Route::post('orders/{productionOrder}/photos', [ProductionOrderFileController::class, 'storePhotos'])
+            ->name('orders.photos.store');
+        Route::delete('orders/{productionOrder}/photos/{file}', [ProductionOrderFileController::class, 'destroyPhoto'])
+            ->name('orders.photos.destroy');
+        Route::post('orders/{productionOrder}/items/{item}/passport', [ProductionOrderFileController::class, 'storePassport'])
+            ->name('orders.items.passport.store');
+        Route::delete('orders/{productionOrder}/items/{item}/passport', [ProductionOrderFileController::class, 'destroyPassport'])
+            ->name('orders.items.passport.destroy');
+        Route::post('orders/{productionOrder}/chat-messages', [ProductionOrderChatMessageController::class, 'store'])
+            ->name('orders.chat-messages.store');
+        Route::delete('orders/{productionOrder}/chat-messages/{chatMessage}', [ProductionOrderChatMessageController::class, 'destroy'])
+            ->name('orders.chat-messages.destroy');
         Route::get('deliveries', [ProductionOrderDeliveryController::class, 'index'])->name('deliveries');
     });
 

+ 153 - 0
tests/Feature/ProductionOrderFileAndChatControllerTest.php

@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Models\ChatMessage;
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class ProductionOrderFileAndChatControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $assistant;
+
+    private User $manager;
+
+    private ProductionOrder $order;
+
+    private ProductionOrderItem $item;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        Storage::fake('public');
+        $this->admin = User::factory()->admin()->create();
+        $this->assistant = User::factory()->assistantHead()->create();
+        $this->manager = User::factory()->manager()->create();
+        $this->order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        $this->item = ProductionOrderItem::factory()->create([
+            'production_order_id' => $this->order->id,
+            'common_catalog_item_id' => CommonCatalogItem::factory(),
+        ]);
+    }
+
+    public function test_assistant_can_upload_documents_photos_and_item_passport(): void
+    {
+        $this->actingAs($this->assistant)
+            ->post(route('schedule.orders.documents.store', $this->order), [
+                'documents' => [UploadedFile::fake()->create('instruction.pdf', 10, 'application/pdf')],
+            ])
+            ->assertRedirect();
+
+        $this->actingAs($this->assistant)
+            ->post(route('schedule.orders.photos.store', $this->order), [
+                'photos' => [UploadedFile::fake()->image('object.jpg', 100, 100)],
+            ])
+            ->assertRedirect();
+
+        $this->actingAs($this->assistant)
+            ->post(route('schedule.orders.items.passport.store', [$this->order, $this->item]), [
+                'passport' => UploadedFile::fake()->create('passport.pdf', 10, 'application/pdf'),
+            ])
+            ->assertRedirect();
+
+        $this->assertSame(1, $this->order->documents()->count());
+        $this->assertSame(1, $this->order->photos()->count());
+        $this->assertNotNull($this->item->fresh()->passport_file_id);
+
+        foreach (File::query()->get() as $file) {
+            Storage::disk('public')->assertExists($file->path);
+        }
+    }
+
+    public function test_manager_can_chat_but_cannot_manage_order_files(): void
+    {
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.chat-messages.store', $this->order), [
+                'message' => 'Заказ принят в работу',
+                'attachments' => [UploadedFile::fake()->create('note.txt', 1, 'text/plain')],
+            ])
+            ->assertRedirect()
+            ->assertSessionHas('success');
+
+        $message = ChatMessage::query()->where('production_order_id', $this->order->id)->firstOrFail();
+        $this->assertSame('Заказ принят в работу', $message->message);
+        $this->assertSame(1, $message->files()->count());
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.documents.store', $this->order), [
+                'documents' => [UploadedFile::fake()->create('forbidden.pdf', 1, 'application/pdf')],
+            ])
+            ->assertForbidden();
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.items.passport.store', [$this->order, $this->item]), [
+                'passport' => UploadedFile::fake()->create('forbidden.pdf', 1, 'application/pdf'),
+            ])
+            ->assertForbidden();
+    }
+
+    public function test_order_card_shows_mime_icons_image_previews_and_chat(): void
+    {
+        $pdf = File::factory()->pdf()->create(['user_id' => $this->admin->id]);
+        $image = File::factory()->image()->create(['user_id' => $this->admin->id]);
+        $passport = File::factory()->image()->create(['user_id' => $this->admin->id]);
+        $this->order->documents()->attach([$pdf->id, $image->id]);
+        $this->order->photos()->attach($image->id);
+        $this->item->update(['passport_file_id' => $passport->id]);
+        ChatMessage::query()->create([
+            'production_order_id' => $this->order->id,
+            'user_id' => $this->manager->id,
+            'message' => 'Сообщение в чате заказа',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.show', $this->order))
+            ->assertOk()
+            ->assertSee('bi-file-earmark-pdf', false)
+            ->assertSee('bi-file-earmark-image', false)
+            ->assertSee('data-toggle="lightbox"', false)
+            ->assertSee('Сообщение в чате заказа')
+            ->assertSee(route('schedule.orders.chat-messages.store', $this->order), false)
+            ->assertDontSee(route('schedule.orders.documents.store', $this->order), false);
+    }
+
+    public function test_file_and_chat_routes_reject_resources_from_another_order(): void
+    {
+        $otherOrder = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        $otherItem = ProductionOrderItem::factory()->create([
+            'production_order_id' => $otherOrder->id,
+            'common_catalog_item_id' => CommonCatalogItem::factory(),
+        ]);
+        $message = ChatMessage::query()->create([
+            'production_order_id' => $otherOrder->id,
+            'user_id' => $this->admin->id,
+            'message' => 'Чужое сообщение',
+        ]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.items.passport.store', [$this->order, $otherItem]), [
+                'passport' => UploadedFile::fake()->create('passport.pdf', 1, 'application/pdf'),
+            ])
+            ->assertNotFound();
+
+        $this->actingAs($this->admin)
+            ->delete(route('schedule.orders.chat-messages.destroy', [$this->order, $message]))
+            ->assertNotFound();
+    }
+}