Jelajahi Sumber

fix production order acceptance issues

Alexander Musikhin 1 Minggu lalu
induk
melakukan
fd080700fb

+ 13 - 0
app/Http/Controllers/FilterController.php

@@ -174,6 +174,9 @@ class FilterController extends Controller
         if ($table === 'schedule_orders' && in_array($column, ['delivery_dates', 'installation_dates'], true)) {
             return $this->productionOrderPlanningFilters($column);
         }
+        if ($table === 'schedule_orders' && $column === 'manager_id') {
+            return $this->productionOrderManagerFilters();
+        }
 
         $gp = session(self::SESSION_KEYS[$table] ?? 'gp_'.$table);
 
@@ -342,6 +345,16 @@ class FilterController extends Controller
         return $this->filterValuesResponse($values);
     }
 
+    private function productionOrderManagerFilters(): JsonResponse
+    {
+        $values = DB::table('production_orders')
+            ->join('users', 'users.id', '=', 'production_orders.manager_id')
+            ->whereNull('production_orders.deleted_at')
+            ->pluck('users.name');
+
+        return $this->filterValuesResponse($values);
+    }
+
     private function filterValuesResponse(iterable $values): JsonResponse
     {
         $normalized = collect($values)

+ 36 - 1
app/Http/Controllers/ProductionOrderController.php

@@ -431,6 +431,38 @@ class ProductionOrderController extends Controller
     private function acceptOrderFilters(Builder $query, Request $request): void
     {
         $filters = (array) $request->input('filters', []);
+        $managerFilter = trim((string) ($filters['manager_id'] ?? ''));
+        if ($managerFilter !== '') {
+            $values = explode('||', $managerFilter);
+            $managerIds = array_values(array_filter(
+                $values,
+                static fn (string $value): bool => ctype_digit($value),
+            ));
+            $managerNames = array_values(array_filter(
+                $values,
+                static fn (string $value): bool => $value !== '-пусто-' && ! ctype_digit($value),
+            ));
+            $includeEmpty = in_array('-пусто-', $values, true);
+
+            $query->where(function (Builder $managerQuery) use ($managerIds, $managerNames, $includeEmpty): void {
+                if ($managerIds !== []) {
+                    $managerQuery->whereIn('manager_id', $managerIds);
+                }
+                if ($managerNames !== []) {
+                    $method = $managerIds !== [] ? 'orWhereHas' : 'whereHas';
+                    $managerQuery->{$method}(
+                        'manager',
+                        static fn (Builder $userQuery): Builder => $userQuery->whereIn('name', $managerNames),
+                    );
+                }
+                if ($includeEmpty) {
+                    ($managerIds !== [] || $managerNames !== [])
+                        ? $managerQuery->orWhereNull('manager_id')
+                        : $managerQuery->whereNull('manager_id');
+                }
+            });
+        }
+
         $relationFilters = [
             'delivery_dates' => ['deliveries', 'delivery_date'],
             'installation_dates' => ['installations', 'installation_date'],
@@ -464,7 +496,10 @@ class ProductionOrderController extends Controller
             });
         }
 
-        $request->merge(['filters' => Arr::except($filters, array_keys($relationFilters))]);
+        $request->merge(['filters' => Arr::except($filters, [
+            'manager_id',
+            ...array_keys($relationFilters),
+        ])]);
         $this->acceptFilters($query, $request);
         $request->merge(['filters' => $filters]);
     }

+ 6 - 10
app/Http/Controllers/ProductionOrderFileController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Http\Controllers;
 
+use App\Http\Requests\UploadProductionOrderDocumentsRequest;
+use App\Http\Requests\UploadProductionOrderPhotosRequest;
 use App\Models\File;
 use App\Models\ProductionOrder;
 use App\Models\ProductionOrderItem;
@@ -15,14 +17,11 @@ use Throwable;
 class ProductionOrderFileController extends Controller
 {
     public function storeDocuments(
-        Request $request,
+        UploadProductionOrderDocumentsRequest $request,
         ProductionOrder $productionOrder,
         FileService $fileService,
     ): RedirectResponse {
-        $validated = $request->validate([
-            'documents' => ['required', 'array', 'max:5'],
-            'documents.*' => ['required', 'file', 'max:10240'],
-        ]);
+        $validated = $request->validated();
 
         try {
             $files = collect($validated['documents'])
@@ -55,14 +54,11 @@ class ProductionOrderFileController extends Controller
     }
 
     public function storePhotos(
-        Request $request,
+        UploadProductionOrderPhotosRequest $request,
         ProductionOrder $productionOrder,
         FileService $fileService,
     ): RedirectResponse {
-        $validated = $request->validate([
-            'photos' => ['required', 'array', 'max:10'],
-            'photos.*' => ['required', 'file', 'mimes:jpeg,jpg,png,webp', 'max:10240'],
-        ]);
+        $validated = $request->validated();
 
         try {
             $files = collect($validated['photos'])

+ 38 - 0
app/Http/Requests/UploadProductionOrderDocumentsRequest.php

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UploadProductionOrderDocumentsRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        return [
+            'documents' => ['required', 'array', 'max:5'],
+            'documents.*' => ['required', 'file', 'max:10240'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function messages(): array
+    {
+        return [
+            'documents.required' => 'Выберите хотя бы один документ.',
+            'documents.array' => 'Не удалось прочитать список документов.',
+            'documents.max' => 'За один раз можно выбрать не более :max документов.',
+            'documents.*.required' => 'Файл документа не выбран.',
+            'documents.*.file' => 'Выбранный документ не является файлом.',
+            'documents.*.uploaded' => 'Не удалось загрузить документ. Возможно, файл слишком большой.',
+            'documents.*.max' => 'Размер каждого документа не должен превышать 10 МБ.',
+        ];
+    }
+}

+ 39 - 0
app/Http/Requests/UploadProductionOrderPhotosRequest.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UploadProductionOrderPhotosRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        return [
+            'photos' => ['required', 'array', 'max:10'],
+            'photos.*' => ['required', 'file', 'mimes:jpeg,jpg,png,webp', 'max:10240'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function messages(): array
+    {
+        return [
+            'photos.required' => 'Выберите хотя бы одну фотографию.',
+            'photos.array' => 'Не удалось прочитать список фотографий.',
+            'photos.max' => 'За один раз можно выбрать не более :max фотографий.',
+            'photos.*.required' => 'Файл фотографии не выбран.',
+            'photos.*.file' => 'Выбранная фотография не является файлом.',
+            'photos.*.uploaded' => 'Не удалось загрузить фотографию. Возможно, файл слишком большой.',
+            'photos.*.mimes' => 'Допустимы только фотографии JPG, JPEG, PNG и WebP.',
+            'photos.*.max' => 'Размер каждой фотографии не должен превышать 10 МБ.',
+        ];
+    }
+}

+ 34 - 3
app/Services/ProductionOrderDocumentService.php

@@ -237,13 +237,35 @@ class ProductionOrderDocumentService
         }
 
         foreach ($items->pluck('catalogItem')->filter()->unique('id') as $catalogItem) {
+            $article = trim((string) $catalogItem->article);
+            $archiveDirectory = 'Техдокументация/'.$this->safeFilename($article).'/';
+            $legacyDirectory = 'public/images/tech-docs/'.$article;
+
+            if ($article !== '' && basename($article) === $article) {
+                foreach (Storage::disk('base')->allFiles($legacyDirectory) as $legacyDocumentPath) {
+                    $archiveName = $archiveDirectory.$this->safeFilename(basename($legacyDocumentPath));
+                    if ($this->addFileIfMissing(
+                        $zip,
+                        Storage::disk('base')->path($legacyDocumentPath),
+                        $archiveName,
+                    )) {
+                        $added++;
+                    }
+                }
+            }
+
             foreach ($catalogItem->documents as $document) {
                 if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
                     continue;
                 }
-                $archiveName = 'Техдокументация/'.$this->safeFilename((string) $catalogItem->article).'/'.$this->safeFilename($document->original_name);
-                $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
-                $added++;
+                $archiveName = $archiveDirectory.$this->safeFilename($document->original_name);
+                if ($this->addFileIfMissing(
+                    $zip,
+                    Storage::disk('public')->path($document->path),
+                    $archiveName,
+                )) {
+                    $added++;
+                }
             }
         }
 
@@ -350,4 +372,13 @@ class ProductionOrderDocumentService
 
         return $name;
     }
+
+    private function addFileIfMissing(ZipArchive $zip, string $sourcePath, string $archiveName): bool
+    {
+        if ($zip->locateName($archiveName) !== false || ! is_file($sourcePath)) {
+            return false;
+        }
+
+        return $zip->addFile($sourcePath, $archiveName);
+    }
 }

+ 417 - 0
docs/refactor/test-order-schedule.md

@@ -0,0 +1,417 @@
+# Сценарий приёмочной проверки графика заказов и рекламаций
+
+Дата подготовки: **04.09.2026**.
+
+Связанные документы:
+
+- [ТЗ: График заказов](tz-order-schedule.md)
+- [Ответы заказчика по графику заказов](tz-order-schedule-answers.md)
+- [ТЗ: Модуль «Графики»](tz-schedules.md)
+- [ТЗ: Рекламации](tz-reclamations.md)
+- [План рефакторинга](plan.md)
+
+## 1. Область проверки
+
+Проверяется ручной контур этапов 9 и 10:
+
+- список и карточка производственных заказов;
+- позиции МАФ из общего каталога;
+- статусы, фильтры и права на поля;
+- доставки и монтажи;
+- документы, фотографии, паспорта МАФ и чат;
+- экспорт и формирование документов;
+- уведомления и журнал действий;
+- создание рекламации типа `Прочее` по выбранным МАФ.
+
+В эту проверку **не входят** импорт XLS, обмен с 1С и перенос истории из старого Manager: для них ещё требуется согласованный формат данных.
+
+## 2. Что подготовить
+
+- Пользователя с ролью `Администратор`.
+- Пользователей с ролями `Помощник руководителя`, `Менеджер`, `Водитель` и `Бригадир`.
+- В общем каталоге — минимум две позиции МАФ:
+  - у первой есть картинка и технические документы; для проверки документов ДКР можно использовать артикул `1112` или `5001`;
+  - у второй нет картинки либо нет технических документов — для проверки пустых состояний.
+- Небольшой файл PDF или DOCX, фотографию JPG/PNG/WebP и скан паспорта МАФ.
+- Работающий обработчик очереди. Экспорт и пакеты документов выполняются в фоне и после постановки задачи могут появиться не сразу.
+
+Для тестовых данных удобно использовать номер с префиксом `TEST`, например `TEST-0904.26`, и даты ближайшей недели. После проверки такой заказ можно удалить администратором.
+
+## 3. Быстрая критическая проверка
+
+Если времени мало, сначала выполнить пункты 3.1–3.7. Они покрывают основной пользовательский путь.
+
+### 3.1. Открытие раздела
+
+1. Войти администратором.
+2. Открыть меню `Графики → График заказов`.
+
+Ожидаемый результат:
+
+- страница открывается без ошибки 403;
+- видны таблица, кнопки `Экспорт` и `Добавить заказ`;
+- в таблице есть колонки с датами доставки и монтажа;
+- `Дата отгрузки по заявке` видна администратору.
+
+### 3.2. Создание заказа
+
+1. Нажать `Добавить заказ`.
+2. Заполнить обязательные поля:
+   - номер и год заказа;
+   - заказчик;
+   - адрес объекта;
+   - менеджер;
+   - статус;
+   - тип исполнения;
+   - номер счёта;
+   - дату оплаты;
+   - срок поставки в рабочих днях.
+3. В блоке `Оборудование` ввести не менее двух символов артикула или наименования.
+4. Выбрать позицию общего каталога, указать количество `2` и добавить её.
+5. Добавить вторую позицию каталога количеством `1`.
+6. Для строк заполнить разные номера заказа МАФ.
+7. Нажать `Сохранить`.
+
+Ожидаемый результат:
+
+- заказ сохраняется и открывается его карточка;
+- слева расположены поля заказа, справа — оборудование;
+- одинаковая позиция количеством `2` представлена двумя отдельными строками;
+- всего в заказе три экземпляра МАФ;
+- у позиции с картинкой показана миниатюра, по нажатию открывается просмотр;
+- рассчитана `Дата отгрузки по договору`: дата оплаты плюс срок поставки по производственному календарю;
+- введённые значения сохраняются после обновления страницы.
+
+### 3.3. Доставка и монтаж
+
+1. В карточке нажать `Перенести в график доставок`.
+2. В блоке новой доставки выбрать дату текущей или следующей недели, время и водителя.
+3. Раскрыть `Данные заявки и перевозки`, заполнить несколько дополнительных полей.
+4. Сохранить доставку.
+5. Аналогично нажать `Перенести в график монтажей`, выбрать дату, число дней и бригадира, затем сохранить.
+6. Добавить по второй доставке и второму монтажу с другими датами.
+
+Ожидаемый результат:
+
+- у одного заказа сохраняется несколько доставок и монтажей;
+- все записи отображаются отдельными блоками в карточке;
+- в списке заказов показаны все даты доставки и монтажа;
+- в журнале действий появились записи о назначении доставки и монтажа с датой, исполнителем и автором изменения.
+
+### 3.4. Проверка графиков
+
+1. Открыть `Графики → График доставок`.
+2. Перейти на неделю созданной доставки с помощью даты или стрелок.
+3. Нажать номер заказа в строке доставки.
+4. Открыть действующий `График монтажей` и найти созданный монтаж.
+
+Ожидаемый результат:
+
+- доставка находится в нужном дне недели;
+- показаны время, номер заказа, заказчик, адрес, водитель, перевозчик и примечание;
+- номер заказа ведёт обратно в его карточку;
+- монтаж отображается в существующем графике монтажей и ведёт к тому же производственному заказу.
+
+### 3.5. Файлы и документы
+
+1. Загрузить в `Документы` PDF или DOCX.
+2. Загрузить в `Фотографии` изображение.
+3. Загрузить скан паспорта в одной строке МАФ.
+4. Нажать `Экспорт МАФ`.
+5. Для доставки нажать `Документы для доставки`.
+6. Для монтажа нажать `Документы для монтажа`.
+7. Выбрать один или несколько МАФ чекбоксами и нажать `Скачать техдокументацию`.
+8. Дождаться обработки очереди и открыть сформированные файлы из уведомления или списка доступных файлов.
+
+Ожидаемый результат:
+
+- у обычного документа показана иконка его MIME-типа и ссылка на скачивание;
+- фотография отображается миниатюрой и открывается в просмотрщике;
+- в строке МАФ паспорт меняется с отсутствующего на загруженный и доступен для скачивания;
+- экспорт МАФ содержит реквизиты заказа и отдельную строку для каждого экземпляра;
+- заявка на доставку сформирована из шаблона для выбранной доставки;
+- монтажный пакет и архив техдокументации создаются без ошибки;
+- техдокументация собирается только для отмеченных МАФ.
+- в архив попадают схемы ДКР из `public/images/tech-docs/{артикул}` и дополнительные документы общего каталога; файлы с одинаковым именем не дублируются.
+
+### 3.6. Создание рекламации
+
+1. В таблице оборудования отметить два МАФ.
+2. Нажать `Создать рекламацию`.
+
+Ожидаемый результат:
+
+- без выбора МАФ форма не отправляется и просит выбрать хотя бы одну позицию;
+- с выбранными МАФ создаётся рекламация и открывается её карточка;
+- тип рекламации — `Прочее`;
+- в поле площадки/заказа есть ссылка с заказчиком и адресом, ведущая в карточку производственного заказа;
+- в оборудовании рекламации отображаются только выбранные МАФ с картинкой, артикулом, номером заказа МАФ, заводским номером и датой производства;
+- действия для платёжной документации и документы ДКР отсутствуют;
+- остальные возможности рекламации — детали, запчасти, фотографии, акты и чат — остаются доступны согласно правам пользователя.
+
+### 3.7. Вкладки рекламаций
+
+1. Открыть `Рекламации → Все` и найти созданную рекламацию.
+2. Открыть вкладку `ДКР`.
+
+Ожидаемый результат:
+
+- рекламация типа `Прочее` присутствует во вкладке `Все`;
+- во вкладке `ДКР` её нет;
+- существующие рекламации ДКР продолжают отображаться и работать как раньше.
+
+## 4. Полная проверка списка заказов
+
+### 4.1. Колонки и сортировка
+
+Проверьте наличие колонок:
+
+- ID;
+- номер заказа;
+- заказчик;
+- адрес объекта;
+- номер счёта;
+- дата оплаты;
+- срок поставки;
+- дата отгрузки по договору;
+- дата отгрузки по заявке — только для администратора по умолчанию;
+- статус;
+- даты доставки;
+- даты монтажа;
+- менеджер;
+- примечание.
+
+Ожидаемый результат:
+
+- при первом открытии новые заказы находятся выше старых;
+- переход в карточку и кнопка `Назад` возвращают к прежнему списку с фильтрами;
+- выбранный в основном интерфейсе год не скрывает заказы графика: модуль использует собственный год заказа.
+
+### 4.2. Фильтры и поиск
+
+1. Последовательно проверить фильтр по номеру, заказчику, статусу и менеджеру.
+2. Проверить фильтры дат доставки и монтажа.
+3. У заказа с несколькими датами выбрать каждую дату по очереди.
+4. Проверить поиск по номеру, заказчику и адресу.
+5. Сбросить фильтры.
+
+Ожидаемый результат:
+
+- каждый фильтр оставляет только совпадающие заказы;
+- фильтры доставки и монтажа предлагают выбор существующих значений, а не диапазон дат;
+- заказ находится по любой связанной дате доставки или монтажа;
+- сброс возвращает полный список.
+
+### 4.3. Цвета и просрочка
+
+1. По очереди менять статус тестового заказа и возвращаться в список.
+2. Установить статус `Закрыт`.
+3. Для отдельной проверки создать или изменить незакрытый заказ так, чтобы дата отгрузки по договору оказалась в прошлом.
+
+Ожидаемый результат:
+
+- цветом статуса подсвечивается вся строка заказа;
+- закрытая строка отображается полупрозрачной;
+- у незакрытого просроченного заказа номер визуально выделен красным;
+- закрытый заказ не помечается как просроченный.
+
+### 4.4. Экспорт отфильтрованного списка
+
+1. Установить фильтр, оставляющий только тестовый заказ.
+2. Нажать `Экспорт`.
+3. Дождаться обработки фоновой задачи и открыть XLSX.
+
+Ожидаемый результат:
+
+- в файле находятся только записи, соответствующие текущим фильтрам и поиску;
+- один заказ занимает одну строку;
+- оборудование заказа агрегировано в одной колонке;
+- реквизиты и даты заказа соответствуют карточке.
+
+## 5. Полная проверка карточки
+
+### 5.1. Редактирование оборудования
+
+1. Добавить ещё один МАФ из общего каталога.
+2. Изменить номер заказа МАФ, заводской номер и дату производства.
+3. Удалить одну ещё не использованную в рекламации позицию.
+4. Сохранить и обновить страницу.
+
+Ожидаемый результат:
+
+- добавление одной позиции создаёт одну новую строку;
+- изменения сохраняются у правильного экземпляра;
+- удалённая строка исчезает, остальные не меняются;
+- миниатюра всегда относится к выбранной позиции общего каталога.
+
+### 5.2. Изменение статуса и аудит
+
+1. Запомнить число записей журнала.
+2. Изменить обычное поле, например примечание, и сохранить.
+3. Изменить статус и сохранить.
+
+Ожидаемый результат:
+
+- изменение примечания не создаёт запись журнала;
+- изменение статуса создаёт ровно одну новую значимую запись с автором и временем;
+- добавление доставок и монтажей также фиксируется в журнале.
+
+### 5.3. Редактирование и удаление планов
+
+1. Изменить дату или исполнителя существующей доставки.
+2. Изменить параметры монтажа.
+3. Проверить новые значения в соответствующих графиках.
+4. Удалить одну доставку и один монтаж.
+
+Ожидаемый результат:
+
+- изменения сразу отражаются в карточке и графиках;
+- удалённые записи исчезают, остальные доставки и монтажи сохраняются;
+- нельзя случайно изменить запись, принадлежащую другому заказу.
+
+### 5.4. Чат
+
+1. Отправить обычное сообщение.
+2. Если интерфейс позволяет, адресовать сообщение конкретному пользователю.
+3. Обновить страницу.
+
+Ожидаемый результат:
+
+- сообщение остаётся в чате этого заказа;
+- отображаются автор и время;
+- чат другого заказа не содержит это сообщение.
+
+## 6. Проверка прав
+
+Права могут быть изменены администратором индивидуально. Ниже указаны ожидаемые права ролей по умолчанию после применения миграций.
+
+### 6.1. Администратор
+
+Ожидаемый результат:
+
+- доступны создание, полное редактирование и удаление заказа;
+- доступны управление МАФ, доставками, монтажами и файлами;
+- доступны экспорт и генерация документов;
+- видна и редактируется `Дата отгрузки по заявке`.
+
+### 6.2. Помощник руководителя
+
+1. Войти помощником и открыть тестовый заказ.
+2. Попробовать изменить адрес, примечание, заводской номер и дату производства.
+3. Проверить доставки, монтажи, документы, фотографии, паспорта и чат.
+
+Ожидаемый результат:
+
+- разрешены адрес, примечание, заводской номер и дата производства;
+- доступны управление доставками, монтажами, документами, фотографиями и паспортами;
+- недоступны административные поля и `Дата отгрузки по заявке`;
+- нельзя добавлять или удалять состав заказа, если отдельное право не выдано администратором.
+
+### 6.3. Менеджер
+
+Ожидаемый результат по умолчанию:
+
+- список и карточка доступны для чтения;
+- административные поля и `Дата отгрузки по заявке` не видны либо недоступны для изменения;
+- редактирование производственного заказа, доставок и монтажей недоступно;
+- доступен чат;
+- при наличии стандартного права создания рекламаций доступно создание рекламации по выбранным МАФ.
+
+### 6.4. Водитель и бригадир
+
+Ожидаемый результат по умолчанию:
+
+- график заказов доступен для чтения;
+- водитель присутствует в списке выбора водителя, бригадир — в списке выбора бригадира;
+- управление заказом без отдельно выданных прав недоступно;
+- назначенные пользовательские уведомления приходят только при включённых событиях и каналах.
+
+### 6.5. Индивидуальная настройка прав
+
+1. Администратором открыть карточку пользователя.
+2. Выдать или запретить одно действие либо просмотр/редактирование одного поля графика заказов.
+3. Перезайти этим пользователем и проверить карточку.
+
+Ожидаемый результат:
+
+- изменение применяется только к выбранному праву;
+- запрет роли или пользователя имеет приоритет над разрешением;
+- скрытое поле отсутствует и в карточке, и в списке;
+- отсутствие права на действие скрывает кнопку и запрещает прямой запрос к маршруту.
+
+## 7. Проверка уведомлений
+
+1. В карточке получателя включить в разделе `График заказов` браузерный канал для событий:
+   - создан заказ;
+   - изменён статус;
+   - добавлена доставка;
+   - добавлена дата монтажа;
+   - добавлена рекламация.
+2. Выполнить каждое событие другим пользователем.
+3. Проверить уведомления менеджера заказа, назначенного водителя и бригадира.
+4. Отключить одно событие и повторить его.
+
+Ожидаемый результат:
+
+- уведомления получают только связанные с заказом пользователи;
+- текст содержит номер заказа, событие и автора изменения;
+- ссылка открывает нужный заказ;
+- после отключения события новое уведомление по нему не создаётся;
+- изменение обычного поля заказа не создаёт отдельного уведомления.
+
+## 8. Негативные проверки
+
+- Попытаться сохранить второй заказ с тем же номером и годом — ожидается ошибка уникальности.
+- Использовать тот же номер в другом году — сохранение должно быть разрешено.
+- Нажать `Создать рекламацию` без выбранных МАФ — ожидается предупреждение, рекламация не создаётся.
+- Попытаться сформировать техдокументацию без выбранных МАФ — ожидается предупреждение.
+- Открыть карточку или действие без необходимого права прямой ссылкой — ожидается 403, а не выполнение действия.
+- Указать в запросе рекламации МАФ другого заказа — рекламация не должна создаваться.
+- Открыть сгенерированный файл другим обычным пользователем — ожидается запрет; файл доступен инициатору и администратору.
+- Удалить доставку или монтаж и обновить график — удалённая запись не должна вернуться.
+
+## 9. Что прислать разработчику при ошибке
+
+Для каждого несоответствия желательно указать:
+
+1. роль и имя пользователя;
+2. раздел и номер заказа;
+3. выполненные действия;
+4. что ожидалось и что получилось;
+5. скриншот;
+6. примерное время ошибки;
+7. текст ошибки из интерфейса, если он есть.
+
+Шаблон:
+
+```text
+Роль:
+Заказ:
+Раздел:
+Шаги:
+Ожидалось:
+Получилось:
+Время:
+```
+
+## 10. Итоговый чек-лист приёмки
+
+- [ ] Заказ создаётся и повторно открывается без потери данных.
+- [ ] Одинаковые МАФ хранятся отдельными экземплярами.
+- [ ] Картинки каталога отображаются и открываются.
+- [ ] Договорная дата рассчитывается по рабочим дням.
+- [ ] Строки списка окрашены по статусу, закрытые полупрозрачны, просрочка заметна.
+- [ ] Все фильтры работают; даты доставки и монтажа выбираются из значений.
+- [ ] У одного заказа работают несколько доставок и монтажей.
+- [ ] Доставка видна в недельном графике, монтаж — в графике монтажей.
+- [ ] Документы, фотографии и паспорта загружаются и открываются корректно.
+- [ ] Экспорт и все пакеты документов формируются.
+- [ ] Журнал содержит только согласованные значимые действия.
+- [ ] Чат работает внутри конкретного заказа.
+- [ ] Уведомления учитывают настройки событий и каналов.
+- [ ] Права администратора, помощника, менеджера, водителя и бригадира соответствуют ожиданиям.
+- [ ] Рекламация создаётся только по выбранным МАФ и получает тип `Прочее`.
+- [ ] Рекламация видна в `Все`, отсутствует в `ДКР`, платёжные документы недоступны.
+- [ ] Существующие площадки, график монтажей и рекламации ДКР продолжают работать.
+
+Итог проверки: **принято / принято с замечаниями / требуется доработка**.

+ 40 - 23
resources/views/production_orders/edit.blade.php

@@ -251,12 +251,10 @@
                         <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>
+                                <button type="button" class="btn btn-sm text-success"
+                                        onclick="$('#production-order-documents-input').trigger('click')">
+                                    <i class="bi bi-plus-circle-fill"></i> Загрузить
+                                </button>
                             @endif
                         </div>
                         <div class="card-body">
@@ -285,26 +283,34 @@
                     </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 class="card">
+                        <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
+                            <strong>Фотографии ({{ $order->photos->count() }})</strong>
+                            <div class="d-flex flex-wrap gap-1">
+                                @if($canManagePhotos)
+                                    <button type="button" class="btn btn-sm text-success"
+                                            onclick="$('#production-order-photos-input').trigger('click')">
+                                        <i class="bi bi-plus-circle-fill"></i> Загрузить
+                                    </button>
+                                @endif
+                                <button type="button" class="btn btn-sm btn-outline-secondary collapsed"
+                                        data-bs-toggle="collapse"
+                                        data-bs-target="#production-order-photos-{{ $order->id }}"
+                                        aria-expanded="false"
+                                        aria-controls="production-order-photos-{{ $order->id }}">
+                                    <i class="bi bi-images"></i> Показать все
+                                </button>
+                            </div>
                         </div>
-                        <div class="card-body">
+                        <div id="production-order-photos-{{ $order->id }}" class="card-body collapse"
+                             data-lazy-images>
                             <div class="row g-2">
                                 @forelse($order->photos as $file)
-                                    <div class="col-6 col-md-4 position-relative">
+                                    <div class="col-auto 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">
+                                            <img data-src="{{ $file->thumbnail_link }}" alt="{{ $file->original_name }}"
+                                                 class="img-thumbnail maf-img">
                                         </a>
                                         @if($canManagePhotos)
                                             <form action="{{ route('schedule.orders.photos.destroy', [$order, $file]) }}"
@@ -329,12 +335,23 @@
             @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>
+                      method="POST" enctype="multipart/form-data" class="visually-hidden">
+                    @csrf
+                    <input required type="file" id="production-order-documents-input"
+                           name="documents[]" multiple class="form-control form-control-sm"
+                           onchange="$(this).parent().submit()">
+                </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>
+                      method="POST" enctype="multipart/form-data" class="visually-hidden">
+                    @csrf
+                    <input required type="file" id="production-order-photos-input"
+                           name="photos[]" multiple accept=".jpg,.jpeg,.png,.webp"
+                           class="form-control form-control-sm"
+                           onchange="$(this).parent().submit()">
+                </form>
             @endif
 
             <div class="card mt-3">

+ 35 - 0
tests/Feature/ProductionOrderControllerTest.php

@@ -255,6 +255,41 @@ class ProductionOrderControllerTest extends TestCase
             ->assertDontSee('ГЗ-РАЗМЕЩЕН');
     }
 
+    public function test_manager_filter_uses_names_and_filters_orders_by_manager(): void
+    {
+        $this->manager->update(['name' => 'Иванов Иван Иванович']);
+        $otherManager = User::factory()->manager()->create(['name' => 'Петров Пётр Петрович']);
+        $matching = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-МЕНЕДЖЕР-ИВАНОВ',
+            'manager_id' => $this->manager->id,
+        ]);
+        $other = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-МЕНЕДЖЕР-ПЕТРОВ',
+            'manager_id' => $otherManager->id,
+        ]);
+
+        $response = $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'schedule_orders',
+                'column' => 'manager_id',
+            ]))
+            ->assertOk();
+
+        $this->assertSame(
+            ['Иванов Иван Иванович', 'Петров Пётр Петрович'],
+            $response->json(),
+        );
+        $this->assertNotContains((string) $this->manager->id, $response->json());
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders', [
+                'filters' => ['manager_id' => 'Иванов Иван Иванович'],
+            ]))
+            ->assertOk()
+            ->assertSee($matching->order_number)
+            ->assertDontSee($other->order_number);
+    }
+
     public function test_order_schedule_shows_planning_dates_and_status_row_styles(): void
     {
         $closed = ProductionOrder::factory()->create([

+ 27 - 3
tests/Feature/ProductionOrderDocumentControllerTest.php

@@ -44,6 +44,7 @@ class ProductionOrderDocumentControllerTest extends TestCase
 
         Storage::fake('local');
         Storage::fake('public');
+        Storage::fake('base');
         $this->admin = User::factory()->admin()->create();
         $this->manager = User::factory()->manager()->create(['phone' => '+7 900 123-45-67']);
         $this->driver = User::factory()->driver()->create();
@@ -126,13 +127,24 @@ class ProductionOrderDocumentControllerTest extends TestCase
 
     public function test_installation_and_technical_archives_include_catalog_documents(): void
     {
-        $document = File::factory()->pdf()->create([
+        Storage::disk('base')->put('public/images/tech-docs/ART-100/manual.pdf', 'legacy-manual');
+        Storage::disk('base')->put('public/images/tech-docs/ART-100/assembly.pdf', 'legacy-assembly');
+        $duplicateDocument = File::factory()->pdf()->create([
             'user_id' => $this->admin->id,
             'path' => 'catalog/ART-100/manual.pdf',
             'original_name' => 'manual.pdf',
         ]);
-        Storage::disk('public')->put($document->path, 'pdf-content');
-        $this->item->catalogItem->documents()->attach($document->id);
+        $additionalDocument = File::factory()->pdf()->create([
+            'user_id' => $this->admin->id,
+            'path' => 'catalog/ART-100/additional.pdf',
+            'original_name' => 'additional.pdf',
+        ]);
+        Storage::disk('public')->put($duplicateDocument->path, 'common-catalog-manual');
+        Storage::disk('public')->put($additionalDocument->path, 'common-catalog-additional');
+        $this->item->catalogItem->documents()->attach([
+            $duplicateDocument->id,
+            $additionalDocument->id,
+        ]);
         $installation = ProductionOrderInstallation::factory()->create([
             'production_order_id' => $this->order->id,
             'brigadier_id' => $this->brigadier->id,
@@ -155,7 +167,19 @@ class ProductionOrderDocumentControllerTest extends TestCase
         $technicalEntries = $this->zipEntries(Storage::disk('local')->path($technicalFile->path));
         $installationEntries = $this->zipEntries(Storage::disk('local')->path($installationFile->path));
         $this->assertContains('Техдокументация/ART-100/manual.pdf', $technicalEntries);
+        $this->assertContains('Техдокументация/ART-100/assembly.pdf', $technicalEntries);
+        $this->assertContains('Техдокументация/ART-100/additional.pdf', $technicalEntries);
+        $this->assertSame(1, count(array_filter(
+            $technicalEntries,
+            static fn (string $name): bool => $name === 'Техдокументация/ART-100/manual.pdf',
+        )));
         $this->assertContains('Техдокументация/ART-100/manual.pdf', $installationEntries);
+        $this->assertContains('Техдокументация/ART-100/assembly.pdf', $installationEntries);
+        $this->assertContains('Техдокументация/ART-100/additional.pdf', $installationEntries);
+        $this->assertSame(1, count(array_filter(
+            $installationEntries,
+            static fn (string $name): bool => $name === 'Техдокументация/ART-100/manual.pdf',
+        )));
         $this->assertTrue(collect($installationEntries)->contains(fn (string $name): bool => str_ends_with($name, '.xlsx')));
     }
 

+ 55 - 0
tests/Feature/ProductionOrderFileAndChatControllerTest.php

@@ -102,6 +102,55 @@ class ProductionOrderFileAndChatControllerTest extends TestCase
             ->assertForbidden();
     }
 
+    public function test_order_card_uses_queued_multi_file_upload_forms(): void
+    {
+        $this->actingAs($this->assistant)
+            ->get(route('schedule.orders.show', $this->order))
+            ->assertOk()
+            ->assertSee('id="production-order-documents-input"', false)
+            ->assertSee('name="documents[]" multiple', false)
+            ->assertSee('id="production-order-photos-input"', false)
+            ->assertSee('name="photos[]" multiple', false)
+            ->assertSee('onchange="$(this).parent().submit()"', false);
+    }
+
+    public function test_document_and_photo_upload_validation_errors_are_in_russian(): void
+    {
+        $documentResponse = $this->actingAs($this->assistant)
+            ->withHeader('Accept', 'application/json')
+            ->post(route('schedule.orders.documents.store', $this->order), [
+                'documents' => ['не файл'],
+            ])
+            ->assertUnprocessable()
+            ->assertJsonValidationErrors('documents.0');
+
+        $this->assertSame(
+            'Выбранный документ не является файлом.',
+            $documentResponse->json('errors')['documents.0'][0],
+        );
+        $this->assertStringContainsString(
+            'Выбранный документ не является файлом.',
+            (string) $documentResponse->json('message'),
+        );
+
+        $photoResponse = $this->actingAs($this->assistant)
+            ->withHeader('Accept', 'application/json')
+            ->post(route('schedule.orders.photos.store', $this->order), [
+                'photos' => [UploadedFile::fake()->create('document.pdf', 10, 'application/pdf')],
+            ])
+            ->assertUnprocessable()
+            ->assertJsonValidationErrors('photos.0');
+
+        $this->assertSame(
+            'Допустимы только фотографии JPG, JPEG, PNG и WebP.',
+            $photoResponse->json('errors')['photos.0'][0],
+        );
+        $this->assertStringContainsString(
+            'Допустимы только фотографии JPG, JPEG, PNG и WebP.',
+            (string) $photoResponse->json('message'),
+        );
+    }
+
     public function test_order_card_shows_mime_icons_image_previews_and_chat(): void
     {
         $pdf = File::factory()->pdf()->create(['user_id' => $this->admin->id]);
@@ -122,6 +171,12 @@ class ProductionOrderFileAndChatControllerTest extends TestCase
             ->assertSee('bi-file-earmark-pdf', false)
             ->assertSee('bi-file-earmark-image', false)
             ->assertSee('data-toggle="lightbox"', false)
+            ->assertSee('Фотографии (1)')
+            ->assertSee('data-bs-target="#production-order-photos-'.$this->order->id.'"', false)
+            ->assertSee('id="production-order-photos-'.$this->order->id.'" class="card-body collapse"', false)
+            ->assertSee('data-lazy-images', false)
+            ->assertSee('data-src="'.$image->thumbnail_link.'"', false)
+            ->assertSee('class="img-thumbnail maf-img"', false)
             ->assertSee('Сообщение в чате заказа')
             ->assertSee(route('schedule.orders.chat-messages.store', $this->order), false)
             ->assertDontSee(route('schedule.orders.documents.store', $this->order), false);