13 Commits d30f7725f8 ... 40406bfce7

Auteur SHA1 Message Date
  Alexander Musikhin 40406bfce7 implement production order XLS import il y a 4 jours
  Alexander Musikhin e2689f7b0a document production order XLS import rules il y a 4 jours
  Alexander Musikhin da1ed50ab9 updated docs il y a 2 semaines
  Alexander Musikhin 226cc67bc5 fix production order card overflow il y a 2 semaines
  Alexander Musikhin fd080700fb fix production order acceptance issues il y a 2 semaines
  Alexander Musikhin 89ac489c25 fix production order schedule requirements il y a 2 semaines
  Alexander Musikhin b31a7faa26 export filtered production order schedule il y a 2 semaines
  Alexander Musikhin 174247419c add production order notification filter labels il y a 2 semaines
  Alexander Musikhin 5396569260 disable Vite file watcher polling il y a 2 semaines
  Alexander Musikhin c120680919 add production order notifications and documents il y a 2 semaines
  Alexander Musikhin 219c5de95f add production order files and chat il y a 2 semaines
  Alexander Musikhin 2bf8c56fe2 implement production order planning il y a 2 semaines
  Alexander Musikhin eb59396118 implement production order schedule core il y a 2 semaines
99 fichiers modifiés avec 8330 ajouts et 104 suppressions
  1. 4 1
      app/Console/Commands/CleanupGeneratedDocuments.php
  2. 12 0
      app/Enums/ProductionOrderActivityType.php
  3. 35 0
      app/Enums/ProductionOrderExecutionType.php
  4. 33 0
      app/Enums/ProductionOrderNotificationEvent.php
  5. 13 0
      app/Enums/ProductionOrderSource.php
  6. 47 0
      app/Enums/ProductionOrderStatus.php
  7. 1 0
      app/Http/Controllers/ChatMessageController.php
  8. 47 0
      app/Http/Controllers/FilterController.php
  9. 78 0
      app/Http/Controllers/ProductionOrderChatMessageController.php
  10. 537 0
      app/Http/Controllers/ProductionOrderController.php
  11. 115 0
      app/Http/Controllers/ProductionOrderDeliveryController.php
  12. 126 0
      app/Http/Controllers/ProductionOrderDocumentController.php
  13. 145 0
      app/Http/Controllers/ProductionOrderFileController.php
  14. 76 0
      app/Http/Controllers/ProductionOrderInstallationController.php
  15. 50 0
      app/Http/Controllers/ProductionOrderReclamationController.php
  16. 11 2
      app/Http/Controllers/ReclamationController.php
  17. 17 5
      app/Http/Controllers/ScheduleController.php
  18. 10 0
      app/Http/Controllers/UserController.php
  19. 38 0
      app/Http/Requests/CreateProductionOrderReclamationRequest.php
  20. 65 0
      app/Http/Requests/SaveProductionOrderDeliveryRequest.php
  21. 48 0
      app/Http/Requests/SaveProductionOrderInstallationRequest.php
  22. 101 0
      app/Http/Requests/SaveProductionOrderRequest.php
  23. 38 0
      app/Http/Requests/UploadProductionOrderDocumentsRequest.php
  24. 39 0
      app/Http/Requests/UploadProductionOrderPhotosRequest.php
  25. 54 0
      app/Jobs/Export/ExportProductionOrdersJob.php
  26. 64 0
      app/Jobs/GenerateProductionOrderFileJob.php
  27. 9 0
      app/Jobs/Import/ImportJob.php
  28. 6 0
      app/Models/ChatMessage.php
  29. 23 0
      app/Models/File.php
  30. 1 0
      app/Models/Import.php
  31. 29 0
      app/Models/ProductionCalendarDay.php
  32. 169 0
      app/Models/ProductionOrder.php
  33. 60 0
      app/Models/ProductionOrderActivity.php
  34. 65 0
      app/Models/ProductionOrderDelivery.php
  35. 57 0
      app/Models/ProductionOrderInstallation.php
  36. 70 0
      app/Models/ProductionOrderItem.php
  37. 14 0
      app/Models/Reclamation.php
  38. 1 0
      app/Models/ReclamationView.php
  39. 3 0
      app/Models/Role.php
  40. 9 0
      app/Models/Schedule.php
  41. 6 0
      app/Models/UserNotification.php
  42. 3 0
      app/Models/UserNotificationSetting.php
  43. 196 0
      app/Services/Export/ExportProductionOrdersService.php
  44. 454 0
      app/Services/Import/ImportProductionOrdersService.php
  45. 125 9
      app/Services/NotificationService.php
  46. 44 0
      app/Services/ProductionCalendarService.php
  47. 384 0
      app/Services/ProductionOrderDocumentService.php
  48. 127 0
      app/Services/ProductionOrderPlanningService.php
  49. 140 0
      app/Services/ProductionOrderService.php
  50. 45 0
      config/access.php
  51. 31 2
      config/access_routes.php
  52. 25 0
      database/factories/ProductionCalendarDayFactory.php
  53. 29 0
      database/factories/ProductionOrderActivityFactory.php
  54. 42 0
      database/factories/ProductionOrderDeliveryFactory.php
  55. 48 0
      database/factories/ProductionOrderFactory.php
  56. 29 0
      database/factories/ProductionOrderInstallationFactory.php
  57. 31 0
      database/factories/ProductionOrderItemFactory.php
  58. 7 0
      database/factories/UserFactory.php
  59. 137 0
      database/migrations/2026_09_03_000001_create_production_order_tables.php
  60. 26 0
      database/migrations/2026_09_03_000002_sync_production_order_permissions.php
  61. 28 0
      database/migrations/2026_09_03_000003_link_production_order_installations_to_schedules.php
  62. 50 0
      database/migrations/2026_09_03_000004_add_files_and_chat_to_production_orders.php
  63. 24 0
      database/migrations/2026_09_03_000005_add_production_order_notification_settings.php
  64. 26 0
      database/migrations/2026_09_03_000006_resync_production_order_field_permissions.php
  65. 104 0
      database/migrations/2026_09_03_000007_link_reclamations_to_production_orders.php
  66. 43 2
      database/seeders/RbacSeeder.php
  67. 55 22
      docs/refactor/plan.md
  68. 419 0
      docs/refactor/test-order-schedule.md
  69. 6 8
      docs/refactor/tz-order-schedule-answers.md
  70. 4 9
      docs/refactor/tz-order-schedule.md
  71. 6 5
      docs/refactor/tz-schedules.md
  72. 5 2
      resources/views/partials/chat.blade.php
  73. 18 1
      resources/views/partials/table.blade.php
  74. 92 0
      resources/views/production_order_deliveries/index.blade.php
  75. 503 0
      resources/views/production_orders/edit.blade.php
  76. 91 0
      resources/views/production_orders/index.blade.php
  77. 120 0
      resources/views/production_orders/partials/delivery-form.blade.php
  78. 15 0
      resources/views/production_orders/partials/file-link.blade.php
  79. 59 0
      resources/views/production_orders/partials/installation-form.blade.php
  80. 95 0
      resources/views/production_orders/partials/item-row.blade.php
  81. 84 0
      resources/views/production_orders/partials/planning.blade.php
  82. 39 7
      resources/views/reclamations/edit.blade.php
  83. 28 17
      resources/views/schedule/index.blade.php
  84. 1 0
      resources/views/users/edit.blade.php
  85. 58 8
      routes/web.php
  86. 25 0
      tests/Feature/CleanupGeneratedDocumentsCommandTest.php
  87. 577 0
      tests/Feature/ProductionOrderControllerTest.php
  88. 253 0
      tests/Feature/ProductionOrderDocumentControllerTest.php
  89. 208 0
      tests/Feature/ProductionOrderFileAndChatControllerTest.php
  90. 144 0
      tests/Feature/ProductionOrderNotificationTest.php
  91. 326 0
      tests/Feature/ProductionOrderPlanningControllerTest.php
  92. 119 0
      tests/Feature/ProductionOrderReclamationControllerTest.php
  93. 4 1
      tests/Unit/Helpers/RolesHelperTest.php
  94. 39 0
      tests/Unit/Models/ProductionOrderTest.php
  95. 18 0
      tests/Unit/Services/AccessServiceTest.php
  96. 128 0
      tests/Unit/Services/Export/ExportProductionOrdersServiceTest.php
  97. 211 0
      tests/Unit/Services/Import/ImportProductionOrdersServiceTest.php
  98. 56 0
      tests/Unit/Services/ProductionCalendarServiceTest.php
  99. 0 3
      vite.config.js

+ 4 - 1
app/Console/Commands/CleanupGeneratedDocuments.php

@@ -165,7 +165,10 @@ class CleanupGeneratedDocuments extends Command
 
     private function generatedFileDisk(File $file): string
     {
-        return Str::startsWith((string) $file->path, 'generated/technical-descriptions/')
+        return Str::startsWith((string) $file->path, [
+            'generated/technical-descriptions/',
+            'generated/production-orders/',
+        ])
             ? 'local'
             : 'public';
     }

+ 12 - 0
app/Enums/ProductionOrderActivityType.php

@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderActivityType: string
+{
+    case StatusChanged = 'status_changed';
+    case DeliveryScheduled = 'delivery_scheduled';
+    case InstallationScheduled = 'installation_scheduled';
+}

+ 35 - 0
app/Enums/ProductionOrderExecutionType.php

@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderExecutionType: string
+{
+    case Pickup = 'pickup';
+    case Delivery = 'delivery';
+    case Installation = 'installation';
+    case DeliveryAndInstallation = 'delivery_and_installation';
+    case DeliveryAndSupervision = 'delivery_and_supervision';
+    case Supervision = 'supervision';
+
+    public const LABELS = [
+        self::Pickup->value => 'Самовывоз',
+        self::Delivery->value => 'Доставка',
+        self::Installation->value => 'Монтаж',
+        self::DeliveryAndInstallation->value => 'Доставка + Монтаж',
+        self::DeliveryAndSupervision->value => 'Доставка + Шеф-монтаж',
+        self::Supervision->value => 'Шеф-монтаж',
+    ];
+
+    public function label(): string
+    {
+        return self::LABELS[$this->value];
+    }
+
+    /** @return array<string, string> */
+    public static function options(): array
+    {
+        return self::LABELS;
+    }
+}

+ 33 - 0
app/Enums/ProductionOrderNotificationEvent.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderNotificationEvent: string
+{
+    case Created = 'created';
+    case StatusChanged = 'status_changed';
+    case DeliveryAdded = 'delivery_added';
+    case InstallationAdded = 'installation_added';
+    case ReclamationAdded = 'reclamation_added';
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::Created => 'Создан заказ',
+            self::StatusChanged => 'Изменён статус заказа',
+            self::DeliveryAdded => 'Добавлена доставка',
+            self::InstallationAdded => 'Добавлена дата монтажа',
+            self::ReclamationAdded => 'Добавлена рекламация',
+        };
+    }
+
+    /** @return array<string, string> */
+    public static function options(): array
+    {
+        return collect(self::cases())->mapWithKeys(
+            static fn (self $event): array => [$event->value => $event->label()],
+        )->all();
+    }
+}

+ 13 - 0
app/Enums/ProductionOrderSource.php

@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderSource: string
+{
+    case Manual = 'manual';
+    case OneC = '1c';
+    case Spreadsheet = 'spreadsheet';
+    case Legacy = 'legacy';
+}

+ 47 - 0
app/Enums/ProductionOrderStatus.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderStatus: string
+{
+    case Placed = 'placed';
+    case ReadyAtFactory = 'ready_at_factory';
+    case InTransit = 'in_transit';
+    case PartialReceipt = 'partial_receipt';
+    case InStock = 'in_stock';
+    case Closed = 'closed';
+
+    public const LABELS = [
+        self::Placed->value => 'Размещен',
+        self::ReadyAtFactory->value => 'Готов на фабрике',
+        self::InTransit->value => 'В пути',
+        self::PartialReceipt->value => 'Частичное поступление',
+        self::InStock->value => 'На складе',
+        self::Closed->value => 'Закрыт',
+    ];
+
+    public function label(): string
+    {
+        return self::LABELS[$this->value];
+    }
+
+    public function color(): string
+    {
+        return match ($this) {
+            self::Placed => 'secondary',
+            self::ReadyAtFactory => 'info',
+            self::InTransit => 'primary',
+            self::PartialReceipt => 'warning',
+            self::InStock => 'success',
+            self::Closed => 'dark',
+        };
+    }
+
+    /** @return array<string, string> */
+    public static function options(): array
+    {
+        return self::LABELS;
+    }
+}

+ 1 - 0
app/Http/Controllers/ChatMessageController.php

@@ -186,6 +186,7 @@ class ChatMessageController extends Controller
                     'order.user',
                     'order.brigadier',
                     'reclamation.order',
+                    'reclamation.productionOrder',
                     'reclamation.user',
                     'reclamation.brigadier',
                 ]), $recipientIds, $isBrigadier);

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

@@ -29,6 +29,7 @@ class FilterController extends Controller
         'common_catalog_items' => 'common_catalog_items',
         'stock_availability' => 'common_catalog_items',
         'stock_orders' => 'stock_orders',
+        'schedule_orders' => 'production_orders',
     ];
 
     const SKIP_YEAR_FILTER = [
@@ -73,6 +74,12 @@ class FilterController extends Controller
         'stock_orders' => [
             'status_name' => 'status',
         ],
+        'schedule_orders' => [
+            'status_name' => 'status',
+            'execution_type_name' => 'execution_type',
+            'delivery_dates' => 'delivery_date',
+            'installation_dates' => 'installation_date',
+        ],
     ];
 
     /**
@@ -88,11 +95,15 @@ class FilterController extends Controller
                 'platform' => 'Площадки',
                 'reclamation' => 'Рекламации',
                 'schedule' => 'График монтажей',
+                'production_order' => 'График заказов',
             ],
             'event' => [
                 'created' => 'Создание',
                 'status_changed' => 'Смена статуса',
                 'schedule_added' => 'Добавлено в график',
+                'delivery_added' => 'Добавлена доставка',
+                'installation_added' => 'Добавлен монтаж',
+                'reclamation_added' => 'Добавлена рекламация',
             ],
         ],
         'spare_part_orders' => [
@@ -129,10 +140,15 @@ class FilterController extends Controller
         'stock_orders' => [
             'status' => StockOrder::STATUS_NAMES,
         ],
+        'schedule_orders' => [
+            'status' => \App\Enums\ProductionOrderStatus::LABELS,
+            'execution_type' => \App\Enums\ProductionOrderExecutionType::LABELS,
+        ],
     ];
 
     private const FIELD_ACCESS_MODULES = [
         'common_catalog_items' => 'common-catalog',
+        'schedule_orders' => 'schedule-orders',
     ];
 
     private const SESSION_KEYS = [
@@ -155,6 +171,12 @@ class FilterController extends Controller
         if ($table === 'stock_orders') {
             return $this->stockOrderFilters($column);
         }
+        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);
 
@@ -308,6 +330,31 @@ class FilterController extends Controller
         return $this->filterValuesResponse($values);
     }
 
+    private function productionOrderPlanningFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'delivery_dates' => DB::table('production_order_deliveries')
+                ->whereNull('deleted_at')
+                ->pluck('delivery_date'),
+            'installation_dates' => DB::table('production_order_installations')
+                ->whereNull('deleted_at')
+                ->pluck('installation_date'),
+            default => [],
+        };
+
+        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)

+ 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', 'Сообщение удалено.');
+    }
+}

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

@@ -0,0 +1,537 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderNotificationEvent;
+use App\Enums\ProductionOrderStatus;
+use App\Http\Requests\SaveProductionOrderRequest;
+use App\Jobs\Export\ExportProductionOrdersJob;
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\ProductionOrder;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\FieldAccessService;
+use App\Services\NotificationService;
+use App\Services\ProductionOrderService;
+use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class ProductionOrderController extends Controller
+{
+    private const INDEX_HEADER = [
+        'id' => 'ID',
+        'order_number' => 'Номер заказа',
+        'customer_name' => 'Заказчик',
+        'object_address' => 'Адрес объекта',
+        'invoice_number' => '№ счёта',
+        'payment_date' => 'Дата оплаты',
+        'supply_working_days' => 'Срок поставки',
+        'contract_shipment_date' => 'Дата отгрузки по договору',
+        'application_shipment_date' => 'Дата отгрузки по заявке',
+        'status_name' => 'Статус',
+        'delivery_dates' => 'Дата доставки',
+        'installation_dates' => 'Дата монтажа',
+        'manager_id' => 'Менеджер',
+        'note' => 'Примечание',
+    ];
+
+    private const SEARCH_FIELDS = [
+        'order_number',
+        'customer_name',
+        'object_address',
+        'invoice_number',
+        'contract_number',
+        'note',
+    ];
+
+    private const FIELD_MAP = [
+        'status_name' => 'status',
+        'execution_type_name' => 'execution_type',
+        'items_count' => 'id',
+        'delivery_dates' => 'delivery_date',
+        'installation_dates' => 'installation_date',
+    ];
+
+    protected array $data = [
+        'active' => 'schedule_orders',
+        'title' => 'График заказов',
+        'id' => 'schedule_orders',
+        'header' => self::INDEX_HEADER,
+        'searchFields' => self::SEARCH_FIELDS,
+    ];
+
+    public function index(Request $request): View
+    {
+        $this->data['header'] = self::INDEX_HEADER;
+        $this->data['searchFields'] = self::SEARCH_FIELDS;
+        unset($this->data['dates'], $this->data['ranges'], $this->data['filters']);
+
+        session(['gp_schedule_orders' => $request->query()]);
+        $nav = $this->startNavigationContext($request);
+        $model = new ProductionOrder;
+
+        $this->data['header'] = array_filter(
+            $this->data['header'],
+            fn (string $field): bool => $request->user()->canViewField(
+                'schedule-orders',
+                self::FIELD_MAP[$field] ?? $field,
+            ),
+            ARRAY_FILTER_USE_KEY,
+        );
+        $this->data['searchFields'] = array_values(array_filter(
+            $this->data['searchFields'],
+            fn (string $field): bool => $request->user()->canViewField('schedule-orders', $field),
+        ));
+        $this->createDateFilters(
+            $model,
+            ...array_values(array_intersect([
+                'payment_date',
+                'contract_shipment_date',
+                'application_shipment_date',
+            ], array_keys($this->data['header']))),
+        );
+        if (isset($this->data['header']['supply_working_days'])) {
+            $this->createRangeFilters($model, 'supply_working_days');
+        }
+        foreach (['status_name', 'execution_type_name'] as $field) {
+            if (isset($this->data['header'][$field])) {
+                $this->data['filters'][$field] = [
+                    'title' => $this->data['header'][$field],
+                    'values' => [],
+                ];
+            }
+        }
+
+        $query = ProductionOrder::query()
+            ->with([
+                'manager',
+                'deliveries:id,production_order_id,delivery_date',
+                'installations:id,production_order_id,installation_date',
+            ])
+            ->withCount('items')
+            ->withMin('deliveries', 'delivery_date')
+            ->withMin('installations', 'installation_date');
+        $this->acceptOrderFilters($query, $request);
+        $this->acceptSearch($query, $request);
+
+        $requestedSort = $request->string('sortBy')->toString();
+        $sortMap = [
+            'status_name' => 'status',
+            'execution_type_name' => 'execution_type',
+            'items_count' => 'items_count',
+            'delivery_dates' => 'deliveries_min_delivery_date',
+            'installation_dates' => 'installations_min_installation_date',
+        ];
+        if (isset($sortMap[$requestedSort])) {
+            $request->merge(['sortBy' => $sortMap[$requestedSort]]);
+        }
+        $this->setSortAndOrderBy($model, $request);
+        if ($requestedSort !== '' && isset($sortMap[$requestedSort])) {
+            $this->data['sortBy'] = $requestedSort;
+            $query->orderBy($sortMap[$requestedSort], $this->data['orderBy']);
+            $query->orderBy('id', $this->data['orderBy']);
+        } else {
+            $this->applyStableSorting($query);
+        }
+
+        $this->data['orders'] = $query->paginate($this->data['per_page'])->withQueryString();
+        $this->data['nav'] = $nav;
+
+        return view('production_orders.index', $this->data);
+    }
+
+    public function create(Request $request, FieldAccessService $fieldAccess): View
+    {
+        return $this->orderView($request, $fieldAccess);
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'filters' => ['nullable', 'array'],
+            'filters.*' => ['nullable', 'string', 'max:1000'],
+            's' => ['nullable', 'string', 'max:500'],
+        ]);
+        $request->merge([
+            'filters' => Arr::only($validated['filters'] ?? [], [
+                'id',
+                'order_number',
+                'customer_name',
+                'object_address',
+                'invoice_number',
+                'payment_date',
+                'payment_date_from',
+                'payment_date_to',
+                'supply_working_days',
+                'supply_working_days_from',
+                'supply_working_days_to',
+                'contract_shipment_date',
+                'contract_shipment_date_from',
+                'contract_shipment_date_to',
+                'application_shipment_date',
+                'application_shipment_date_from',
+                'application_shipment_date_to',
+                'status_name',
+                'execution_type_name',
+                'manager_id',
+                'delivery_dates',
+                'installation_dates',
+                'note',
+            ]),
+            's' => $validated['s'] ?? null,
+        ]);
+        $this->data['dates'] = array_fill_keys([
+            'payment_date',
+            'contract_shipment_date',
+            'application_shipment_date',
+        ], true);
+        $this->data['ranges'] = ['supply_working_days' => true];
+
+        $query = ProductionOrder::query();
+        $this->acceptOrderFilters($query, $request);
+        $this->acceptSearch($query, $request);
+        $orderIds = $query->orderByDesc('created_at')->orderByDesc('id')->pluck('id')->all();
+
+        ExportProductionOrdersJob::dispatch($orderIds, (int) $request->user()->id);
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Задача экспорта графика заказов создана.');
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
+        ]);
+        /** @var UploadedFile $uploadedFile */
+        $uploadedFile = $validated['import_file'];
+        $path = 'production_orders/'.Str::uuid().'.xlsx';
+        Storage::disk('upload')->put($path, $uploadedFile->getContent());
+
+        $import = Import::query()->create([
+            'type' => 'production_orders',
+            'year' => null,
+            'filename' => $path,
+            'status' => 'new',
+            'user_id' => $request->user()->getKey(),
+            'file_path' => $path,
+            'original_filename' => $uploadedFile->getClientOriginalName(),
+        ]);
+        ImportJob::dispatch($import, (int) $request->user()->getKey());
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Задача импорта графика заказов создана.');
+    }
+
+    public function show(
+        Request $request,
+        ProductionOrder $productionOrder,
+        FieldAccessService $fieldAccess,
+    ): View {
+        return $this->orderView(
+            $request,
+            $fieldAccess,
+            $productionOrder->load([
+                'manager',
+                'items.catalogItem.imageFile',
+                'items.passportFile',
+                'deliveries.driver',
+                'installations.brigadier',
+                'activities.user',
+                'documents',
+                'photos',
+                'chatMessages.user',
+                'chatMessages.targetUser',
+                'chatMessages.notifiedUsers',
+                'chatMessages.files',
+            ]),
+        );
+    }
+
+    public function store(
+        SaveProductionOrderRequest $request,
+        FieldAccessService $fieldAccess,
+        ProductionOrderService $service,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $validated = $request->validated();
+        $items = $validated['items'];
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $payload = $fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'schedule-orders',
+            Arr::only($validated, $fields),
+        );
+        $payload['items'] = $items;
+
+        $order = $service->create($payload, $request->user());
+        $notificationService->notifyProductionOrderEvent(
+            $order,
+            ProductionOrderNotificationEvent::Created,
+            $request->user(),
+        );
+
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $order],
+            $this->resolveNavToken($request),
+        ))->with('success', 'Заказ создан.');
+    }
+
+    public function update(
+        SaveProductionOrderRequest $request,
+        ProductionOrder $productionOrder,
+        FieldAccessService $fieldAccess,
+        ProductionOrderService $service,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $validated = $request->validated();
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $payload = $fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'schedule-orders',
+            Arr::only($validated, $fields),
+        );
+        $canManageItems = $request->user()->hasPermission('schedule-orders.items.manage');
+        $editableItemFields = array_values(array_filter(
+            ['factory_number', 'manufacture_date'],
+            fn (string $field): bool => $request->user()->canUpdateField('schedule-orders', $field),
+        ));
+        if (($canManageItems || $editableItemFields !== []) && isset($validated['items'])) {
+            $payload['items'] = $validated['items'];
+        }
+
+        $previousStatus = $productionOrder->status;
+        $updatedOrder = $service->update(
+            $productionOrder,
+            $payload,
+            $request->user(),
+            $canManageItems,
+            $editableItemFields,
+        );
+        if ($updatedOrder->status !== $previousStatus) {
+            $notificationService->notifyProductionOrderEvent(
+                $updatedOrder,
+                ProductionOrderNotificationEvent::StatusChanged,
+                $request->user(),
+            );
+        }
+
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $productionOrder],
+            $this->resolveNavToken($request),
+        ))->with('success', 'Заказ обновлён.');
+    }
+
+    public function destroy(Request $request, ProductionOrder $productionOrder): RedirectResponse
+    {
+        $productionOrder->delete();
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Заказ удалён.');
+    }
+
+    public function searchCatalogItems(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'q' => ['nullable', 'string', 'max:200'],
+        ]);
+        $search = trim((string) ($validated['q'] ?? ''));
+        if (mb_strlen($search) < 2) {
+            return response()->json([]);
+        }
+
+        $items = CommonCatalogItem::query()
+            ->with('imageFile')
+            ->where(function ($query) use ($search): void {
+                $query
+                    ->where('article', 'like', "%{$search}%")
+                    ->orWhere('calculator_name', 'like', "%{$search}%")
+                    ->orWhere('print_name', 'like', "%{$search}%");
+            })
+            ->orderBy('article')
+            ->limit(50)
+            ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
+            ->map(static fn (CommonCatalogItem $item): array => [
+                'id' => $item->id,
+                'label' => $item->article.' — '.($item->calculator_name ?: $item->print_name ?: 'Без наименования'),
+                'image' => $item->imageFile?->thumbnail_link,
+                'image_full' => $item->imageFile?->link,
+            ])
+            ->values();
+
+        return response()->json($items);
+    }
+
+    private function orderView(
+        Request $request,
+        FieldAccessService $fieldAccess,
+        ?ProductionOrder $order = null,
+    ): View {
+        $nav = $this->resolveNavToken($request);
+        $this->rememberNavigation($request, $nav);
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $catalogItemIds = collect($order?->items ?? [])
+            ->pluck('common_catalog_item_id')
+            ->merge(collect($request->old('items', []))->pluck('common_catalog_item_id'))
+            ->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',
+            'title' => $order ? 'Заказ '.$order->order_number : 'Новый заказ',
+            'order' => $order,
+            'nav' => $nav,
+            'back_url' => $this->navigationBackUrl(
+                $request,
+                $nav,
+                route('schedule.orders', session('gp_schedule_orders')),
+            ),
+            'readableFields' => array_fill_keys(
+                $fieldAccess->filterReadableFields($request->user(), 'schedule-orders', $fields),
+                true,
+            ),
+            'writableFields' => collect($fields)->mapWithKeys(fn (string $field): array => [
+                $field => $request->user()->canUpdateField('schedule-orders', $field),
+            ])->all(),
+            '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()
+                ->where('role', Role::MANAGER)
+                ->orderBy('name')
+                ->pluck('name', 'id'),
+            'drivers' => User::query()
+                ->whereIn('role', [Role::DRIVER, Role::BRIGADIER])
+                ->orderBy('name')
+                ->pluck('name', 'id'),
+            'brigadiers' => User::query()
+                ->where('role', Role::BRIGADIER)
+                ->orderBy('name')
+                ->pluck('name', 'id'),
+            'catalogItems' => CommonCatalogItem::query()
+                ->with('imageFile')
+                ->whereKey($catalogItemIds)
+                ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
+                ->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,
+        ]);
+    }
+
+    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'],
+        ];
+
+        foreach ($relationFilters as $filterName => [$relation, $column]) {
+            $value = trim((string) ($filters[$filterName] ?? ''));
+            if ($value === '') {
+                continue;
+            }
+
+            $values = explode('||', $value);
+            $dates = array_values(array_filter(
+                $values,
+                static fn (string $date): bool => $date !== '-пусто-',
+            ));
+            $includeEmpty = in_array('-пусто-', $values, true);
+
+            $query->where(function ($relationQuery) use ($relation, $column, $dates, $includeEmpty): void {
+                if ($dates !== []) {
+                    $relationQuery->whereHas(
+                        $relation,
+                        static fn ($dateQuery) => $dateQuery->whereIn($column, $dates),
+                    );
+                }
+                if ($includeEmpty) {
+                    $dates !== []
+                        ? $relationQuery->orWhereDoesntHave($relation)
+                        : $relationQuery->whereDoesntHave($relation);
+                }
+            });
+        }
+
+        $request->merge(['filters' => Arr::except($filters, [
+            'manager_id',
+            ...array_keys($relationFilters),
+        ])]);
+        $this->acceptFilters($query, $request);
+        $request->merge(['filters' => $filters]);
+    }
+}

+ 115 - 0
app/Http/Controllers/ProductionOrderDeliveryController.php

@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Enums\ProductionOrderNotificationEvent;
+use App\Http\Requests\SaveProductionOrderDeliveryRequest;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Services\ProductionOrderPlanningService;
+use App\Services\NotificationService;
+use Carbon\CarbonImmutable;
+use Carbon\CarbonInterface;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class ProductionOrderDeliveryController extends Controller
+{
+    public function index(Request $request): View
+    {
+        $validated = $request->validate([
+            'week' => ['nullable', 'date'],
+        ]);
+        $weekStart = CarbonImmutable::parse($validated['week'] ?? now())
+            ->startOfWeek(CarbonInterface::MONDAY);
+        $weekEnd = $weekStart->endOfWeek(CarbonInterface::SUNDAY);
+        $days = collect(range(0, 6))->mapWithKeys(static function (int $offset) use ($weekStart): array {
+            $date = $weekStart->addDays($offset);
+
+            return [$date->toDateString() => $date];
+        });
+        $deliveries = ProductionOrderDelivery::query()
+            ->whereBetween('delivery_date', [$weekStart->toDateString(), $weekEnd->toDateString()])
+            ->with(['productionOrder', 'driver'])
+            ->orderBy('delivery_date')
+            ->orderBy('delivery_time')
+            ->orderBy('id')
+            ->get()
+            ->groupBy(fn (ProductionOrderDelivery $delivery): string => $delivery->delivery_date->toDateString());
+        $nav = $this->startNavigationContext($request);
+
+        return view('production_order_deliveries.index', [
+            'active' => 'schedule_deliveries',
+            'title' => 'График доставок',
+            'days' => $days,
+            'deliveries' => $deliveries,
+            'weekStart' => $weekStart,
+            'weekEnd' => $weekEnd,
+            'previousWeek' => $weekStart->subWeek()->toDateString(),
+            'nextWeek' => $weekStart->addWeek()->toDateString(),
+            'nav' => $nav,
+        ]);
+    }
+
+    public function store(
+        SaveProductionOrderDeliveryRequest $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderPlanningService $service,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $delivery = $service->createDelivery($productionOrder, $request->validated(), $request->user());
+        $notificationService->notifyProductionOrderEvent(
+            $productionOrder,
+            ProductionOrderNotificationEvent::DeliveryAdded,
+            $request->user(),
+            delivery: $delivery->load('driver'),
+        );
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Доставка добавлена.');
+    }
+
+    public function update(
+        SaveProductionOrderDeliveryRequest $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderDelivery $delivery,
+        ProductionOrderPlanningService $service,
+    ): RedirectResponse {
+        $this->assertBelongsToOrder($productionOrder, $delivery);
+        $service->updateDelivery($delivery, $request->validated(), $request->user());
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Доставка обновлена.');
+    }
+
+    public function destroy(
+        Request $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderDelivery $delivery,
+        ProductionOrderPlanningService $service,
+    ): RedirectResponse {
+        $this->assertBelongsToOrder($productionOrder, $delivery);
+        $service->deleteDelivery($delivery);
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Доставка удалена.');
+    }
+
+    private function assertBelongsToOrder(
+        ProductionOrder $order,
+        ProductionOrderDelivery $delivery,
+    ): void {
+        abort_unless($delivery->production_order_id === $order->id, 404);
+    }
+
+    private function redirectToOrder(Request $request, ProductionOrder $order): RedirectResponse
+    {
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $order],
+            $this->resolveNavToken($request),
+        ));
+    }
+}

+ 126 - 0
app/Http/Controllers/ProductionOrderDocumentController.php

@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Jobs\GenerateProductionOrderFileJob;
+use App\Models\File;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\Role;
+use App\Services\ProductionOrderDocumentService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class ProductionOrderDocumentController extends Controller
+{
+    public function exportItems(Request $request, ProductionOrder $productionOrder): RedirectResponse
+    {
+        $this->dispatch($request, $productionOrder, ProductionOrderDocumentService::TYPE_ITEMS);
+
+        return back()->with('success', 'Задача экспорта МАФ создана.');
+    }
+
+    public function deliveryRequest(
+        Request $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderDelivery $delivery,
+    ): RedirectResponse {
+        $this->assertDeliveryBelongsToOrder($productionOrder, $delivery);
+        $this->dispatch(
+            $request,
+            $productionOrder,
+            ProductionOrderDocumentService::TYPE_DELIVERY,
+            $delivery->id,
+        );
+
+        return back()->with('success', 'Задача формирования заявки на доставку создана.');
+    }
+
+    public function installationPack(
+        Request $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderInstallation $installation,
+    ): RedirectResponse {
+        $this->assertInstallationBelongsToOrder($productionOrder, $installation);
+        $this->dispatch(
+            $request,
+            $productionOrder,
+            ProductionOrderDocumentService::TYPE_INSTALLATION,
+            $installation->id,
+        );
+
+        return back()->with('success', 'Задача формирования монтажного пакета создана.');
+    }
+
+    public function technicalDocuments(Request $request, ProductionOrder $productionOrder): RedirectResponse
+    {
+        $validated = $request->validate([
+            'item_ids' => ['required', 'array', 'min:1'],
+            'item_ids.*' => ['required', 'integer', 'distinct'],
+        ]);
+        $itemIds = collect($validated['item_ids'])->map(static fn ($id): int => (int) $id)->values();
+        abort_unless(
+            $productionOrder->items()->whereKey($itemIds)->count() === $itemIds->count(),
+            404,
+        );
+        $this->dispatch(
+            $request,
+            $productionOrder,
+            ProductionOrderDocumentService::TYPE_TECHNICAL_DOCUMENTS,
+            itemIds: $itemIds->all(),
+        );
+
+        return back()->with('success', 'Задача архивации техдокументации создана.');
+    }
+
+    public function download(Request $request, File $file): StreamedResponse
+    {
+        abort_unless($file->is_generated, 404);
+        abort_unless(Str::startsWith((string) $file->path, 'generated/production-orders/'), 404);
+        abort_unless(
+            (int) $file->user_id === (int) $request->user()->id
+                || $request->user()->hasRole(Role::ADMIN),
+            403,
+        );
+        abort_unless(Storage::disk('local')->exists((string) $file->path), 404);
+
+        return Storage::disk('local')->download((string) $file->path, (string) $file->original_name);
+    }
+
+    /** @param list<int> $itemIds */
+    private function dispatch(
+        Request $request,
+        ProductionOrder $order,
+        string $type,
+        ?int $relatedId = null,
+        array $itemIds = [],
+    ): void {
+        GenerateProductionOrderFileJob::dispatch(
+            $type,
+            $order->id,
+            (int) $request->user()->id,
+            $relatedId,
+            $itemIds,
+        );
+    }
+
+    private function assertDeliveryBelongsToOrder(
+        ProductionOrder $order,
+        ProductionOrderDelivery $delivery,
+    ): void {
+        abort_unless((int) $delivery->production_order_id === (int) $order->id, 404);
+    }
+
+    private function assertInstallationBelongsToOrder(
+        ProductionOrder $order,
+        ProductionOrderInstallation $installation,
+    ): void {
+        abort_unless((int) $installation->production_order_id === (int) $order->id, 404);
+    }
+}

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

@@ -0,0 +1,145 @@
+<?php
+
+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;
+use App\Services\FileService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Throwable;
+
+class ProductionOrderFileController extends Controller
+{
+    public function storeDocuments(
+        UploadProductionOrderDocumentsRequest $request,
+        ProductionOrder $productionOrder,
+        FileService $fileService,
+    ): RedirectResponse {
+        $validated = $request->validated();
+
+        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(
+        UploadProductionOrderPhotosRequest $request,
+        ProductionOrder $productionOrder,
+        FileService $fileService,
+    ): RedirectResponse {
+        $validated = $request->validated();
+
+        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);
+    }
+}

+ 76 - 0
app/Http/Controllers/ProductionOrderInstallationController.php

@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Enums\ProductionOrderNotificationEvent;
+use App\Http\Requests\SaveProductionOrderInstallationRequest;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderInstallation;
+use App\Services\ProductionOrderPlanningService;
+use App\Services\NotificationService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class ProductionOrderInstallationController extends Controller
+{
+    public function store(
+        SaveProductionOrderInstallationRequest $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderPlanningService $service,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $installation = $service->createInstallation($productionOrder, $request->validated(), $request->user());
+        $notificationService->notifyProductionOrderEvent(
+            $productionOrder,
+            ProductionOrderNotificationEvent::InstallationAdded,
+            $request->user(),
+            installation: $installation->load('brigadier'),
+        );
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Монтаж добавлен.');
+    }
+
+    public function update(
+        SaveProductionOrderInstallationRequest $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderInstallation $installation,
+        ProductionOrderPlanningService $service,
+    ): RedirectResponse {
+        $this->assertBelongsToOrder($productionOrder, $installation);
+        $service->updateInstallation($installation, $request->validated(), $request->user());
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Монтаж обновлён.');
+    }
+
+    public function destroy(
+        Request $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderInstallation $installation,
+        ProductionOrderPlanningService $service,
+    ): RedirectResponse {
+        $this->assertBelongsToOrder($productionOrder, $installation);
+        $service->deleteInstallation($installation);
+
+        return $this->redirectToOrder($request, $productionOrder)
+            ->with('success', 'Монтаж удалён.');
+    }
+
+    private function assertBelongsToOrder(
+        ProductionOrder $order,
+        ProductionOrderInstallation $installation,
+    ): void {
+        abort_unless($installation->production_order_id === $order->id, 404);
+    }
+
+    private function redirectToOrder(Request $request, ProductionOrder $order): RedirectResponse
+    {
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $order],
+            $this->resolveNavToken($request),
+        ));
+    }
+}

+ 50 - 0
app/Http/Controllers/ProductionOrderReclamationController.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Enums\ProductionOrderNotificationEvent;
+use App\Http\Requests\CreateProductionOrderReclamationRequest;
+use App\Models\ProductionOrder;
+use App\Models\Reclamation;
+use App\Models\ReclamationType;
+use App\Services\NotificationService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class ProductionOrderReclamationController extends Controller
+{
+    public function store(
+        CreateProductionOrderReclamationRequest $request,
+        ProductionOrder $productionOrder,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $reclamation = DB::transaction(function () use ($request, $productionOrder): Reclamation {
+            $reclamation = $productionOrder->reclamations()->create([
+                'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_OTHER),
+                'user_id' => $productionOrder->manager_id,
+                'status_id' => Reclamation::STATUS_NEW,
+                'create_date' => Carbon::now(),
+                'finish_date' => Carbon::now()->addDays(30),
+            ]);
+            $reclamation->productionOrderItems()->attach($request->validated('item_ids'));
+
+            return $reclamation;
+        });
+
+        $reclamation->load(['productionOrder', 'status']);
+        $notificationService->notifyReclamationCreated($reclamation, $request->user());
+        $notificationService->notifyProductionOrderEvent(
+            $productionOrder,
+            ProductionOrderNotificationEvent::ReclamationAdded,
+            $request->user(),
+        );
+
+        return redirect()->route('reclamations.show', $this->withNav(
+            ['reclamation' => $reclamation],
+            $this->resolveNavToken($request),
+        ));
+    }
+}

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

@@ -159,6 +159,8 @@ class ReclamationController extends Controller
             ->pluck('name', 'id');
         $this->data['reclamation'] = $reclamation->load([
             'order',
+            'productionOrder',
+            'productionOrderItems.catalogItem.imageFile',
             'reclamationType',
             'chatMessages.user',
             'chatMessages.targetUser',
@@ -202,7 +204,10 @@ class ReclamationController extends Controller
         $reclamation->update($data);
 
         if ((int) $oldStatusId !== (int) $reclamation->status_id) {
-            $notificationService->notifyReclamationStatusChanged($reclamation->fresh(['order', 'status']), auth()->user());
+            $notificationService->notifyReclamationStatusChanged(
+                $reclamation->fresh(['order', 'productionOrder', 'status']),
+                auth()->user(),
+            );
         }
 
         $nav = $this->resolveNavToken($request);
@@ -223,7 +228,10 @@ class ReclamationController extends Controller
         ]);
 
         $reclamation->update(['status_id' => $validated['status_id']]);
-        $notificationService->notifyReclamationStatusChanged($reclamation->fresh(['order', 'status']), auth()->user());
+        $notificationService->notifyReclamationStatusChanged(
+            $reclamation->fresh(['order', 'productionOrder', 'status']),
+            auth()->user(),
+        );
 
         return response()->noContent();
     }
@@ -569,6 +577,7 @@ class ReclamationController extends Controller
 
     public function generateReclamationPack(Request $request, Reclamation $reclamation)
     {
+        abort_unless($reclamation->isDkr(), 403);
         GenerateReclamationPack::dispatch($reclamation, auth()->user()->id);
         return $this->redirectToReclamationShow($request, $reclamation)
             ->with(['success' => 'Задача генерации документов создана!']);

+ 17 - 5
app/Http/Controllers/ScheduleController.php

@@ -69,7 +69,7 @@ class ScheduleController extends Controller
         $result = Schedule::query()
             ->whereBetween('installation_date', [$weekDates['mon'], $weekDates['sun']])
             ->when($scheduleScope === 'brigadier', fn (Builder $query) => $query->where('brigadier_id', $request->user()->id))
-            ->with(['brigadier', 'district', 'area'])
+            ->with(['brigadier', 'district', 'area', 'productionOrderInstallation.productionOrder'])
             ->get();
         $result = $this->filterSchedulesForCurrentUser($result);
         $this->data['scheduleStatusMap'] = $this->buildScheduleStatusMap($result);
@@ -336,10 +336,9 @@ class ScheduleController extends Controller
         $validated = $request->validated();
 
         if(isset($validated['id'])) {
-             Schedule::query()
-                ->where('id', $validated['id'])
-                ->update($validated);
-             $schedule = Schedule::query()->find($validated['id']);
+            $schedule = Schedule::query()->findOrFail($validated['id']);
+            abort_if($schedule->production_order_installation_id, 403);
+            $schedule->update($validated);
         } else {
             $schedule = Schedule::query()
                 ->create([
@@ -365,6 +364,7 @@ class ScheduleController extends Controller
 
     public function delete(Schedule $schedule)
     {
+        abort_if($schedule->production_order_installation_id, 403);
         $schedule->delete();
         return redirect()->back();
     }
@@ -460,6 +460,18 @@ class ScheduleController extends Controller
                 continue;
             }
 
+            if (
+                $schedule->source === Schedule::SOURCE_PRODUCTION_ORDER
+                && $schedule->productionOrderInstallation?->productionOrder
+            ) {
+                $productionOrder = $schedule->productionOrderInstallation->productionOrder;
+                $statusMap[$schedule->id] = [
+                    'name' => $productionOrder->statusLabel(),
+                    'color' => $productionOrder->status->color(),
+                ];
+                continue;
+            }
+
             if ($schedule->source === 'Рекламации') {
                 $reclamationId = $this->extractReclamationId((string)$schedule->address_code);
                 if ($reclamationId) {

+ 10 - 0
app/Http/Controllers/UserController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Enums\ProductionOrderNotificationEvent;
 use App\Http\Requests\User\DeleteUser;
 use App\Http\Requests\User\StoreProfile;
 use App\Http\Requests\User\StoreUser;
@@ -285,6 +286,7 @@ class UserController extends Controller
         $this->data['reclamationStatusOptions'] = Reclamation::STATUS_NAMES;
         $this->data['reclamationStatusColors'] = ReclamationStatus::STATUS_COLOR;
         $this->data['scheduleSourceOptions'] = ['platform' => 'Площадки', 'reclamation' => 'Рекламации'];
+        $this->data['productionOrderEventOptions'] = ProductionOrderNotificationEvent::options();
         $this->data['chatSourceOptions'] = ['platform' => 'Площадки', 'reclamation' => 'Рекламации'];
         $this->data['notificationChannels'] = ['browser' => 'Браузер', 'push' => 'Push', 'email' => 'Email'];
 
@@ -365,12 +367,14 @@ class UserController extends Controller
             'order_settings' => [],
             'reclamation_settings' => [],
             'schedule_settings' => [],
+            'production_order_settings' => [],
             'chat_settings' => [],
         ];
 
         $orderStatuses = array_keys(Order::STATUS_NAMES);
         $reclamationStatuses = array_keys(Reclamation::STATUS_NAMES);
         $scheduleSources = ['platform', 'reclamation'];
+        $productionOrderEvents = array_keys(ProductionOrderNotificationEvent::options());
         $chatSources = ['platform', 'reclamation'];
         $channels = ['browser', 'push', 'email'];
 
@@ -392,6 +396,12 @@ class UserController extends Controller
             }
         }
 
+        foreach ($productionOrderEvents as $event) {
+            foreach ($channels as $channel) {
+                $settings['production_order_settings'][$event][$channel] = isset($input['production_orders'][$event][$channel]);
+            }
+        }
+
         foreach ($chatSources as $source) {
             foreach ($channels as $channel) {
                 $settings['chat_settings'][$source][$channel] = isset($input['chat'][$source][$channel]);

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

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class CreateProductionOrderReclamationRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return $this->user()?->hasPermission('reclamations.create') === true
+            && $this->user()?->hasPermission('schedule-orders.view') === true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        $productionOrderId = (int) $this->route('productionOrder')?->getKey();
+
+        return [
+            'item_ids' => ['required', 'array', 'min:1'],
+            'item_ids.*' => [
+                'required',
+                'integer',
+                'distinct',
+                Rule::exists('production_order_items', 'id')->where(
+                    static fn ($query) => $query
+                        ->where('production_order_id', $productionOrderId)
+                        ->whereNull('deleted_at'),
+                ),
+            ],
+            'nav' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+}

+ 65 - 0
app/Http/Requests/SaveProductionOrderDeliveryRequest.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Models\Role;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class SaveProductionOrderDeliveryRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        return [
+            'delivery_date' => ['required', 'date'],
+            'delivery_time' => ['nullable', 'date_format:H:i'],
+            'driver_id' => [
+                'required',
+                'integer',
+                Rule::exists('users', 'id')->where(
+                    fn (Builder $query): Builder => $query
+                        ->whereIn('role', [Role::DRIVER, Role::BRIGADIER])
+                        ->whereNull('deleted_at'),
+                ),
+            ],
+            'carrier' => ['nullable', 'string', 'max:255'],
+            'request_number' => ['nullable', 'string', 'max:100'],
+            'request_date' => ['nullable', 'date'],
+            'carrier_note' => ['nullable', 'string', 'max:5000'],
+            'contact_position' => ['nullable', 'string', 'max:255'],
+            'contact_name' => ['nullable', 'string', 'max:255'],
+            'contact_phone' => ['nullable', 'string', 'max:100'],
+            'desired_time' => ['nullable', 'date_format:H:i'],
+            'access_system' => ['nullable', 'string', 'max:5000'],
+            'unloading_place' => ['nullable', 'string', 'max:5000'],
+            'advance_notice' => ['nullable', 'string', 'max:5000'],
+            'document_signing_method' => ['nullable', 'string', 'max:5000'],
+            'has_passports_and_certificates' => ['required', 'boolean'],
+            'note' => ['nullable', 'string', 'max:10000'],
+            'nav' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function attributes(): array
+    {
+        return [
+            'delivery_date' => 'дата доставки',
+            'delivery_time' => 'время доставки',
+            'driver_id' => 'водитель',
+            'request_number' => 'номер заявки',
+            'request_date' => 'дата заявки',
+            'desired_time' => 'желаемое время доставки',
+            'has_passports_and_certificates' => 'наличие паспортов и сертификатов',
+        ];
+    }
+}

+ 48 - 0
app/Http/Requests/SaveProductionOrderInstallationRequest.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Models\Role;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class SaveProductionOrderInstallationRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        return [
+            'installation_date' => ['required', 'date'],
+            'installation_days' => ['required', 'integer', 'min:1', 'max:365'],
+            'brigadier_id' => [
+                'required',
+                'integer',
+                Rule::exists('users', 'id')->where(
+                    fn (Builder $query): Builder => $query
+                        ->where('role', Role::BRIGADIER)
+                        ->whereNull('deleted_at'),
+                ),
+            ],
+            'note' => ['nullable', 'string', 'max:10000'],
+            'nav' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function attributes(): array
+    {
+        return [
+            'installation_date' => 'дата монтажа',
+            'installation_days' => 'количество дней монтажа',
+            'brigadier_id' => 'бригадир',
+        ];
+    }
+}

+ 101 - 0
app/Http/Requests/SaveProductionOrderRequest.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\Role;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class SaveProductionOrderRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        /** @var ProductionOrder|null $productionOrder */
+        $productionOrder = $this->route('productionOrder');
+        $canManageItems = $this->user()?->hasPermission('schedule-orders.items.manage') ?? false;
+        $required = $productionOrder ? 'sometimes' : 'required';
+        $orderYear = $this->has('order_year')
+            ? $this->integer('order_year')
+            : $productionOrder?->order_year;
+
+        return [
+            'order_number' => [
+                $required,
+                'required',
+                'string',
+                'max:100',
+                Rule::unique('production_orders', 'order_number')
+                    ->where('order_year', $orderYear)
+                    ->ignore($productionOrder),
+            ],
+            'order_year' => [$required, 'required', 'integer', 'min:2000', 'max:2100'],
+            'customer_name' => [$required, 'required', 'string', 'max:500'],
+            'object_address' => [$required, 'required', 'string', 'max:5000'],
+            'invoice_number' => [$required, 'required', 'string', 'max:100'],
+            'invoice_date' => ['nullable', 'date'],
+            'contract_number' => ['nullable', 'string', 'max:100'],
+            'contract_date' => ['nullable', 'date'],
+            'payment_date' => [$required, 'required', 'date'],
+            'supply_working_days' => [$required, 'required', 'integer', 'min:0', 'max:2000'],
+            'application_shipment_date' => ['nullable', 'date'],
+            'status' => [$required, 'required', Rule::enum(ProductionOrderStatus::class)],
+            'execution_type' => [$required, 'required', Rule::enum(ProductionOrderExecutionType::class)],
+            'manager_id' => [
+                $required,
+                'required',
+                'integer',
+                Rule::exists('users', 'id')->where('role', Role::MANAGER),
+            ],
+            'note' => ['nullable', 'string', 'max:10000'],
+            'items' => [$canManageItems ? 'required' : 'sometimes', 'array', 'min:1'],
+            'items.*.id' => [
+                'nullable',
+                'integer',
+                Rule::exists('production_order_items', 'id')->where(
+                    'production_order_id',
+                    $productionOrder?->id,
+                ),
+            ],
+            'items.*.common_catalog_item_id' => [
+                'required',
+                'integer',
+                Rule::exists('common_catalog_items', 'id')->whereNull('deleted_at'),
+            ],
+            'items.*.order_item_number' => ['required', 'string', 'max:100'],
+            'items.*.factory_number' => ['nullable', 'string', 'max:100'],
+            'items.*.manufacture_date' => ['nullable', 'date'],
+            'nav' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function attributes(): array
+    {
+        return [
+            'order_number' => 'номер заказа',
+            'order_year' => 'год заказа',
+            'customer_name' => 'заказчик',
+            'object_address' => 'адрес объекта',
+            'invoice_number' => 'номер счёта',
+            'payment_date' => 'дата оплаты',
+            'supply_working_days' => 'срок поставки',
+            'status' => 'статус',
+            'execution_type' => 'тип исполнения',
+            'manager_id' => 'менеджер',
+            'items' => 'оборудование',
+            'items.*.common_catalog_item_id' => 'позиция общего каталога',
+            'items.*.order_item_number' => 'номер заказа МАФ',
+        ];
+    }
+}

+ 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 МБ.',
+        ];
+    }
+}

+ 54 - 0
app/Jobs/Export/ExportProductionOrdersJob.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportProductionOrdersService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class ExportProductionOrdersJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $timeout = 600;
+
+    public int $tries = 2;
+
+    /** @param list<int> $orderIds */
+    public function __construct(
+        public readonly array $orderIds,
+        public readonly int $userId,
+    ) {}
+
+    public function handle(ExportProductionOrdersService $service): void
+    {
+        try {
+            $file = $service->handle($this->orderIds, $this->userId);
+
+            event(new SendWebSocketMessageEvent(
+                'Экспорт графика заказов завершён!',
+                $this->userId,
+                ['link' => $file->link],
+            ));
+        } catch (Throwable $exception) {
+            Log::error('Production orders export failed.', [
+                'user_id' => $this->userId,
+                'orders_count' => count($this->orderIds),
+                'error' => $exception->getMessage(),
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                'Ошибка экспорта графика заказов: '.$exception->getMessage(),
+                $this->userId,
+                ['error' => $exception->getMessage()],
+            ));
+
+            throw $exception;
+        }
+    }
+}

+ 64 - 0
app/Jobs/GenerateProductionOrderFileJob.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Jobs;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\ProductionOrderDocumentService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class GenerateProductionOrderFileJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $timeout = 600;
+
+    public int $tries = 2;
+
+    /** @param list<int> $itemIds */
+    public function __construct(
+        private readonly string $type,
+        private readonly int $orderId,
+        private readonly int $userId,
+        private readonly ?int $relatedId = null,
+        private readonly array $itemIds = [],
+    ) {}
+
+    public function handle(ProductionOrderDocumentService $service): void
+    {
+        try {
+            $file = $service->generate(
+                $this->type,
+                $this->orderId,
+                $this->userId,
+                $this->relatedId,
+                $this->itemIds,
+            );
+
+            event(new SendWebSocketMessageEvent(
+                'Файл по заказу готов!',
+                $this->userId,
+                ['link' => $file->link],
+            ));
+        } catch (Throwable $exception) {
+            Log::error('Production order file generation failed.', [
+                'type' => $this->type,
+                'order_id' => $this->orderId,
+                'user_id' => $this->userId,
+                'error' => $exception->getMessage(),
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                'Ошибка формирования файла: '.$exception->getMessage(),
+                $this->userId,
+                ['error' => $exception->getMessage()],
+            ));
+
+            throw $exception;
+        }
+    }
+}

+ 9 - 0
app/Jobs/Import/ImportJob.php

@@ -1,11 +1,14 @@
 <?php
 
+declare(strict_types=1);
+
 namespace App\Jobs\Import;
 
 use App\Events\SendWebSocketMessageEvent;
 use App\Models\Import;
 use App\Services\Import\ImportCommonCatalogService;
 use App\Services\Import\ImportMafOrdersService;
+use App\Services\Import\ImportProductionOrdersService;
 use App\Services\Import\ImportSparePartOrdersService;
 use App\Services\Import\ImportStockOrdersService;
 use App\Services\ImportCatalogService;
@@ -58,6 +61,12 @@ class ImportJob implements ShouldQueue
                         'userId' => $this->userId,
                     ])->handle();
                     break;
+                case 'production_orders':
+                    app(ImportProductionOrdersService::class, [
+                        'import' => $this->import,
+                        'userId' => $this->userId,
+                    ])->handle();
+                    break;
                 case 'spare_part_orders':
                     $this->handleSparePartOrdersImport();
                     break;

+ 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',
+        };
+    }
+
 }

+ 1 - 0
app/Models/Import.php

@@ -25,6 +25,7 @@ class Import extends Model
         'mafs' => 'МАФы',
         'catalog' => 'Каталог',
         'common_catalog' => 'Каталог общий',
+        'production_orders' => 'График заказов',
         'stock_orders' => 'Заказы склада',
         'spare_part_orders' => 'Заказы запчастей',
         'maf_orders' => 'Заказы МАФ',

+ 29 - 0
app/Models/ProductionCalendarDay.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionCalendarDayFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class ProductionCalendarDay extends Model
+{
+    /** @use HasFactory<ProductionCalendarDayFactory> */
+    use HasFactory;
+
+    protected $fillable = [
+        'date',
+        'is_working_day',
+        'name',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'date' => 'immutable_date',
+            'is_working_day' => 'boolean',
+        ];
+    }
+}

+ 169 - 0
app/Models/ProductionOrder.php

@@ -0,0 +1,169 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+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;
+
+class ProductionOrder extends Model
+{
+    /** @use HasFactory<ProductionOrderFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    public const DEFAULT_SORT_BY = 'created_at';
+
+    public const DEFAULT_ORDER_BY = 'desc';
+
+    protected $fillable = [
+        'external_id',
+        'source',
+        'order_number',
+        'order_year',
+        'customer_name',
+        'object_address',
+        'invoice_number',
+        'invoice_date',
+        'contract_number',
+        'contract_date',
+        'payment_date',
+        'supply_working_days',
+        'contract_shipment_date',
+        'application_shipment_date',
+        'status',
+        'execution_type',
+        'manager_id',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'source' => ProductionOrderSource::class,
+            'order_year' => 'integer',
+            'invoice_date' => 'immutable_date',
+            'contract_date' => 'immutable_date',
+            'payment_date' => 'immutable_date',
+            'supply_working_days' => 'integer',
+            'contract_shipment_date' => 'immutable_date',
+            'application_shipment_date' => 'immutable_date',
+            'status' => ProductionOrderStatus::class,
+            'execution_type' => ProductionOrderExecutionType::class,
+            'manager_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function manager(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'manager_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function updater(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'updated_by');
+    }
+
+    public function items(): HasMany
+    {
+        return $this->hasMany(ProductionOrderItem::class)->orderBy('sort')->orderBy('id');
+    }
+
+    public function deliveries(): HasMany
+    {
+        return $this->hasMany(ProductionOrderDelivery::class)->orderBy('delivery_date')->orderBy('id');
+    }
+
+    public function installations(): HasMany
+    {
+        return $this->hasMany(ProductionOrderInstallation::class)->orderBy('installation_date')->orderBy('id');
+    }
+
+    public function activities(): HasMany
+    {
+        return $this->hasMany(ProductionOrderActivity::class)->latest('created_at')->latest('id');
+    }
+
+    public function reclamations(): HasMany
+    {
+        return $this->hasMany(Reclamation::class);
+    }
+
+    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();
+    }
+
+    public function executionTypeLabel(): string
+    {
+        return $this->execution_type->label();
+    }
+
+    public function getStatusNameAttribute(): string
+    {
+        return $this->statusLabel();
+    }
+
+    public function getExecutionTypeNameAttribute(): string
+    {
+        return $this->executionTypeLabel();
+    }
+
+    public function getDeliveryDatesAttribute(): string
+    {
+        return $this->deliveries
+            ->pluck('delivery_date')
+            ->filter()
+            ->map(static fn ($date): string => $date->format('d.m.Y'))
+            ->implode('<br>');
+    }
+
+    public function getInstallationDatesAttribute(): string
+    {
+        return $this->installations
+            ->pluck('installation_date')
+            ->filter()
+            ->map(static fn ($date): string => $date->format('d.m.Y'))
+            ->implode('<br>');
+    }
+
+    public function isShipmentOverdue(): bool
+    {
+        return $this->contract_shipment_date?->isPast()
+            && ! in_array($this->status, [ProductionOrderStatus::InStock, ProductionOrderStatus::Closed], true);
+    }
+}

+ 60 - 0
app/Models/ProductionOrderActivity.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Enums\ProductionOrderActivityType;
+use Database\Factories\ProductionOrderActivityFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ProductionOrderActivity extends Model
+{
+    /** @use HasFactory<ProductionOrderActivityFactory> */
+    use HasFactory;
+
+    public const UPDATED_AT = null;
+
+    protected $fillable = [
+        'production_order_id',
+        'type',
+        'user_id',
+        'delivery_id',
+        'installation_id',
+        'message',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'type' => ProductionOrderActivityType::class,
+            'user_id' => 'integer',
+            'delivery_id' => 'integer',
+            'installation_id' => 'integer',
+            'created_at' => 'immutable_datetime',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function delivery(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrderDelivery::class);
+    }
+
+    public function installation(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrderInstallation::class);
+    }
+}

+ 65 - 0
app/Models/ProductionOrderDelivery.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderDeliveryFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrderDelivery extends Model
+{
+    /** @use HasFactory<ProductionOrderDeliveryFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'delivery_date',
+        'delivery_time',
+        'driver_id',
+        'carrier',
+        'request_number',
+        'request_date',
+        'carrier_note',
+        'contact_position',
+        'contact_name',
+        'contact_phone',
+        'desired_time',
+        'access_system',
+        'unloading_place',
+        'advance_notice',
+        'document_signing_method',
+        'has_passports_and_certificates',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'delivery_date' => 'immutable_date',
+            'request_date' => 'immutable_date',
+            'has_passports_and_certificates' => 'boolean',
+            'driver_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function driver(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'driver_id');
+    }
+}

+ 57 - 0
app/Models/ProductionOrderInstallation.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderInstallationFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrderInstallation extends Model
+{
+    /** @use HasFactory<ProductionOrderInstallationFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'installation_date',
+        'installation_days',
+        'brigadier_id',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'installation_date' => 'immutable_date',
+            'installation_days' => 'integer',
+            'brigadier_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function brigadier(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'brigadier_id');
+    }
+
+    public function schedules(): HasMany
+    {
+        return $this->hasMany(Schedule::class);
+    }
+}

+ 70 - 0
app/Models/ProductionOrderItem.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderItemFactory;
+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\SoftDeletes;
+
+class ProductionOrderItem extends Model
+{
+    /** @use HasFactory<ProductionOrderItemFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'common_catalog_item_id',
+        'external_id',
+        'order_item_number',
+        'factory_number',
+        'manufacture_date',
+        'passport_file_id',
+        'sort',
+        'created_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'common_catalog_item_id' => 'integer',
+            'manufacture_date' => 'immutable_date',
+            'passport_file_id' => 'integer',
+            'sort' => 'integer',
+            'created_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function catalogItem(): BelongsTo
+    {
+        return $this->belongsTo(CommonCatalogItem::class, 'common_catalog_item_id');
+    }
+
+    public function passportFile(): BelongsTo
+    {
+        return $this->belongsTo(File::class, 'passport_file_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function reclamations(): BelongsToMany
+    {
+        return $this->belongsToMany(Reclamation::class, 'production_order_item_reclamation')
+            ->withTimestamps();
+    }
+}

+ 14 - 0
app/Models/Reclamation.php

@@ -48,6 +48,7 @@ class Reclamation extends Model
 
     protected $fillable = [
         'order_id',
+        'production_order_id',
         'reclamation_type_id',
         'user_id',
         'status_id',
@@ -68,6 +69,11 @@ class Reclamation extends Model
         return $this->belongsTo(Order::class)->withoutGlobalScope(\App\Models\Scopes\YearScope::class);
     }
 
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
     public function status(): BelongsTo
     {
         return $this->belongsTo(ReclamationStatus::class);
@@ -93,6 +99,14 @@ class Reclamation extends Model
             ->withoutGlobalScope(\App\Models\Scopes\YearScope::class);
     }
 
+    public function productionOrderItems(): BelongsToMany
+    {
+        return $this->belongsToMany(
+            ProductionOrderItem::class,
+            'production_order_item_reclamation',
+        )->withTimestamps();
+    }
+
     public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);

+ 1 - 0
app/Models/ReclamationView.php

@@ -17,6 +17,7 @@ class ReclamationView extends Model
     protected $fillable = [
         'id',
         'order_id',
+        'production_order_id',
         'reclamation_type_id',
         'reclamation_type_code',
         'reclamation_type_name',

+ 3 - 0
app/Models/Role.php

@@ -13,6 +13,7 @@ class Role extends Model
     const BRIGADIER = 'brigadier';
     const WAREHOUSE_HEAD = 'warehouse_head';
     const ASSISTANT_HEAD = 'assistant_head';
+    const DRIVER = 'driver';
 
     const VALID_ROLES = [
         self::ADMIN,
@@ -20,6 +21,7 @@ class Role extends Model
         self::BRIGADIER,
         self::WAREHOUSE_HEAD,
         self::ASSISTANT_HEAD,
+        self::DRIVER,
     ];
 
     const NAMES = [
@@ -28,6 +30,7 @@ class Role extends Model
         self::BRIGADIER => 'Бригадир',
         self::WAREHOUSE_HEAD => 'Рук. Склада',
         self::ASSISTANT_HEAD => 'Помощник рук.',
+        self::DRIVER => 'Водитель',
     ];
 
     protected $fillable = [

+ 9 - 0
app/Models/Schedule.php

@@ -11,12 +11,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 class Schedule extends Model
 {
     use HasFactory;
+
+    public const SOURCE_PRODUCTION_ORDER = 'График заказов';
+
     protected $fillable = [
         'installation_date',
         'address_code',
         'manual',
         'source',
         'order_id',
+        'production_order_installation_id',
         'district_id',
         'area_id',
         'object_address',
@@ -43,4 +47,9 @@ class Schedule extends Model
     {
         return $this->belongsTo(User::class, 'brigadier_id', 'id');
     }
+
+    public function productionOrderInstallation(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrderInstallation::class);
+    }
 }

+ 6 - 0
app/Models/UserNotification.php

@@ -13,17 +13,20 @@ class UserNotification extends Model
     public const TYPE_PLATFORM = 'platform';
     public const TYPE_RECLAMATION = 'reclamation';
     public const TYPE_SCHEDULE = 'schedule';
+    public const TYPE_PRODUCTION_ORDER = 'production_order';
 
     public const TYPE_NAMES = [
         self::TYPE_PLATFORM => 'Площадки',
         self::TYPE_RECLAMATION => 'Рекламации',
         self::TYPE_SCHEDULE => 'График монтажей',
+        self::TYPE_PRODUCTION_ORDER => 'График заказов',
     ];
 
     public const TYPE_COLORS = [
         self::TYPE_PLATFORM => 'primary',
         self::TYPE_RECLAMATION => 'success',
         self::TYPE_SCHEDULE => 'warning',
+        self::TYPE_PRODUCTION_ORDER => 'info',
     ];
 
     public const EVENT_CREATED = 'created';
@@ -36,6 +39,9 @@ class UserNotification extends Model
         self::EVENT_STATUS_CHANGED => 'Смена статуса',
         self::EVENT_SCHEDULE_ADDED => 'Добавлено в график',
         self::EVENT_CHAT_MESSAGE => 'Сообщение в чате',
+        'delivery_added' => 'Добавлена доставка',
+        'installation_added' => 'Добавлен монтаж',
+        'reclamation_added' => 'Добавлена рекламация',
     ];
 
     public const DEFAULT_SORT_BY = 'created_at';

+ 3 - 0
app/Models/UserNotificationSetting.php

@@ -15,6 +15,7 @@ class UserNotificationSetting extends Model
         'order_settings',
         'reclamation_settings',
         'schedule_settings',
+        'production_order_settings',
         'chat_settings',
     ];
 
@@ -24,6 +25,7 @@ class UserNotificationSetting extends Model
             'order_settings' => 'array',
             'reclamation_settings' => 'array',
             'schedule_settings' => 'array',
+            'production_order_settings' => 'array',
             'chat_settings' => 'array',
         ];
     }
@@ -40,6 +42,7 @@ class UserNotificationSetting extends Model
             'order_settings' => [],
             'reclamation_settings' => [],
             'schedule_settings' => [],
+            'production_order_settings' => [],
             'chat_settings' => [],
         ];
     }

+ 196 - 0
app/Services/Export/ExportProductionOrdersService.php

@@ -0,0 +1,196 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Export;
+
+use App\Models\File;
+use App\Models\ProductionOrder;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportProductionOrdersService
+{
+    public const ORDERS_SHEET = 'График заказов';
+
+    public const ITEMS_SHEET = 'МАФ';
+
+    public const HEADERS = [
+        'ID',
+        'Номер заказа',
+        'Год',
+        'Заказчик',
+        'Адрес объекта',
+        'Номер счёта',
+        'Дата счёта',
+        'Номер договора',
+        'Дата договора',
+        'Дата оплаты',
+        'Срок поставки, раб. дней',
+        'Дата отгрузки по договору',
+        'Дата отгрузки по заявке',
+        'Статус',
+        'Тип исполнения',
+        'Менеджер',
+        'Оборудование',
+        'Примечание',
+    ];
+
+    public const ITEM_HEADERS = [
+        'Строка заказа',
+        'ID МАФ',
+        'ID позиции каталога',
+        'Артикул',
+        'Наименование',
+        'Номер заказа МАФ',
+        'Заводской номер',
+        'Дата производства',
+    ];
+
+    /** @param list<int> $orderIds */
+    public function handle(array $orderIds, int $userId): File
+    {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle(self::ORDERS_SHEET);
+        $sheet->fromArray(self::HEADERS, null, 'A1');
+        $sheet->getStyle('A1:R1')->getFont()->setBold(true);
+        $sheet->getStyle('A1:R1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('FFD9EAF7');
+
+        $itemsSheet = $spreadsheet->createSheet();
+        $itemsSheet->setTitle(self::ITEMS_SHEET);
+        $itemsSheet->fromArray(self::ITEM_HEADERS, null, 'A1');
+        $itemsSheet->getStyle('A1:H1')->getFont()->setBold(true);
+        $itemsSheet->getStyle('A1:H1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('FFE2F0D9');
+
+        $row = 2;
+        $itemRow = 2;
+        ProductionOrder::query()
+            ->with(['manager', 'items.catalogItem'])
+            ->whereKey($orderIds)
+            ->orderByDesc('created_at')
+            ->orderByDesc('id')
+            ->chunk(200, function ($orders) use ($sheet, $itemsSheet, &$row, &$itemRow): void {
+                foreach ($orders as $order) {
+                    $orderRow = $row;
+                    $sheet->setCellValue("A{$row}", $order->id);
+                    $sheet->setCellValueExplicit("B{$row}", $order->order_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("C{$row}", $order->order_year);
+                    $sheet->setCellValue("D{$row}", $order->customer_name);
+                    $sheet->setCellValue("E{$row}", $order->object_address);
+                    $sheet->setCellValueExplicit("F{$row}", $order->invoice_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("G{$row}", $order->invoice_date?->format('d.m.Y'));
+                    $sheet->setCellValueExplicit("H{$row}", (string) $order->contract_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("I{$row}", $order->contract_date?->format('d.m.Y'));
+                    $sheet->setCellValue("J{$row}", $order->payment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("K{$row}", $order->supply_working_days);
+                    $sheet->setCellValue("L{$row}", $order->contract_shipment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("M{$row}", $order->application_shipment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("N{$row}", $order->statusLabel());
+                    $sheet->setCellValue("O{$row}", $order->executionTypeLabel());
+                    $sheet->setCellValue("P{$row}", $order->manager?->name);
+                    $sheet->setCellValue("Q{$row}", $this->equipmentSummary($order));
+                    $sheet->setCellValue("R{$row}", $order->note);
+
+                    foreach ($order->items as $item) {
+                        $itemsSheet->setCellValue("A{$itemRow}", $orderRow);
+                        $itemsSheet->setCellValue("B{$itemRow}", $item->id);
+                        $itemsSheet->setCellValue("C{$itemRow}", $item->common_catalog_item_id);
+                        $itemsSheet->setCellValueExplicit(
+                            "D{$itemRow}",
+                            (string) $item->catalogItem?->article,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValue(
+                            "E{$itemRow}",
+                            $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
+                        );
+                        $itemsSheet->setCellValueExplicit(
+                            "F{$itemRow}",
+                            $item->order_item_number,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValueExplicit(
+                            "G{$itemRow}",
+                            (string) $item->factory_number,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValue(
+                            "H{$itemRow}",
+                            $item->manufacture_date?->format('d.m.Y'),
+                        );
+                        $itemRow++;
+                    }
+
+                    $row++;
+                }
+            });
+
+        $lastRow = max(1, $row - 1);
+        $sheet->getStyle("A1:R{$lastRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $sheet->getStyle("A1:R{$lastRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        $sheet->freezePane('A2');
+        $sheet->setAutoFilter("A1:R{$lastRow}");
+        foreach (range('A', 'R') as $column) {
+            $sheet->getColumnDimension($column)->setWidth(in_array($column, ['D', 'E', 'Q', 'R'], true) ? 36 : 18);
+        }
+
+        $lastItemRow = max(1, $itemRow - 1);
+        $itemsSheet->getStyle("A1:H{$lastItemRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $itemsSheet->getStyle("A1:H{$lastItemRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        $itemsSheet->freezePane('A2');
+        $itemsSheet->setAutoFilter("A1:H{$lastItemRow}");
+        foreach (range('A', 'H') as $column) {
+            $itemsSheet->getColumnDimension($column)->setWidth(in_array($column, ['D', 'E', 'F'], true) ? 30 : 18);
+        }
+
+        $spreadsheet->setActiveSheetIndex(0);
+
+        $filename = 'График_заказов_'.now()->format('Y-m-d_H-i-s').'.xlsx';
+        $path = 'generated/production-orders/'.$userId.'/'.Str::uuid().'/'.$filename;
+        Storage::disk('local')->makeDirectory(dirname($path));
+        (new Xlsx($spreadsheet))->save(Storage::disk('local')->path($path));
+        $spreadsheet->disconnectWorksheets();
+
+        $file = File::query()->create([
+            'user_id' => $userId,
+            'original_name' => $filename,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'path' => $path,
+            'link' => '',
+            'is_generated' => true,
+        ]);
+        $file->update(['link' => route('schedule.orders.files.download', $file)]);
+
+        return $file;
+    }
+
+    private function equipmentSummary(ProductionOrder $order): string
+    {
+        return $order->items
+            ->groupBy(static fn ($item): string => implode('|', [
+                $item->catalogItem?->article,
+                $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
+            ]))
+            ->map(static function ($items): string {
+                $item = $items->first();
+                $name = $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name;
+                $label = collect([$item->catalogItem?->article, $name])
+                    ->filter(static fn ($value): bool => filled($value))
+                    ->implode(' — ');
+
+                return ($label !== '' ? $label.' — ' : '').$items->count().' шт.';
+            })
+            ->implode("\n");
+    }
+}

+ 454 - 0
app/Services/Import/ImportProductionOrdersService.php

@@ -0,0 +1,454 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Export\ExportProductionOrdersService;
+use App\Services\ProductionOrderService;
+use DateTimeImmutable;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use PhpOffice\PhpSpreadsheet\Cell\Cell;
+use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
+use PhpOffice\PhpSpreadsheet\Shared\Date as SpreadsheetDate;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use RuntimeException;
+use Throwable;
+
+final class ImportProductionOrdersService
+{
+    public function __construct(
+        private readonly Import $import,
+        private readonly int $userId,
+        private readonly ProductionOrderService $orderService,
+    ) {}
+
+    public function handle(): bool
+    {
+        $spreadsheet = null;
+
+        try {
+            $actor = User::query()->findOrFail($this->userId);
+            $spreadsheet = (new Xlsx)->load(
+                Storage::disk('upload')->path((string) $this->import->filename),
+            );
+            $ordersSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
+            $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
+            if ($ordersSheet === null || $itemsSheet === null) {
+                throw new RuntimeException('В файле должны быть листы «График заказов» и «МАФ».');
+            }
+
+            $this->assertHeaders($ordersSheet, $itemsSheet);
+            $orders = $this->readOrders($ordersSheet);
+            $this->readItems($itemsSheet, $orders);
+
+            $created = 0;
+            $updated = 0;
+            DB::transaction(function () use ($orders, $actor, &$created, &$updated): void {
+                foreach ($orders as $orderData) {
+                    $payload = $orderData['data'];
+                    $payload['items'] = $orderData['items'];
+
+                    if ($orderData['order'] instanceof ProductionOrder) {
+                        $this->orderService->update(
+                            $orderData['order'],
+                            $payload,
+                            $actor,
+                            true,
+                        );
+                        $updated++;
+                    } else {
+                        $this->orderService->create(
+                            $payload,
+                            $actor,
+                            ProductionOrderSource::Spreadsheet,
+                        );
+                        $created++;
+                    }
+                }
+            });
+
+            $this->import->log("Создано заказов: {$created}; обновлено заказов: {$updated}.");
+            $this->import->update(['status' => 'DONE']);
+
+            return true;
+        } catch (Throwable $exception) {
+            $this->import->log($exception->getMessage(), 'ERROR');
+            $this->import->update(['status' => 'ERROR']);
+
+            throw $exception;
+        } finally {
+            $spreadsheet?->disconnectWorksheets();
+        }
+    }
+
+    private function assertHeaders(Worksheet $ordersSheet, Worksheet $itemsSheet): void
+    {
+        $orderHeaders = $ordersSheet->rangeToArray('A1:R1', null, true, true, false)[0];
+        $itemHeaders = $itemsSheet->rangeToArray('A1:H1', null, true, true, false)[0];
+
+        if ($this->normalizedHeaders($orderHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::HEADERS)) {
+            throw new RuntimeException('Некорректные заголовки листа «График заказов». Используйте экспорт графика заказов.');
+        }
+        if ($this->normalizedHeaders($itemHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::ITEM_HEADERS)) {
+            throw new RuntimeException('Некорректные заголовки листа «МАФ». Используйте экспорт графика заказов.');
+        }
+    }
+
+    /**
+     * @return array<int, array{
+     *     order: ProductionOrder|null,
+     *     data: array<string, mixed>,
+     *     items: list<array<string, mixed>>
+     * }>
+     */
+    private function readOrders(Worksheet $sheet): array
+    {
+        $orders = [];
+        $seenIds = [];
+        $seenNumbers = [];
+
+        for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
+            if (! $this->rowHasValues($sheet, $row, 'A', 'R')) {
+                continue;
+            }
+
+            $id = $this->integerValue($sheet->getCell("A{$row}"), 'ID заказа', 1, true);
+            if ($id !== null && isset($seenIds[$id])) {
+                throw new RuntimeException("Лист «График заказов», строка {$row}: ID {$id} указан повторно.");
+            }
+
+            $order = $id === null ? null : ProductionOrder::query()->find($id);
+            if ($id !== null && $order === null) {
+                throw new RuntimeException("Лист «График заказов», строка {$row}: заказ с ID {$id} не найден.");
+            }
+
+            $orderNumber = $this->requiredString($sheet->getCell("B{$row}"), 'Номер заказа');
+            $orderYear = $this->integerValue($sheet->getCell("C{$row}"), 'Год', 2000, false, 2100);
+            $numberKey = $this->normalize($orderNumber).'|'.$orderYear;
+            if (isset($seenNumbers[$numberKey])) {
+                throw new RuntimeException(
+                    "Лист «График заказов», строка {$row}: заказ {$orderNumber} за {$orderYear} год указан повторно.",
+                );
+            }
+
+            $data = [
+                'order_number' => $orderNumber,
+                'order_year' => $orderYear,
+                'customer_name' => $this->requiredString($sheet->getCell("D{$row}"), 'Заказчик'),
+                'object_address' => $this->requiredString($sheet->getCell("E{$row}"), 'Адрес объекта'),
+                'invoice_number' => $this->requiredString($sheet->getCell("F{$row}"), 'Номер счёта'),
+                'invoice_date' => $this->dateValue($sheet->getCell("G{$row}"), 'Дата счёта', false),
+                'contract_number' => $this->nullableString($sheet->getCell("H{$row}")),
+                'contract_date' => $this->dateValue($sheet->getCell("I{$row}"), 'Дата договора', false),
+                'payment_date' => $this->dateValue($sheet->getCell("J{$row}"), 'Дата оплаты', true),
+                'supply_working_days' => $this->integerValue(
+                    $sheet->getCell("K{$row}"),
+                    'Срок поставки',
+                    0,
+                    false,
+                    2000,
+                ),
+                'application_shipment_date' => $this->dateValue(
+                    $sheet->getCell("M{$row}"),
+                    'Дата отгрузки по заявке',
+                    false,
+                ),
+                'status' => $this->statusValue($sheet->getCell("N{$row}")),
+                'execution_type' => $this->executionTypeValue($sheet->getCell("O{$row}")),
+                'manager_id' => $this->managerId($sheet->getCell("P{$row}")),
+                'note' => $this->nullableString($sheet->getCell("R{$row}")),
+            ];
+
+            $validator = Validator::make($data, [
+                'order_number' => [
+                    'required',
+                    'string',
+                    'max:100',
+                    Rule::unique('production_orders', 'order_number')
+                        ->where('order_year', $orderYear)
+                        ->ignore($order?->id),
+                ],
+                'order_year' => ['required', 'integer', 'min:2000', 'max:2100'],
+                'customer_name' => ['required', 'string', 'max:500'],
+                'object_address' => ['required', 'string', 'max:5000'],
+                'invoice_number' => ['required', 'string', 'max:100'],
+                'invoice_date' => ['nullable', 'date'],
+                'contract_number' => ['nullable', 'string', 'max:100'],
+                'contract_date' => ['nullable', 'date'],
+                'payment_date' => ['required', 'date'],
+                'supply_working_days' => ['required', 'integer', 'min:0', 'max:2000'],
+                'application_shipment_date' => ['nullable', 'date'],
+                'status' => ['required', Rule::enum(ProductionOrderStatus::class)],
+                'execution_type' => ['required', Rule::enum(ProductionOrderExecutionType::class)],
+                'manager_id' => [
+                    'required',
+                    'integer',
+                    Rule::exists('users', 'id')->where('role', Role::MANAGER),
+                ],
+                'note' => ['nullable', 'string', 'max:10000'],
+            ]);
+            if ($validator->fails()) {
+                throw new RuntimeException(
+                    "Лист «График заказов», строка {$row}: ".implode(' ', $validator->errors()->all()),
+                );
+            }
+
+            $orders[$row] = [
+                'order' => $order,
+                'data' => $validator->validated(),
+                'items' => [],
+            ];
+            if ($id !== null) {
+                $seenIds[$id] = true;
+            }
+            $seenNumbers[$numberKey] = true;
+        }
+
+        if ($orders === []) {
+            throw new RuntimeException('Лист «График заказов» не содержит заказов для импорта.');
+        }
+
+        return $orders;
+    }
+
+    /**
+     * @param array<int, array{
+     *     order: ProductionOrder|null,
+     *     data: array<string, mixed>,
+     *     items: list<array<string, mixed>>
+     * }> $orders
+     */
+    private function readItems(Worksheet $sheet, array &$orders): void
+    {
+        $seenIds = [];
+
+        for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
+            if (! $this->rowHasValues($sheet, $row, 'A', 'H')) {
+                continue;
+            }
+
+            $orderRow = $this->integerValue($sheet->getCell("A{$row}"), 'Строка заказа', 2);
+            if (! isset($orders[$orderRow])) {
+                throw new RuntimeException(
+                    "Лист «МАФ», строка {$row}: строка заказа {$orderRow} отсутствует на листе «График заказов».",
+                );
+            }
+
+            $itemId = $this->integerValue($sheet->getCell("B{$row}"), 'ID МАФ', 1, true);
+            $existingItem = null;
+            if ($itemId !== null) {
+                if (isset($seenIds[$itemId])) {
+                    throw new RuntimeException("Лист «МАФ», строка {$row}: ID МАФ {$itemId} указан повторно.");
+                }
+                $existingItem = ProductionOrderItem::query()->find($itemId);
+                if ($existingItem === null) {
+                    throw new RuntimeException("Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не найден.");
+                }
+                $targetOrder = $orders[$orderRow]['order'];
+                if ($targetOrder === null || $existingItem->production_order_id !== $targetOrder->id) {
+                    throw new RuntimeException(
+                        "Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не принадлежит указанному заказу.",
+                    );
+                }
+                $seenIds[$itemId] = true;
+            }
+
+            $catalogItemId = $this->integerValue(
+                $sheet->getCell("C{$row}"),
+                'ID позиции каталога',
+                1,
+            );
+            if (! CommonCatalogItem::query()->whereKey($catalogItemId)->exists()) {
+                throw new RuntimeException(
+                    "Лист «МАФ», строка {$row}: позиция общего каталога с ID {$catalogItemId} не найдена.",
+                );
+            }
+
+            $itemData = [
+                'id' => $existingItem?->id,
+                'common_catalog_item_id' => $catalogItemId,
+                'order_item_number' => $this->requiredString(
+                    $sheet->getCell("F{$row}"),
+                    'Номер заказа МАФ',
+                ),
+                'factory_number' => $this->nullableString($sheet->getCell("G{$row}")),
+                'manufacture_date' => $this->dateValue(
+                    $sheet->getCell("H{$row}"),
+                    'Дата производства',
+                    false,
+                ),
+            ];
+            $validator = Validator::make($itemData, [
+                'id' => ['nullable', 'integer'],
+                'common_catalog_item_id' => ['required', 'integer'],
+                'order_item_number' => ['required', 'string', 'max:100'],
+                'factory_number' => ['nullable', 'string', 'max:100'],
+                'manufacture_date' => ['nullable', 'date'],
+            ]);
+            if ($validator->fails()) {
+                throw new RuntimeException(
+                    "Лист «МАФ», строка {$row}: ".implode(' ', $validator->errors()->all()),
+                );
+            }
+
+            $orders[$orderRow]['items'][] = array_filter(
+                $validator->validated(),
+                static fn (mixed $value, string $key): bool => $key !== 'id' || $value !== null,
+                ARRAY_FILTER_USE_BOTH,
+            );
+        }
+
+        foreach ($orders as $row => $order) {
+            if ($order['items'] === []) {
+                throw new RuntimeException(
+                    "Лист «График заказов», строка {$row}: на листе «МАФ» не указано оборудование заказа.",
+                );
+            }
+        }
+    }
+
+    /** @param list<mixed> $headers */
+    private function normalizedHeaders(array $headers): array
+    {
+        return array_map($this->normalize(...), $headers);
+    }
+
+    private function normalize(mixed $value): string
+    {
+        return mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', (string) $value)));
+    }
+
+    private function rowHasValues(Worksheet $sheet, int $row, string $from, string $to): bool
+    {
+        $values = $sheet->rangeToArray("{$from}{$row}:{$to}{$row}", null, true, true, false)[0];
+
+        return collect($values)->contains(static fn (mixed $value): bool => trim((string) $value) !== '');
+    }
+
+    private function requiredString(Cell $cell, string $label): string
+    {
+        $value = $this->nullableString($cell);
+        if ($value === null) {
+            throw new RuntimeException("Не заполнено поле «{$label}».");
+        }
+
+        return $value;
+    }
+
+    private function nullableString(Cell $cell): ?string
+    {
+        $value = trim((string) $cell->getFormattedValue());
+
+        return $value === '' ? null : $value;
+    }
+
+    private function integerValue(
+        Cell $cell,
+        string $label,
+        int $minimum,
+        bool $nullable = false,
+        ?int $maximum = null,
+    ): ?int {
+        $raw = $cell->getCalculatedValue();
+        if ($raw === null || trim((string) $raw) === '') {
+            if ($nullable) {
+                return null;
+            }
+
+            throw new RuntimeException("Не заполнено поле «{$label}».");
+        }
+
+        $value = filter_var($raw, FILTER_VALIDATE_INT);
+        if ($value === false || $value < $minimum || ($maximum !== null && $value > $maximum)) {
+            throw new RuntimeException("Поле «{$label}» содержит недопустимое целое число.");
+        }
+
+        return $value;
+    }
+
+    private function dateValue(Cell $cell, string $label, bool $required): ?string
+    {
+        $raw = $cell->getCalculatedValue();
+        if ($raw === null || trim((string) $raw) === '') {
+            if ($required) {
+                throw new RuntimeException("Не заполнено поле «{$label}».");
+            }
+
+            return null;
+        }
+
+        if (is_numeric($raw)) {
+            try {
+                return SpreadsheetDate::excelToDateTimeObject((float) $raw)->format('Y-m-d');
+            } catch (Throwable) {
+                throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
+            }
+        }
+
+        $value = trim((string) $raw);
+        foreach (['d.m.Y', 'Y-m-d', 'd/m/Y'] as $format) {
+            $date = DateTimeImmutable::createFromFormat('!'.$format, $value);
+            $errors = DateTimeImmutable::getLastErrors();
+            if ($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))) {
+                return $date->format('Y-m-d');
+            }
+        }
+
+        throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
+    }
+
+    private function managerId(Cell $cell): int
+    {
+        $name = $this->requiredString($cell, 'Менеджер');
+        $managers = User::query()
+            ->where('role', Role::MANAGER)
+            ->where('name', $name)
+            ->limit(2)
+            ->get(['id']);
+        if ($managers->isEmpty()) {
+            throw new RuntimeException("Менеджер «{$name}» не найден.");
+        }
+        if ($managers->count() > 1) {
+            throw new RuntimeException("Имя менеджера «{$name}» неоднозначно.");
+        }
+
+        return (int) $managers->first()->id;
+    }
+
+    private function statusValue(Cell $cell): string
+    {
+        $value = $this->normalize($this->requiredString($cell, 'Статус'));
+        foreach (ProductionOrderStatus::cases() as $status) {
+            if (in_array($value, [$this->normalize($status->value), $this->normalize($status->label())], true)) {
+                return $status->value;
+            }
+        }
+
+        throw new RuntimeException("Неизвестный статус «{$cell->getFormattedValue()}».");
+    }
+
+    private function executionTypeValue(Cell $cell): string
+    {
+        $value = $this->normalize($this->requiredString($cell, 'Тип исполнения'));
+        foreach (ProductionOrderExecutionType::cases() as $type) {
+            if (in_array($value, [$this->normalize($type->value), $this->normalize($type->label())], true)) {
+                return $type->value;
+            }
+        }
+
+        throw new RuntimeException("Неизвестный тип исполнения «{$cell->getFormattedValue()}».");
+    }
+}

+ 125 - 9
app/Services/NotificationService.php

@@ -2,12 +2,16 @@
 
 namespace App\Services;
 
+use App\Enums\ProductionOrderNotificationEvent;
 use App\Events\SendPersistentNotificationEvent;
 use App\Helpers\DateHelper;
 use App\Jobs\SendUserNotificationChannelJob;
 use App\Models\ChatMessage;
 use App\Models\NotificationDeliveryLog;
 use App\Models\Order;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
 use App\Models\Reclamation;
 use App\Models\Schedule;
 use App\Models\User;
@@ -20,6 +24,92 @@ use Illuminate\Support\Str;
 
 class NotificationService
 {
+    public function notifyProductionOrderEvent(
+        ProductionOrder $order,
+        ProductionOrderNotificationEvent $event,
+        ?User $author = null,
+        ?ProductionOrderDelivery $delivery = null,
+        ?ProductionOrderInstallation $installation = null,
+    ): void {
+        $order->loadMissing(['manager', 'deliveries.driver', 'installations.brigadier']);
+        $authorSuffix = $author ? sprintf(' Изменил %s.', $author->name) : '';
+        $authorSuffixHtml = $author ? sprintf(' Изменил %s.', e($author->name)) : '';
+        $url = route('schedule.orders.show', $order);
+        $label = 'заказ №'.$order->order_number;
+        $htmlLabel = sprintf('<a href="%s">заказ №%s</a>', $url, e($order->order_number));
+
+        [$message, $messageHtml] = match ($event) {
+            ProductionOrderNotificationEvent::Created => [
+                sprintf('Создан %s, %s.', $label, $order->object_address).$authorSuffix,
+                sprintf('Создан %s, %s.', $htmlLabel, e($order->object_address)).$authorSuffixHtml,
+            ],
+            ProductionOrderNotificationEvent::StatusChanged => [
+                sprintf('Статус %s изменён на «%s».', $label, $order->statusLabel()).$authorSuffix,
+                sprintf('Статус %s изменён на «%s».', $htmlLabel, e($order->statusLabel())).$authorSuffixHtml,
+            ],
+            ProductionOrderNotificationEvent::DeliveryAdded => [
+                sprintf(
+                    'Для %s добавлена доставка на %s, водитель %s.',
+                    $label,
+                    $delivery?->delivery_date?->format('d.m.Y') ?? '—',
+                    $delivery?->driver?->name ?? '—',
+                ).$authorSuffix,
+                sprintf(
+                    'Для %s добавлена доставка на %s, водитель %s.',
+                    $htmlLabel,
+                    e($delivery?->delivery_date?->format('d.m.Y') ?? '—'),
+                    e($delivery?->driver?->name ?? '—'),
+                ).$authorSuffixHtml,
+            ],
+            ProductionOrderNotificationEvent::InstallationAdded => [
+                sprintf(
+                    'Для %s добавлен монтаж на %s, бригадир %s.',
+                    $label,
+                    $installation?->installation_date?->format('d.m.Y') ?? '—',
+                    $installation?->brigadier?->name ?? '—',
+                ).$authorSuffix,
+                sprintf(
+                    'Для %s добавлен монтаж на %s, бригадир %s.',
+                    $htmlLabel,
+                    e($installation?->installation_date?->format('d.m.Y') ?? '—'),
+                    e($installation?->brigadier?->name ?? '—'),
+                ).$authorSuffixHtml,
+            ],
+            ProductionOrderNotificationEvent::ReclamationAdded => [
+                sprintf('Для %s добавлена рекламация.', $label).$authorSuffix,
+                sprintf('Для %s добавлена рекламация.', $htmlLabel).$authorSuffixHtml,
+            ],
+        };
+
+        foreach ($this->productionOrderRecipients($order) as $user) {
+            $settings = $this->settingsForUser($user->id);
+            $channels = $settings->getChannelsForKey('production_order_settings', $event->value);
+            if ($channels === []) {
+                continue;
+            }
+
+            $notification = $this->createInAppNotification(
+                $user,
+                UserNotification::TYPE_PRODUCTION_ORDER,
+                $event->value,
+                'График заказов',
+                $message,
+                $messageHtml,
+                [
+                    'production_order_id' => $order->id,
+                    'delivery_id' => $delivery?->id,
+                    'installation_id' => $installation?->id,
+                ],
+            );
+
+            $this->dispatchDeliveryJobs($notification, [
+                NotificationDeliveryLog::CHANNEL_BROWSER => ! empty($channels['browser']),
+                NotificationDeliveryLog::CHANNEL_PUSH => ! empty($channels['push']),
+                NotificationDeliveryLog::CHANNEL_EMAIL => ! empty($channels['email']),
+            ]);
+        }
+    }
+
     public function notifyChatMessage(ChatMessage $chatMessage, array $recipientIds = [], bool $forceBrowserNotification = false): void
     {
         $chatMessage->loadMissing([
@@ -28,6 +118,7 @@ class NotificationService
             'order.user',
             'order.brigadier',
             'reclamation.order',
+            'reclamation.productionOrder',
             'reclamation.user',
             'reclamation.brigadier',
         ]);
@@ -111,23 +202,28 @@ class NotificationService
     public function notifyReclamationCreated(Reclamation $reclamation, ?User $author = null): void
     {
         $order = $reclamation->order;
-        if (!$order) {
+        $productionOrder = $reclamation->productionOrder;
+        if (!$order && !$productionOrder) {
             return;
         }
 
         $authorSuffix = $author ? sprintf(' Изменил %s.', $author->name) : '';
         $authorSuffixHtml = $author ? sprintf(' Изменил %s.', e($author->name)) : '';
 
+        $address = $order?->object_address ?? $productionOrder?->object_address ?? '—';
+        $sourceUrl = $order
+            ? route('order.show', ['order' => $order->id, 'sync_year' => 1])
+            : route('schedule.orders.show', ['productionOrder' => $productionOrder]);
         $message = sprintf(
             'Добавлена новая рекламация по адресу %s #%d.',
-            $order->object_address,
+            $address,
             $reclamation->id,
         ) . $authorSuffix;
 
         $messageHtml = sprintf(
             'Добавлена новая рекламация по адресу <a href="%s">%s</a> <a href="%s">#%d</a>.',
-            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
-            e($order->object_address),
+            $sourceUrl,
+            e($address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
         ) . $authorSuffixHtml;
@@ -145,7 +241,8 @@ class NotificationService
     public function notifyReclamationStatusChanged(Reclamation $reclamation, ?User $author = null): void
     {
         $order = $reclamation->order;
-        if (!$order) {
+        $productionOrder = $reclamation->productionOrder;
+        if (!$order && !$productionOrder) {
             return;
         }
 
@@ -153,17 +250,21 @@ class NotificationService
         $authorSuffix = $author ? sprintf(' Изменил %s.', $author->name) : '';
         $authorSuffixHtml = $author ? sprintf(' Изменил %s.', e($author->name)) : '';
 
+        $address = $order?->object_address ?? $productionOrder?->object_address ?? '—';
+        $sourceUrl = $order
+            ? route('order.show', ['order' => $order->id, 'sync_year' => 1])
+            : route('schedule.orders.show', ['productionOrder' => $productionOrder]);
         $message = sprintf(
             'Статус рекламации %s #%d изменен на %s.',
-            $order->object_address,
+            $address,
             $reclamation->id,
             $statusName,
         ) . $authorSuffix;
 
         $messageHtml = sprintf(
             'Статус рекламации по адресу <a href="%s">%s</a> <a href="%s">#%d</a> изменен на %s.',
-            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
-            e($order->object_address),
+            $sourceUrl,
+            e($address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
             e($statusName),
@@ -499,6 +600,19 @@ class NotificationService
         return $query->distinct()->get();
     }
 
+    private function productionOrderRecipients(ProductionOrder $order): Collection
+    {
+        $ids = collect([$order->manager_id])
+            ->merge($order->deliveries->pluck('driver_id'))
+            ->merge($order->installations->pluck('brigadier_id'))
+            ->filter()
+            ->map(static fn ($id): int => (int) $id)
+            ->unique()
+            ->values();
+
+        return User::query()->whereKey($ids)->get();
+    }
+
     private function reclamationRecipients(Reclamation $reclamation): Collection
     {
         $query = User::query()
@@ -682,7 +796,9 @@ class NotificationService
         }
 
         $reclamation = $chatMessage->reclamation;
-        $address = $reclamation?->order?->object_address ?? '-';
+        $address = $reclamation?->order?->object_address
+            ?? $reclamation?->productionOrder?->object_address
+            ?? '-';
         $reclamationUrl = $reclamation
             ? route('reclamations.show', ['reclamation' => $reclamation->id, 'sync_year' => 1])
             : route('reclamations.index');

+ 44 - 0
app/Services/ProductionCalendarService.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\ProductionCalendarDay;
+use Carbon\CarbonImmutable;
+use DateTimeInterface;
+use InvalidArgumentException;
+
+class ProductionCalendarService
+{
+    public function addWorkingDays(DateTimeInterface|string $startDate, int $workingDays): CarbonImmutable
+    {
+        if ($workingDays < 0) {
+            throw new InvalidArgumentException('Количество рабочих дней не может быть отрицательным.');
+        }
+
+        $date = CarbonImmutable::parse($startDate)->startOfDay();
+        if ($workingDays === 0) {
+            return $date;
+        }
+
+        $overrides = ProductionCalendarDay::query()
+            ->pluck('is_working_day', 'date')
+            ->map(static fn (mixed $value): bool => (bool) $value);
+
+        $remaining = $workingDays;
+        while ($remaining > 0) {
+            $date = $date->addDay();
+            $key = $date->toDateString();
+            $isWorkingDay = $overrides->has($key)
+                ? $overrides->get($key)
+                : ! $date->isWeekend();
+
+            if ($isWorkingDay) {
+                $remaining--;
+            }
+        }
+
+        return $date;
+    }
+}

+ 384 - 0
app/Services/ProductionOrderDocumentService.php

@@ -0,0 +1,384 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Models\File;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Writer\Xls;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use RuntimeException;
+use ZipArchive;
+
+class ProductionOrderDocumentService
+{
+    public const TYPE_ITEMS = 'items';
+    public const TYPE_DELIVERY = 'delivery';
+    public const TYPE_INSTALLATION = 'installation';
+    public const TYPE_TECHNICAL_DOCUMENTS = 'technical_documents';
+
+    public function generate(
+        string $type,
+        int $orderId,
+        int $userId,
+        ?int $relatedId = null,
+        array $itemIds = [],
+    ): File {
+        $order = ProductionOrder::query()
+            ->with([
+                'manager',
+                'items.catalogItem.documents',
+                'items.passportFile',
+                'documents',
+            ])
+            ->findOrFail($orderId);
+
+        return match ($type) {
+            self::TYPE_ITEMS => $this->exportItems($order, $userId),
+            self::TYPE_DELIVERY => $this->deliveryRequest($order, (int) $relatedId, $userId),
+            self::TYPE_INSTALLATION => $this->installationPack($order, (int) $relatedId, $userId),
+            self::TYPE_TECHNICAL_DOCUMENTS => $this->technicalDocuments($order, $itemIds, $userId),
+            default => throw new RuntimeException('Неизвестный тип документа заказа.'),
+        };
+    }
+
+    private function exportItems(ProductionOrder $order, int $userId): File
+    {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('МАФ');
+        $sheet->mergeCells('A1:F1');
+        $sheet->setCellValue('A1', 'Оборудование заказа №'.$order->order_number);
+        $sheet->mergeCells('A2:F2');
+        $sheet->setCellValue('A2', sprintf(
+            'Заказчик: %s; адрес: %s; счёт №%s',
+            $order->customer_name,
+            $order->object_address,
+            $order->invoice_number,
+        ));
+        $sheet->fromArray([
+            'Артикул',
+            'Наименование',
+            'Номер заказа МАФ',
+            'Заводской номер',
+            'Дата производства',
+            'Паспорт',
+        ], null, 'A4');
+
+        $row = 5;
+        foreach ($order->items as $item) {
+            $sheet->setCellValueExplicit("A{$row}", (string) $item->catalogItem?->article, DataType::TYPE_STRING);
+            $sheet->setCellValue("B{$row}", $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name);
+            $sheet->setCellValueExplicit("C{$row}", (string) $item->order_item_number, DataType::TYPE_STRING);
+            $sheet->setCellValueExplicit("D{$row}", (string) $item->factory_number, DataType::TYPE_STRING);
+            $sheet->setCellValue("E{$row}", $item->manufacture_date?->format('d.m.Y'));
+            $sheet->setCellValue("F{$row}", $item->passport_file_id ? 'Да' : 'Нет');
+            $row++;
+        }
+
+        $lastRow = max(4, $row - 1);
+        $sheet->getStyle("A4:F{$lastRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $sheet->getStyle("A1:F{$lastRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        foreach (['A' => 18, 'B' => 42, 'C' => 24, 'D' => 22, 'E' => 20, 'F' => 14] as $column => $width) {
+            $sheet->getColumnDimension($column)->setWidth($width);
+        }
+
+        $filename = $this->safeFilename("МАФ_заказа_{$order->order_number}.xlsx");
+        $file = $this->writeSpreadsheet($spreadsheet, $filename, $userId, new Xlsx($spreadsheet));
+        $spreadsheet->disconnectWorksheets();
+
+        return $file;
+    }
+
+    private function deliveryRequest(ProductionOrder $order, int $deliveryId, int $userId): File
+    {
+        $delivery = $order->deliveries()->with('driver')->findOrFail($deliveryId);
+        $template = base_path('templates/DeliveryRequest.xls');
+        if (! is_file($template)) {
+            throw new RuntimeException('Шаблон заявки на доставку не найден.');
+        }
+
+        $spreadsheet = IOFactory::load($template);
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->getPageSetup()->setPrintArea('B1:AG94');
+        $sheet->setCellValue('B2', 'Заявка на доставку/самовывоз оборудования от '.now()->format('d.m.Y'));
+        $sheet->setCellValue('G4', $order->invoice_number);
+        $sheet->setCellValue('N4', $order->invoice_date?->format('d.m.Y'));
+        $sheet->setCellValue('G5', $order->contract_number);
+        $sheet->setCellValue('N5', $order->contract_date?->format('d.m.Y'));
+        $sheet->setCellValue('G6', $order->order_number);
+        $sheet->setCellValue('F8', $order->manager?->name);
+        $sheet->setCellValue('V8', $order->manager?->phone);
+        $sheet->setCellValue('B11', $delivery->carrier ?: $delivery->driver?->name);
+        $sheet->setCellValue('O11', trim(implode(' / ', array_filter([
+            $delivery->request_number,
+            $delivery->request_date?->format('d.m.Y'),
+        ]))));
+        $sheet->setCellValue('S11', $this->shortTime($delivery->delivery_time));
+        $sheet->setCellValue('W11', $delivery->carrier_note);
+        $sheet->setCellValue('L12', $order->customer_name);
+        $sheet->setCellValue('C13', $delivery->contact_position);
+        $sheet->setCellValue('L13', $delivery->contact_name);
+        $sheet->setCellValue('Y13', $delivery->contact_phone);
+        $sheet->setCellValue('L16', $order->execution_type === ProductionOrderExecutionType::Pickup ? 'Самовывоз' : 'Доставка');
+        $sheet->setCellValue('P16', $order->execution_type === ProductionOrderExecutionType::Pickup ? '' : 'Х');
+        $sheet->setCellValue('Q16', $order->execution_type === ProductionOrderExecutionType::Pickup ? 'Х' : '');
+        $sheet->setCellValue('L17', $order->object_address);
+        $sheet->setCellValue('L18', $delivery->delivery_date->format('d.m.Y'));
+        $sheet->setCellValue('L19', $this->shortTime($delivery->desired_time ?: $delivery->delivery_time));
+        $sheet->setCellValue('L20', $delivery->access_system);
+        $sheet->setCellValue('L21', $delivery->unloading_place);
+        $sheet->setCellValue('L22', $delivery->advance_notice);
+        $sheet->setCellValue('L23', $delivery->document_signing_method);
+        $sheet->setCellValue('L24', $delivery->has_passports_and_certificates ? 'Да' : 'Нет');
+        $sheet->setCellValue('G25', $this->equipmentSummary($order->items));
+        $sheet->setCellValue('G26', $delivery->note);
+        $sheet->setCellValue('B27', $order->manager?->name);
+        $sheet->setCellValue('S28', now()->format('d.m.Y'));
+
+        $filename = $this->safeFilename("Заявка_на_доставку_{$order->order_number}_{$delivery->delivery_date->format('Y-m-d')}.xls");
+        $file = $this->writeSpreadsheet($spreadsheet, $filename, $userId, new Xls($spreadsheet));
+        $spreadsheet->disconnectWorksheets();
+
+        return $file;
+    }
+
+    private function installationPack(ProductionOrder $order, int $installationId, int $userId): File
+    {
+        $installation = $order->installations()->with('brigadier')->findOrFail($installationId);
+        $template = base_path('templates/OrderForMount.xlsx');
+        if (! is_file($template)) {
+            throw new RuntimeException('Шаблон заявки на монтаж не найден.');
+        }
+
+        $temporaryDirectory = $this->temporaryDirectory();
+        Storage::disk('local')->makeDirectory($temporaryDirectory);
+        $requestFilename = $this->safeFilename("Заявка_на_монтаж_{$order->order_number}.xlsx");
+        $requestPath = $temporaryDirectory.'/'.$requestFilename;
+        $spreadsheet = IOFactory::load($template);
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setCellValue('F8', $order->manager?->name);
+        $sheet->setCellValue('X8', $order->manager?->phone);
+        $sheet->setCellValue('C12', $order->customer_name);
+        $sheet->setCellValue('L14', $order->object_address);
+        $sheet->setCellValue('L15', $installation->installation_date->format('d.m.Y'));
+        $sheet->setCellValue('G33', $this->equipmentSummary($order->items));
+        (new Xlsx($spreadsheet))->save(Storage::disk('local')->path($requestPath));
+        $spreadsheet->disconnectWorksheets();
+
+        $filename = $this->safeFilename("Монтажный_пакет_{$order->order_number}.zip");
+        try {
+            return $this->zipFiles(
+                $order,
+                $order->items,
+                $userId,
+                $filename,
+                [$requestPath => $requestFilename],
+                includeOrderDocuments: true,
+            );
+        } finally {
+            Storage::disk('local')->deleteDirectory($temporaryDirectory);
+        }
+    }
+
+    private function technicalDocuments(ProductionOrder $order, array $itemIds, int $userId): File
+    {
+        $ids = collect($itemIds)->map(static fn ($id): int => (int) $id)->filter()->unique();
+        $items = $order->items->whereIn('id', $ids);
+        if ($items->isEmpty() || $items->count() !== $ids->count()) {
+            throw new RuntimeException('Выбранные МАФ не найдены в заказе.');
+        }
+
+        return $this->zipFiles(
+            $order,
+            $items,
+            $userId,
+            $this->safeFilename("Техдокументация_{$order->order_number}.zip"),
+        );
+    }
+
+    /**
+     * @param Collection<int, \App\Models\ProductionOrderItem> $items
+     * @param array<string, string> $localFiles
+     */
+    private function zipFiles(
+        ProductionOrder $order,
+        Collection $items,
+        int $userId,
+        string $filename,
+        array $localFiles = [],
+        bool $includeOrderDocuments = false,
+    ): File {
+        $path = $this->generatedPath($userId, $filename);
+        Storage::disk('local')->makeDirectory(dirname($path));
+        $zip = new ZipArchive;
+        if ($zip->open(Storage::disk('local')->path($path), ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+            throw new RuntimeException('Не удалось создать ZIP-архив.');
+        }
+
+        $added = 0;
+        foreach ($localFiles as $filePath => $archiveName) {
+            if (Storage::disk('local')->exists($filePath)) {
+                $zip->addFile(Storage::disk('local')->path($filePath), $archiveName);
+                $added++;
+            }
+        }
+
+        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 = $archiveDirectory.$this->safeFilename($document->original_name);
+                if ($this->addFileIfMissing(
+                    $zip,
+                    Storage::disk('public')->path($document->path),
+                    $archiveName,
+                )) {
+                    $added++;
+                }
+            }
+        }
+
+        if ($includeOrderDocuments) {
+            foreach ($order->documents as $document) {
+                if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
+                    continue;
+                }
+                $archiveName = 'Документы заказа/'.$this->safeFilename($document->original_name);
+                $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
+                $added++;
+            }
+        }
+        $zip->close();
+
+        if ($added === 0) {
+            Storage::disk('local')->delete($path);
+            throw new RuntimeException('Для выбранных МАФ нет технической документации.');
+        }
+
+        return $this->registerFile($path, $filename, 'application/zip', $userId);
+    }
+
+    private function writeSpreadsheet(Spreadsheet $spreadsheet, string $filename, int $userId, object $writer): File
+    {
+        $path = $this->generatedPath($userId, $filename);
+        Storage::disk('local')->makeDirectory(dirname($path));
+        $writer->save(Storage::disk('local')->path($path));
+
+        $mimeType = Str::endsWith($filename, '.xls')
+            ? 'application/vnd.ms-excel'
+            : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+        return $this->registerFile($path, $filename, $mimeType, $userId);
+    }
+
+    private function registerFile(string $path, string $filename, string $mimeType, int $userId): File
+    {
+        $file = File::query()->create([
+            'link' => '',
+            'path' => $path,
+            'user_id' => $userId,
+            'original_name' => $filename,
+            'mime_type' => $mimeType,
+            'is_generated' => true,
+        ]);
+        $file->update(['link' => route('schedule.orders.files.download', $file)]);
+
+        return $file;
+    }
+
+    private function equipmentSummary(Collection $items): string
+    {
+        return $items
+            ->groupBy(static fn ($item): string => implode('|', [
+                $item->catalogItem?->article,
+                $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
+            ]))
+            ->map(static function (Collection $group): string {
+                $item = $group->first();
+                $name = $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name;
+
+                return trim((string) $item->catalogItem?->article.' — '.$name).' — '.$group->count().' шт.';
+            })
+            ->implode("\n");
+    }
+
+    private function shortTime(mixed $value): string
+    {
+        return $value ? substr((string) $value, 0, 5) : '';
+    }
+
+    private function generatedPath(int $userId, string $filename): string
+    {
+        return "generated/production-orders/{$userId}/".Str::uuid()."/{$filename}";
+    }
+
+    private function temporaryDirectory(): string
+    {
+        return 'generated/production-orders/tmp/'.Str::uuid();
+    }
+
+    private function safeFilename(string $value): string
+    {
+        $value = preg_replace('/[^\pL\pN ._()-]+/u', '_', trim($value)) ?? '';
+
+        return mb_substr($value === '' ? 'файл' : $value, 0, 180);
+    }
+
+    private function uniqueArchiveName(ZipArchive $zip, string $name): string
+    {
+        if ($zip->locateName($name) === false) {
+            return $name;
+        }
+
+        $extension = pathinfo($name, PATHINFO_EXTENSION);
+        $base = $extension === '' ? $name : substr($name, 0, -strlen($extension) - 1);
+        for ($index = 2; $index < 1000; $index++) {
+            $candidate = $base." ({$index})".($extension === '' ? '' : ".{$extension}");
+            if ($zip->locateName($candidate) === false) {
+                return $candidate;
+            }
+        }
+
+        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);
+    }
+}

+ 127 - 0
app/Services/ProductionOrderPlanningService.php

@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Enums\ProductionOrderActivityType;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\Schedule;
+use App\Models\User;
+use Illuminate\Support\Facades\DB;
+
+class ProductionOrderPlanningService
+{
+    /** @param array<string, mixed> $data */
+    public function createDelivery(ProductionOrder $order, array $data, User $actor): ProductionOrderDelivery
+    {
+        return DB::transaction(function () use ($order, $data, $actor): ProductionOrderDelivery {
+            unset($data['nav']);
+            $delivery = $order->deliveries()->create($data + [
+                'created_by' => $actor->id,
+                'updated_by' => $actor->id,
+            ]);
+            $order->activities()->create([
+                'type' => ProductionOrderActivityType::DeliveryScheduled,
+                'user_id' => $actor->id,
+                'delivery_id' => $delivery->id,
+                'message' => sprintf(
+                    'Назначена доставка на %s, водитель %s.',
+                    $delivery->delivery_date->format('d.m.Y'),
+                    $delivery->driver()->value('name') ?? '—',
+                ),
+            ]);
+
+            return $delivery;
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function updateDelivery(ProductionOrderDelivery $delivery, array $data, User $actor): void
+    {
+        unset($data['nav']);
+        $delivery->update($data + ['updated_by' => $actor->id]);
+    }
+
+    public function deleteDelivery(ProductionOrderDelivery $delivery): void
+    {
+        $delivery->delete();
+    }
+
+    /** @param array<string, mixed> $data */
+    public function createInstallation(ProductionOrder $order, array $data, User $actor): ProductionOrderInstallation
+    {
+        return DB::transaction(function () use ($order, $data, $actor): ProductionOrderInstallation {
+            unset($data['nav']);
+            $installation = $order->installations()->create($data + [
+                'created_by' => $actor->id,
+                'updated_by' => $actor->id,
+            ]);
+            $order->activities()->create([
+                'type' => ProductionOrderActivityType::InstallationScheduled,
+                'user_id' => $actor->id,
+                'installation_id' => $installation->id,
+                'message' => sprintf(
+                    'Назначен монтаж на %s, бригадир %s.',
+                    $installation->installation_date->format('d.m.Y'),
+                    $installation->brigadier()->value('name') ?? '—',
+                ),
+            ]);
+            $this->syncInstallationSchedules($installation);
+
+            return $installation;
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function updateInstallation(ProductionOrderInstallation $installation, array $data, User $actor): void
+    {
+        DB::transaction(function () use ($installation, $data, $actor): void {
+            unset($data['nav']);
+            $installation->update($data + ['updated_by' => $actor->id]);
+            $this->syncInstallationSchedules($installation);
+        });
+    }
+
+    public function deleteInstallation(ProductionOrderInstallation $installation): void
+    {
+        DB::transaction(function () use ($installation): void {
+            $installation->schedules()->delete();
+            $installation->delete();
+        });
+    }
+
+    private function syncInstallationSchedules(ProductionOrderInstallation $installation): void
+    {
+        $installation->schedules()->delete();
+        $order = $installation->productionOrder()->with('items.catalogItem')->firstOrFail();
+        $equipment = $order->items
+            ->groupBy('common_catalog_item_id')
+            ->map(static function ($items): string {
+                $article = $items->first()->catalogItem?->article ?? 'Без артикула';
+
+                return $article.' - '.$items->count();
+            })
+            ->implode("\r\n");
+
+        for ($offset = 0; $offset < $installation->installation_days; $offset++) {
+            Schedule::query()->create([
+                'production_order_installation_id' => $installation->id,
+                'address_code' => $order->order_number,
+                'source' => Schedule::SOURCE_PRODUCTION_ORDER,
+                'installation_date' => $installation->installation_date->addDays($offset)->toDateString(),
+                'manual' => false,
+                'district_id' => null,
+                'area_id' => null,
+                'object_address' => $order->object_address,
+                'object_type' => $order->executionTypeLabel(),
+                'mafs' => $equipment,
+                'mafs_count' => $order->items->count(),
+                'brigadier_id' => $installation->brigadier_id,
+                'comment' => $installation->note,
+            ]);
+        }
+    }
+}

+ 140 - 0
app/Services/ProductionOrderService.php

@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Enums\ProductionOrderActivityType;
+use App\Enums\ProductionOrderSource;
+use App\Models\ProductionOrder;
+use App\Models\User;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+
+class ProductionOrderService
+{
+    public function __construct(
+        private readonly ProductionCalendarService $calendar,
+    ) {}
+
+    /** @param array<string, mixed> $data */
+    public function create(
+        array $data,
+        User $actor,
+        ProductionOrderSource $source = ProductionOrderSource::Manual,
+    ): ProductionOrder {
+        return DB::transaction(function () use ($data, $actor, $source): ProductionOrder {
+            $items = Arr::pull($data, 'items', []);
+            unset($data['nav']);
+
+            $data['source'] = $source;
+            $data['contract_shipment_date'] = $this->contractShipmentDate($data);
+            $data['created_by'] = $actor->id;
+            $data['updated_by'] = $actor->id;
+
+            $order = ProductionOrder::query()->create($data);
+            $this->syncItems($order, $items, $actor);
+
+            return $order->load(['manager', 'items.catalogItem']);
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function update(
+        ProductionOrder $order,
+        array $data,
+        User $actor,
+        bool $syncItems,
+        array $editableItemFields = [],
+    ): ProductionOrder {
+        return DB::transaction(function () use (
+            $order,
+            $data,
+            $actor,
+            $syncItems,
+            $editableItemFields,
+        ): ProductionOrder {
+            $items = Arr::pull($data, 'items', null);
+            unset($data['nav']);
+
+            if (array_key_exists('payment_date', $data) || array_key_exists('supply_working_days', $data)) {
+                $data['contract_shipment_date'] = $this->contractShipmentDate([
+                    'payment_date' => $data['payment_date'] ?? $order->payment_date,
+                    'supply_working_days' => $data['supply_working_days'] ?? $order->supply_working_days,
+                ]);
+            }
+            $data['updated_by'] = $actor->id;
+            $order->update($data);
+
+            if ($order->wasChanged('status')) {
+                $order->activities()->create([
+                    'type' => ProductionOrderActivityType::StatusChanged,
+                    'user_id' => $actor->id,
+                    'message' => 'Статус изменён на «'.$order->statusLabel().'».',
+                ]);
+            }
+
+            if ($syncItems && is_array($items)) {
+                $this->syncItems($order, $items, $actor);
+            } elseif (is_array($items) && $editableItemFields !== []) {
+                $this->updateItemDetails($order, $items, $editableItemFields);
+            }
+
+            return $order->refresh()->load(['manager', 'items.catalogItem']);
+        });
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     * @param  array<int, string>  $editableFields
+     */
+    private function updateItemDetails(ProductionOrder $order, array $items, array $editableFields): void
+    {
+        foreach ($items as $itemData) {
+            $id = isset($itemData['id']) ? (int) $itemData['id'] : 0;
+            if ($id < 1) {
+                continue;
+            }
+
+            $order->items()->whereKey($id)->firstOrFail()->update(
+                Arr::only($itemData, $editableFields),
+            );
+        }
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     */
+    private function syncItems(ProductionOrder $order, array $items, User $actor): void
+    {
+        $keptIds = [];
+        foreach (array_values($items) as $sort => $itemData) {
+            $id = isset($itemData['id']) ? (int) $itemData['id'] : null;
+            $payload = Arr::only($itemData, [
+                'common_catalog_item_id',
+                'order_item_number',
+                'factory_number',
+                'manufacture_date',
+            ]);
+            $payload['sort'] = $sort;
+
+            if ($id !== null && $id > 0) {
+                $item = $order->items()->whereKey($id)->firstOrFail();
+                $item->update($payload);
+            } else {
+                $item = $order->items()->create($payload + ['created_by' => $actor->id]);
+            }
+            $keptIds[] = $item->id;
+        }
+
+        $order->items()->whereNotIn('id', $keptIds)->delete();
+    }
+
+    /** @param array<string, mixed> $data */
+    private function contractShipmentDate(array $data): string
+    {
+        return $this->calendar
+            ->addWorkingDays((string) $data['payment_date'], (int) $data['supply_working_days'])
+            ->toDateString();
+    }
+}

+ 45 - 0
config/access.php

@@ -66,6 +66,51 @@ return [
             'scope.brigadier' => 'Область видимости: бригадир',
         ],
     ],
+    'schedule-orders' => [
+        'name' => 'График заказов',
+        'entity' => 'production_order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'items.manage' => 'Управление МАФ',
+            'deliveries.manage' => 'Управление доставками',
+            'installations.manage' => 'Управление монтажами',
+            'documents.manage' => 'Управление документами',
+            'photos.manage' => 'Управление фотографиями',
+            'passports.manage' => 'Управление паспортами МАФ',
+            'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
+            'export' => 'Экспорт',
+            'import' => 'Импорт',
+            'documents.generate' => 'Генерация документов',
+        ],
+        'fields' => [
+            'id' => 'ID',
+            'order_number' => 'Номер заказа',
+            'order_year' => 'Год заказа',
+            'customer_name' => 'Заказчик',
+            'object_address' => 'Адрес объекта',
+            'invoice_number' => 'Номер счёта',
+            'invoice_date' => 'Дата счёта',
+            'contract_number' => 'Номер договора',
+            'contract_date' => 'Дата договора',
+            'payment_date' => 'Дата оплаты',
+            'supply_working_days' => 'Срок поставки',
+            'contract_shipment_date' => 'Дата отгрузки по договору',
+            'application_shipment_date' => 'Дата отгрузки по заявке',
+            'delivery_date' => 'Дата доставки',
+            'installation_date' => 'Дата монтажа',
+            'status' => 'Статус',
+            'execution_type' => 'Тип исполнения',
+            'manager_id' => 'Менеджер',
+            'note' => 'Примечание',
+            'order_item_number' => 'Номер заказа МАФ',
+            'factory_number' => 'Заводской номер МАФ',
+            'manufacture_date' => 'Дата производства МАФ',
+        ],
+    ],
     'catalog' => [
         'name' => 'Каталог',
         'entity' => 'product',

+ 31 - 2
config/access_routes.php

@@ -12,8 +12,8 @@ return [
         'product.search' => 'catalog.search',
         'pricing_codes.get_description' => 'pricing_codes.search',
         'pricing_codes.search' => 'pricing_codes.search',
-        'schedule.orders' => true,
-        'schedule.deliveries' => true,
+        'schedule.orders' => 'schedule-orders.view',
+        'schedule.deliveries' => 'schedule-orders.view',
         'user.profile' => 'profile.view',
         'profile.store' => 'profile.update',
         'profile.delete' => 'profile.delete',
@@ -93,6 +93,35 @@ return [
             'orders.destroy' => 'stock.orders.manage',
             'orders.import' => 'stock.orders.import',
         ],
+        'schedule.orders.' => [
+            'create' => 'schedule-orders.create',
+            'store' => 'schedule-orders.create',
+            'catalog-search' => 'schedule-orders.items.manage',
+            'export' => 'schedule-orders.export',
+            'import' => 'schedule-orders.import',
+            'show' => 'schedule-orders.view',
+            'update' => 'schedule-orders.update',
+            'destroy' => 'schedule-orders.delete',
+            'deliveries.store' => 'schedule-orders.deliveries.manage',
+            'deliveries.update' => 'schedule-orders.deliveries.manage',
+            'deliveries.destroy' => 'schedule-orders.deliveries.manage',
+            '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',
+            'export-items' => 'schedule-orders.export',
+            'deliveries.document' => 'schedule-orders.documents.generate',
+            'installations.documents' => 'schedule-orders.documents.generate',
+            'technical-documents' => 'schedule-orders.documents.generate',
+            'files.download' => 'schedule-orders.view',
+        ],
         'contract.' => [
             'index' => 'contracts.view',
             'show' => 'contracts.view',

+ 25 - 0
database/factories/ProductionCalendarDayFactory.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionCalendarDay;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionCalendarDay> */
+class ProductionCalendarDayFactory extends Factory
+{
+    protected $model = ProductionCalendarDay::class;
+
+    public function definition(): array
+    {
+        $date = fake()->unique()->dateTimeBetween('-1 year', '+2 years');
+
+        return [
+            'date' => $date->format('Y-m-d'),
+            'is_working_day' => ! in_array((int) $date->format('N'), [6, 7], true),
+            'name' => fake()->optional()->sentence(3),
+        ];
+    }
+}

+ 29 - 0
database/factories/ProductionOrderActivityFactory.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Enums\ProductionOrderActivityType;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderActivity;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderActivity> */
+class ProductionOrderActivityFactory extends Factory
+{
+    protected $model = ProductionOrderActivity::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'type' => fake()->randomElement(ProductionOrderActivityType::cases()),
+            'user_id' => User::factory(),
+            'delivery_id' => null,
+            'installation_id' => null,
+            'message' => fake()->sentence(),
+        ];
+    }
+}

+ 42 - 0
database/factories/ProductionOrderDeliveryFactory.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderDelivery> */
+class ProductionOrderDeliveryFactory extends Factory
+{
+    protected $model = ProductionOrderDelivery::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'delivery_date' => fake()->dateTimeBetween('now', '+3 months'),
+            'delivery_time' => fake()->optional()->time('H:i'),
+            'driver_id' => User::factory()->driver(),
+            'carrier' => fake()->optional()->company(),
+            'request_number' => fake()->optional()->numerify('####'),
+            'request_date' => fake()->optional()->date(),
+            'carrier_note' => fake()->optional()->sentence(),
+            'contact_position' => fake()->optional()->jobTitle(),
+            'contact_name' => fake()->optional()->name(),
+            'contact_phone' => fake()->optional()->phoneNumber(),
+            'desired_time' => fake()->optional()->time('H:i'),
+            'access_system' => fake()->optional()->sentence(),
+            'unloading_place' => fake()->optional()->sentence(),
+            'advance_notice' => fake()->optional()->sentence(),
+            'document_signing_method' => fake()->optional()->sentence(),
+            'has_passports_and_certificates' => fake()->optional()->boolean(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 48 - 0
database/factories/ProductionOrderFactory.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\User;
+use Carbon\CarbonImmutable;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrder> */
+class ProductionOrderFactory extends Factory
+{
+    protected $model = ProductionOrder::class;
+
+    public function definition(): array
+    {
+        $paymentDate = CarbonImmutable::instance(fake()->dateTimeBetween('-3 months', '+1 month'));
+        $supplyDays = fake()->numberBetween(5, 60);
+
+        return [
+            'external_id' => null,
+            'source' => ProductionOrderSource::Manual,
+            'order_number' => fake()->unique()->numerify('####.##'),
+            'order_year' => (int) $paymentDate->format('Y'),
+            'customer_name' => fake()->company(),
+            'object_address' => fake()->address(),
+            'invoice_number' => fake()->numerify('СЧ-######'),
+            'invoice_date' => $paymentDate->subDays(2),
+            'contract_number' => fake()->optional()->numerify('Д-######'),
+            'contract_date' => fake()->optional()->passthrough($paymentDate->subDays(10)),
+            'payment_date' => $paymentDate,
+            'supply_working_days' => $supplyDays,
+            'contract_shipment_date' => $paymentDate->addWeekdays($supplyDays),
+            'application_shipment_date' => null,
+            'status' => ProductionOrderStatus::Placed,
+            'execution_type' => fake()->randomElement(ProductionOrderExecutionType::cases()),
+            'manager_id' => User::factory()->manager(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 29 - 0
database/factories/ProductionOrderInstallationFactory.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderInstallation;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderInstallation> */
+class ProductionOrderInstallationFactory extends Factory
+{
+    protected $model = ProductionOrderInstallation::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'installation_date' => fake()->dateTimeBetween('now', '+3 months'),
+            'installation_days' => fake()->numberBetween(1, 20),
+            'brigadier_id' => User::factory()->brigadier(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 31 - 0
database/factories/ProductionOrderItemFactory.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderItem> */
+class ProductionOrderItemFactory extends Factory
+{
+    protected $model = ProductionOrderItem::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'common_catalog_item_id' => CommonCatalogItem::factory(),
+            'external_id' => null,
+            'order_item_number' => fake()->numerify('МАФ-######'),
+            'factory_number' => null,
+            'manufacture_date' => null,
+            'passport_file_id' => null,
+            'sort' => 0,
+            'created_by' => null,
+        ];
+    }
+}

+ 7 - 0
database/factories/UserFactory.php

@@ -69,6 +69,13 @@ class UserFactory extends Factory
         ]);
     }
 
+    public function driver(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'role' => 'driver',
+        ]);
+    }
+
     /**
      * Indicate that the model's email address should be unverified.
      */

+ 137 - 0
database/migrations/2026_09_03_000001_create_production_order_tables.php

@@ -0,0 +1,137 @@
+<?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_calendar_days', function (Blueprint $table): void {
+            $table->id();
+            $table->date('date')->unique();
+            $table->boolean('is_working_day');
+            $table->string('name')->nullable();
+            $table->timestamps();
+        });
+
+        Schema::create('production_orders', function (Blueprint $table): void {
+            $table->id();
+            $table->string('external_id')->nullable()->unique();
+            $table->string('source', 32)->default('manual');
+            $table->string('order_number', 100);
+            $table->unsignedSmallInteger('order_year');
+            $table->string('customer_name', 500);
+            $table->text('object_address');
+            $table->string('invoice_number', 100);
+            $table->date('invoice_date')->nullable();
+            $table->string('contract_number', 100)->nullable();
+            $table->date('contract_date')->nullable();
+            $table->date('payment_date');
+            $table->unsignedSmallInteger('supply_working_days');
+            $table->date('contract_shipment_date');
+            $table->date('application_shipment_date')->nullable();
+            $table->string('status', 40)->default('placed');
+            $table->string('execution_type', 40);
+            $table->foreignId('manager_id')->constrained('users')->restrictOnDelete();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->unique(['order_number', 'order_year']);
+            $table->index(['status', 'contract_shipment_date']);
+            $table->index(['manager_id', 'created_at']);
+        });
+
+        Schema::create('production_order_items', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->foreignId('common_catalog_item_id')->constrained('common_catalog_items')->restrictOnDelete();
+            $table->string('external_id')->nullable();
+            $table->string('order_item_number', 100);
+            $table->string('factory_number', 100)->nullable();
+            $table->date('manufacture_date')->nullable();
+            $table->foreignId('passport_file_id')->nullable()->constrained('files')->nullOnDelete();
+            $table->unsignedInteger('sort')->default(0);
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->unique(['production_order_id', 'external_id'], 'prod_order_items_order_external_unique');
+            $table->index(['production_order_id', 'order_item_number'], 'prod_order_items_order_number_index');
+            $table->index(['common_catalog_item_id', 'factory_number'], 'prod_order_items_catalog_factory_index');
+        });
+
+        Schema::create('production_order_deliveries', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->date('delivery_date');
+            $table->time('delivery_time')->nullable();
+            $table->foreignId('driver_id')->constrained('users')->restrictOnDelete();
+            $table->string('carrier')->nullable();
+            $table->string('request_number', 100)->nullable();
+            $table->date('request_date')->nullable();
+            $table->text('carrier_note')->nullable();
+            $table->string('contact_position')->nullable();
+            $table->string('contact_name')->nullable();
+            $table->string('contact_phone', 100)->nullable();
+            $table->time('desired_time')->nullable();
+            $table->text('access_system')->nullable();
+            $table->text('unloading_place')->nullable();
+            $table->text('advance_notice')->nullable();
+            $table->text('document_signing_method')->nullable();
+            $table->boolean('has_passports_and_certificates')->nullable();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index(['delivery_date', 'driver_id'], 'prod_order_deliveries_date_driver_index');
+        });
+
+        Schema::create('production_order_installations', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->date('installation_date');
+            $table->unsignedSmallInteger('installation_days');
+            $table->foreignId('brigadier_id')->constrained('users')->restrictOnDelete();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index(['installation_date', 'brigadier_id'], 'prod_order_installations_date_brig_index');
+        });
+
+        Schema::create('production_order_activities', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->string('type', 40);
+            $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('delivery_id')->nullable()->constrained('production_order_deliveries')->nullOnDelete();
+            $table->foreignId('installation_id')->nullable()->constrained('production_order_installations')->nullOnDelete();
+            $table->text('message');
+            $table->timestamp('created_at')->useCurrent();
+
+            $table->index(['production_order_id', 'created_at'], 'prod_order_activities_order_created_index');
+            $table->index(['type', 'created_at'], 'prod_order_activities_type_created_index');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('production_order_activities');
+        Schema::dropIfExists('production_order_installations');
+        Schema::dropIfExists('production_order_deliveries');
+        Schema::dropIfExists('production_order_items');
+        Schema::dropIfExists('production_orders');
+        Schema::dropIfExists('production_calendar_days');
+    }
+};

+ 26 - 0
database/migrations/2026_09_03_000002_sync_production_order_permissions.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+use Database\Seeders\RbacSeeder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (
+            Schema::hasTable('roles')
+            && Schema::hasTable('permissions')
+            && Schema::hasTable('role_permissions')
+        ) {
+            app(RbacSeeder::class)->run();
+        }
+    }
+
+    public function down(): void
+    {
+        // Системные роли и permissions остаются в истории RBAC.
+    }
+};

+ 28 - 0
database/migrations/2026_09_03_000003_link_production_order_installations_to_schedules.php

@@ -0,0 +1,28 @@
+<?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::table('schedules', function (Blueprint $table): void {
+            $table->foreignId('production_order_installation_id')
+                ->nullable()
+                ->after('order_id')
+                ->constrained('production_order_installations')
+                ->cascadeOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('schedules', function (Blueprint $table): void {
+            $table->dropConstrainedForeignId('production_order_installation_id');
+        });
+    }
+};

+ 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');
+    }
+};

+ 24 - 0
database/migrations/2026_09_03_000005_add_production_order_notification_settings.php

@@ -0,0 +1,24 @@
+<?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::table('user_notification_settings', function (Blueprint $table): void {
+            $table->json('production_order_settings')->nullable()->after('schedule_settings');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('user_notification_settings', function (Blueprint $table): void {
+            $table->dropColumn('production_order_settings');
+        });
+    }
+};

+ 26 - 0
database/migrations/2026_09_03_000006_resync_production_order_field_permissions.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+use Database\Seeders\RbacSeeder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (
+            Schema::hasTable('roles')
+            && Schema::hasTable('permissions')
+            && Schema::hasTable('role_permissions')
+        ) {
+            app(RbacSeeder::class)->run();
+        }
+    }
+
+    public function down(): void
+    {
+        // Системные роли и permissions восстанавливаются текущим RbacSeeder.
+    }
+};

+ 104 - 0
database/migrations/2026_09_03_000007_link_reclamations_to_production_orders.php

@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::unprepared('DROP VIEW IF EXISTS reclamations_view');
+
+        Schema::table('reclamations', function (Blueprint $table): void {
+            $table->unsignedBigInteger('order_id')->nullable()->change();
+            $table->foreignId('production_order_id')
+                ->nullable()
+                ->after('order_id')
+                ->constrained('production_orders')
+                ->restrictOnDelete();
+        });
+
+        Schema::create('production_order_item_reclamation', function (Blueprint $table): void {
+            $table->unsignedBigInteger('reclamation_id');
+            $table->unsignedBigInteger('production_order_item_id');
+            $table->timestamps();
+
+            $table->primary(['reclamation_id', 'production_order_item_id'], 'prod_item_reclamation_primary');
+            $table->foreign('reclamation_id', 'prod_item_reclamation_reclamation_fk')
+                ->references('id')
+                ->on('reclamations')
+                ->cascadeOnDelete();
+            $table->foreign('production_order_item_id', 'prod_item_reclamation_item_fk')
+                ->references('id')
+                ->on('production_order_items')
+                ->restrictOnDelete();
+        });
+
+        $this->createView();
+    }
+
+    public function down(): void
+    {
+        DB::unprepared('DROP VIEW IF EXISTS reclamations_view');
+        Schema::dropIfExists('production_order_item_reclamation');
+
+        Schema::table('reclamations', function (Blueprint $table): void {
+            $table->dropConstrainedForeignId('production_order_id');
+            $table->unsignedBigInteger('order_id')->nullable(false)->change();
+        });
+
+        DB::unprepared(<<<'SQL'
+            CREATE VIEW reclamations_view AS
+                SELECT r.*,
+                    u.name AS user_name,
+                    u1.name AS brigadier_name,
+                    o.year,
+                    o.name AS order_name,
+                    o.object_address,
+                    o.district_name,
+                    o.area_name,
+                    o.object_type_name,
+                    rs.name AS status_name,
+                    o.year AS maf_installation_year,
+                    rt.code AS reclamation_type_code,
+                    rt.name AS reclamation_type_name
+                FROM reclamations r
+                    LEFT JOIN users u ON r.user_id = u.id
+                    LEFT JOIN users u1 ON r.brigadier_id = u1.id
+                    LEFT JOIN reclamation_statuses rs ON r.status_id = rs.id
+                    LEFT JOIN orders_view o ON r.order_id = o.id
+                    INNER JOIN reclamation_types rt ON r.reclamation_type_id = rt.id
+            SQL);
+    }
+
+    private function createView(): void
+    {
+        DB::unprepared(<<<'SQL'
+            CREATE VIEW reclamations_view AS
+                SELECT r.*,
+                    u.name AS user_name,
+                    u1.name AS brigadier_name,
+                    COALESCE(o.year, po.order_year) AS year,
+                    COALESCE(o.name, CONCAT(po.customer_name, ' — ', po.object_address)) AS order_name,
+                    COALESCE(o.object_address, po.object_address) AS object_address,
+                    o.district_name,
+                    o.area_name,
+                    COALESCE(o.object_type_name, po.execution_type) AS object_type_name,
+                    rs.name AS status_name,
+                    COALESCE(o.year, po.order_year) AS maf_installation_year,
+                    rt.code AS reclamation_type_code,
+                    rt.name AS reclamation_type_name
+                FROM reclamations r
+                    LEFT JOIN users u ON r.user_id = u.id
+                    LEFT JOIN users u1 ON r.brigadier_id = u1.id
+                    LEFT JOIN reclamation_statuses rs ON r.status_id = rs.id
+                    LEFT JOIN orders_view o ON r.order_id = o.id
+                    LEFT JOIN production_orders po ON r.production_order_id = po.id
+                    INNER JOIN reclamation_types rt ON r.reclamation_type_id = rt.id
+            SQL);
+    }
+};

+ 43 - 2
database/seeders/RbacSeeder.php

@@ -132,6 +132,8 @@ class RbacSeeder extends Seeder
             'areas.ajax.view',
             'catalog.search',
             'common-catalog.view',
+            'schedule-orders.view',
+            'schedule-orders.chat.create',
             'documents.view',
             'stock.view',
             'stock.reserve',
@@ -149,6 +151,14 @@ class RbacSeeder extends Seeder
             fn (string $field): string => "common-catalog.fields.{$field}.view",
             $commonCatalogPublicFields,
         ));
+        $scheduleOrderPublicFields = array_diff(
+            array_keys(config('access.schedule-orders.fields', [])),
+            ['application_shipment_date'],
+        );
+        $auth = array_merge($auth, array_map(
+            fn (string $field): string => "schedule-orders.fields.{$field}.view",
+            $scheduleOrderPublicFields,
+        ));
 
         $manager = array_merge($auth, [
             'orders.update',
@@ -216,10 +226,40 @@ class RbacSeeder extends Seeder
             'schedule.scope.manager',
         ]);
 
-        $assistantHead = array_values(array_diff(
+        $scheduleOrderPermissions = array_values(array_filter(
             $all,
-            $this->commonCatalogAdminOnlyPermissions(),
+            static fn (string $slug): bool => str_starts_with($slug, 'schedule-orders.'),
         ));
+        $scheduleOrderAssistantPermissions = array_merge(
+            [
+                'schedule-orders.view',
+                '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',
+            ],
+            array_map(
+                fn (string $field): string => "schedule-orders.fields.{$field}.view",
+                $scheduleOrderPublicFields,
+            ),
+            [
+                'schedule-orders.fields.object_address.update',
+                'schedule-orders.fields.note.update',
+                'schedule-orders.fields.factory_number.update',
+                'schedule-orders.fields.manufacture_date.update',
+            ],
+        );
+        $assistantHead = array_values(array_unique(array_merge(
+            array_diff(
+                $all,
+                $this->commonCatalogAdminOnlyPermissions(),
+                $scheduleOrderPermissions,
+            ),
+            $scheduleOrderAssistantPermissions,
+        )));
 
         $rolePermissions = [
             Role::ADMIN => $all,
@@ -227,6 +267,7 @@ class RbacSeeder extends Seeder
             Role::MANAGER => $manager,
             Role::BRIGADIER => $brigadier,
             Role::WAREHOUSE_HEAD => $warehouseHead,
+            Role::DRIVER => $auth,
         ];
 
         foreach ($rolePermissions as $roleSlug => $permissionSlugs) {

+ 55 - 22
docs/refactor/plan.md

@@ -85,8 +85,8 @@
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
 | 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
-| 9. Графики заказов и доставок | Готов к реализации без интеграции с 1С | Ответы заказчика и шаблон заявки получены; ручное ядро, доставки, монтажи, документы, уведомления и аудит разблокированы. Контракт обмена требует специалиста 1С. |
-| 10. Рекламации из графика заказов | ТЗ получено, реализация после основы графика | Подтверждены выбор отдельных МАФ и тип `Прочее`, но новая связь рекламации зависит от моделей заказа и его экземпляров. |
+| 9. Графики заказов и доставок | Ручной контур и XLS реализованы; интеграция в работе | Нормализованное ядро, таблица и карточка заказа по ТЗ, несколько доставок/монтажей, недельный график доставок, файлы, уведомления, документы, экспорт и рекламации приняты пользователем. XLS-импорт реализован и покрыт автотестами; 1С и перенос истории ожидают согласования интеграционного контракта. |
+| 10. Рекламации из графика заказов | Реализован и проверен | Из карточки заказа создаётся рекламация типа `Прочее` по выбранным экземплярам МАФ; она доступна во вкладке `Все`, исключена из `ДКР` и не имеет платёжных документов. Сценарий принят пользователем вместе с ручным контуром графика заказов. |
 
 ## Этап 1. Реорганизация меню и заглушки
 
@@ -245,10 +245,42 @@
 
 ## Этап 9. Графики заказов и доставок
 
-Статус: **ТЗ, ответы заказчика и шаблон заявки получены; можно реализовывать весь ручной контур, кроме обмена с 1С и неоднозначной части XLS-импорта**.
+Статус: **ручной контур реализован и проверен пользователем; XLS-импорт реализован и покрыт автотестами; в работе остаются обмен с 1С и перенос истории**.
 
 Старый `График отгрузок` является источником существующих данных и сценариев, но не переносится одной таблицей. Архитектура нового модуля должна оставаться нормализованной и расширяемой под дополнительные требования заказчика.
 
+### Техническая схема
+
+- `production_orders` — заголовок производственного заказа; не смешивается с ДКР-`orders`.
+- `production_order_items` — отдельный экземпляр МАФ со ссылкой на `common_catalog_items` и собственным паспортом.
+- `production_order_deliveries` — неограниченное число доставок с водителем и данными заявки.
+- `production_order_installations` — неограниченное число этапов монтажа с бригадиром.
+- `production_order_activities` — журнал только согласованных значимых событий.
+- `production_calendar_days` — переносы и исключения производственного календаря; базовое правило без исключений — понедельник–пятница.
+- Фиксированные статусы и типы исполнения оформляются PHP-enum со строковым значением в БД.
+- Номер заказа уникален в пределах календарного года; внешний GUID хранится отдельно и остаётся nullable до согласования 1С.
+- Данные доставок, монтажей и другие ручные поля не включаются в payload замены из 1С.
+
+### Подэтапы реализации
+
+1. **9.1. Основа данных и права** — таблицы, модели, enum, роль `driver`, permissions и расчёт даты отгрузки.
+2. **9.2. Ручной заказ** — список с фильтрами, карточка, создание/редактирование, отдельные экземпляры МАФ из общего каталога.
+3. **9.3. Доставки и монтажи** — несколько записей на заказ, выбор водителя/бригадира, недельный график доставок и связь с графиком монтажей.
+4. **9.4. Файлы и коммуникации** — документы, фотографии, чат заказа, скан паспорта каждого экземпляра МАФ.
+5. **9.5. События и документы** — аудит, настраиваемые уведомления, экспорт МАФ, монтажный пакет, заявка на доставку и техдокументация.
+6. **9.6. Обмен данными** — экспорт списка, XLS-импорт по согласованному формату, API 1С с журналом загрузок и отдельная миграция старых данных.
+7. **9.7. Рекламации и приёмка** — связь с этапом 10, полные автотесты и пользовательская проверка.
+
+Текущий подэтап: **9.6. Обмен данными**; фоновый экспорт списка и атомарный XLS-импорт реализованы, а 1С и перенос истории ожидают согласования контракта. Подэтап 9.7 реализован и принят пользователем.
+
+- [x] **9.1. Основа данных и права:** добавлены нормализованные таблицы и модели, enum статусов/типов, производственный календарь, роль `driver`, действия и permissions отдельных полей.
+- [x] **9.2. Ручной заказ:** реализованы список с фильтрами, цветовой статус, карточка, создание/редактирование, расчёт договорной даты отгрузки и отдельные экземпляры МАФ из общего каталога.
+- [x] **9.3. Доставки и монтажи:** реализованы несколько доставок/монтажей на заказ, выбор водителя/бригадира, недельный график доставок и синхронизация с действующим графиком монтажей.
+- [x] **9.4. Файлы и коммуникации:** реализованы документы, фотографии, чат и скан паспорта каждого экземпляра МАФ; для файлов используются MIME-иконки и просмотр изображений.
+- [x] **9.5. События и документы:** реализованы согласованный аудит, настраиваемые уведомления, экспорт МАФ, заявка на доставку, монтажный пакет и архив техдокументации.
+- [ ] **9.6. Обмен данными:** реализовать согласованную часть XLS/1С и отдельный перенос истории.
+- [x] **9.7. Рекламации и автопроверка:** этап 10 связан с заказами, полный набор автоматических тестов пройден, интерфейс принят пользователем.
+
 - [x] Изучить старый `График отгрузок` в `manager.stroyprofit.com`.
 - [x] Описать найденный HTTP-обмен с 1С и состав текущего payload.
 - [x] Проанализировать существующие статусы, ручные поля, вложения и несколько доставок по заказу.
@@ -263,36 +295,37 @@
 - [ ] Согласовать принадлежность полей источнику или ручному редактированию, чтобы повторная загрузка не уничтожала плановые данные.
 - [x] Согласовать календарь рабочих дней и значения полей дат отгрузки.
 - [x] Зафиксировать `Водитель` как отдельную роль в ролевой модели.
-- [ ] Спроектировать нормализованные таблицы и связи после получения ТЗ.
+- [x] Спроектировать нормализованные таблицы и связи после получения ТЗ.
 - [ ] Спроектировать загрузку данных через 1С.
 - [ ] Реализовать безопасный идемпотентный импорт с журналом запусков и ошибок.
-- [ ] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
+- [x] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
 - [ ] Подготовить отдельную миграцию исторических данных старого Manager с отчётом о конфликтах и дублях.
-- [ ] Добавить карточку заказа с выбором одного или нескольких МАФ.
-- [ ] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал действий.
-- [ ] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
-- [ ] Реализовать импорт/экспорт списка заказов и экспорт МАФ одного заказа.
-- [ ] Реализовать пакеты документов для монтажа, доставки и технической документации.
-- [ ] Добавить создание рекламации из заказа через текущую форму.
+- [x] Добавить карточку заказа с выбором одного или нескольких МАФ.
+- [x] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал согласованных действий.
+- [x] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
+- [x] Реализовать экспорт списка заказов с активными фильтрами и экспорт МАФ одного заказа.
+- [x] Реализовать ручной XLS-импорт: обновление по `ID`, создание при пустом `ID`, ошибка при неизвестном `ID`; экземпляры МАФ принимать с отдельного листа.
+- [x] Реализовать пакеты документов для монтажа, доставки и технической документации.
+- [x] Добавить создание рекламации из заказа через текущую форму.
 - [x] Получить базовые требования и шаблон заявки для `Графика доставок`.
-- [ ] Реализовать `График доставок` в виде недельного списка.
-- [ ] Добавить ручное планирование занятости водителей.
-- [ ] Связать доставки с адресами из графика заказов.
-- [ ] Проверить связи монтажей с ДКР-площадками и графиком заказов.
+- [x] Реализовать `График доставок` в виде недельного списка.
+- [x] Добавить ручное планирование занятости водителей.
+- [x] Связать доставки с адресами из графика заказов.
+- [x] Проверить связи монтажей с ДКР-площадками и графиком заказов.
 
 ## Этап 10. Рекламации из графика заказов
 
-Статус: **ТЗ получено; реализация после базовых моделей этапа 9**.
+Статус: **реализован и проверен пользователем**.
 
-- [ ] Реализовать создание рекламации из графика заказов по выбранным МАФ с типом `other` (`Прочее`).
-- [ ] В карточке заказа дать выбор одного или нескольких МАФ.
-- [ ] Открывать текущую форму создания рекламации с предзаполненными данными.
-- [ ] Проверить, что рекламация попадает во вкладку `Все` и не попадает во вкладку `ДКР`.
-- [ ] Проверить, что платежные документы для такой рекламации недоступны.
+- [x] Реализовать создание рекламации из графика заказов по выбранным МАФ с типом `other` (`Прочее`).
+- [x] В карточке заказа дать выбор одного или нескольких МАФ.
+- [x] Открывать текущую карточку рекламации с предзаполненными данными заказа и выбранными МАФ.
+- [x] Проверить, что рекламация попадает во вкладку `Все` и не попадает во вкладку `ДКР`.
+- [x] Проверить, что платежные документы для такой рекламации недоступны.
 
 ## Этап 11. Проверка
 
-- [ ] Запустить `make test` внутри контейнеров.
+- [x] Запустить `make test` внутри контейнеров (1104 теста, 3037 проверок; функциональных ошибок нет, отсутствует только драйвер покрытия).
 - [ ] Проверить меню в браузере под админом.
 - [ ] Проверить меню под пользователем с ограниченными правами.
 - [ ] Проверить, что существующие маршруты ДКР работают после перегруппировки меню.

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

@@ -0,0 +1,419 @@
+# Сценарий приёмочной проверки графика заказов и рекламаций
+
+Дата подготовки: **04.09.2026**.
+
+Статус: **проверено и принято пользователем 08.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. XLS-импорт уже реализован и покрыт отдельными автоматическими тестами; для 1С и переноса истории остаются интеграционные вопросы.
+
+## 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. Итоговый чек-лист приёмки
+
+- [x] Заказ создаётся и повторно открывается без потери данных.
+- [x] Одинаковые МАФ хранятся отдельными экземплярами.
+- [x] Картинки каталога отображаются и открываются.
+- [x] Договорная дата рассчитывается по рабочим дням.
+- [x] Строки списка окрашены по статусу, закрытые полупрозрачны, просрочка заметна.
+- [x] Все фильтры работают; даты доставки и монтажа выбираются из значений.
+- [x] У одного заказа работают несколько доставок и монтажей.
+- [x] Доставка видна в недельном графике, монтаж — в графике монтажей.
+- [x] Документы, фотографии и паспорта загружаются и открываются корректно.
+- [x] Экспорт и все пакеты документов формируются.
+- [x] Журнал содержит только согласованные значимые действия.
+- [x] Чат работает внутри конкретного заказа.
+- [x] Уведомления учитывают настройки событий и каналов.
+- [x] Права администратора, помощника, менеджера, водителя и бригадира соответствуют ожиданиям.
+- [x] Рекламация создаётся только по выбранным МАФ и получает тип `Прочее`.
+- [x] Рекламация видна в `Все`, отсутствует в `ДКР`, платёжные документы недоступны.
+- [x] Существующие площадки, график монтажей и рекламации ДКР продолжают работать.
+
+Итог проверки: **принято**.

+ 6 - 8
docs/refactor/tz-order-schedule-answers.md

@@ -92,7 +92,9 @@
 
 13. **Обновление ручным импортом**
 
-    Нужно предусмотреть обновление заказа из файла по внутреннему `ID` заказа.
+    Заказ с заполненным внутренним `ID` обновляется. Пустой `ID` означает создание нового заказа. Если заполненный `ID` не найден, импорт строки завершается ошибкой без создания заказа.
+
+    Экземпляры МАФ передаются на отдельном листе XLS и связываются с заказом через строку основного листа.
 
 14. **Паспорт МАФ**
 
@@ -165,6 +167,8 @@
 - роль `Водитель` и исходные права помощника руководителя;
 - события уведомлений и состав аудита;
 - экспорт списка с учётом фильтров;
+- правила XLS-импорта: обновление по `ID`, создание при пустом `ID`, ошибка при неизвестном `ID`;
+- отдельный лист XLS для экземпляров МАФ;
 - паспорт как готовый скан;
 - генерация заявки на доставку по полученному шаблону;
 - отдельная отчётность рекламаций типа `Прочее`.
@@ -178,10 +182,4 @@
 3. Полный payload и контракт API.
 4. Правила повторной синхронизации, конфликтов, аннулирования и частичного изменения состава заказа.
 
-### Нужно зафиксировать до импорта XLS
-
-5. Может ли импорт создавать новые заказы или только обновлять существующие по `ID`.
-6. Как обрабатывать пустой, неизвестный или принадлежащий другому окружению `ID`.
-7. Нужен ли отдельный лист с экземплярами МАФ при импорте, если экспорт содержит агрегированное оборудование в одной колонке.
-
-Эти вопросы не блокируют разработку ручного ядра графика заказов, доставок, монтажей, файлов, уведомлений, аудита и экспорта заявки на доставку.
+Ручной XLS-импорт реализован по согласованным правилам. Открытые вопросы выше блокируют только интеграцию с 1С.

+ 4 - 9
docs/refactor/tz-order-schedule.md

@@ -250,13 +250,14 @@
 - определены события уведомлений и их получатели;
 - аудит содержит изменения статуса и добавление в графики без диффа обычных полей;
 - экспорт учитывает фильтры и агрегирует оборудование в одной колонке;
-- импорт должен уметь обновлять заказ по его внутреннему ID;
+- импорт обновляет заказ по внутреннему `ID`, создаёт новый заказ при пустом `ID` и возвращает ошибку при неизвестном `ID`;
+- экземпляры МАФ импортируются с отдельного листа XLS;
 - паспорт загружается готовым сканом;
 - реализуются все перечисленные кнопки карточки;
 - рекламации `Прочее` и `ДКР` имеют разную отчётность;
 - получен шаблон заявки на доставку.
 
-Остаются вопросы по 1С и точным правилам ручного импорта.
+Остаются вопросы по интеграции с 1С. Правила ручного XLS-импорта согласованы.
 
 ### Блокирующие проектирование и обмен с 1С
 
@@ -265,12 +266,6 @@
 3. Какие поля 1С являются первичными, а какие локальные изменения должны сохраняться при повторной синхронизации?
 4. Как обрабатывать повторную загрузку, аннулирование и частичное изменение состава заказа?
 
-### Требуют уточнения до реализации ручного импорта
-
-5. Может ли XLS создавать новые заказы или только обновлять существующие по `ID`?
-6. Как обрабатывать отсутствующий или неизвестный `ID`?
-7. Как импортировать отдельные экземпляры МАФ, если оборудование в общем экспорте агрегировано в одной колонке?
-
 ## 13. Рекомендуемая последовательность реализации
 
 1. Спроектировать нормализованные таблицы заказов, экземпляров МАФ, доставок, монтажей и аудита.
@@ -278,6 +273,6 @@
 3. Реализовать справочники, права и базовую карточку с ручным созданием заказа.
 4. Добавить документы, фотографии, чат, паспорт МАФ и техническую документацию.
 5. Реализовать доставки, монтажи, уведомления, аудит и заявку на доставку.
-6. Реализовать экспорт с фильтрами; ручной импорт — после уточнения оставшихся правил.
+6. Реализовать экспорт с фильтрами и ручной XLS-импорт по согласованным правилам — реализовано.
 7. Реализовать рекламации типа `Прочее` по выбранным экземплярам МАФ.
 8. После контакта со специалистом 1С спроектировать и реализовать идемпотентный API обмена с журналом загрузок.

+ 6 - 5
docs/refactor/tz-schedules.md

@@ -149,8 +149,7 @@
 - может ли 1С передавать стабильный GUID заказа вместо ключа, собранного из номера и года;
 - может ли 1С передавать артикул общего каталога и идентификатор каждого экземпляра МАФ;
 - какие поля принадлежат 1С, а какие должны сохраняться при повторной синхронизации;
-- как 1С сообщает аннулирование и частичное изменение состава заказа;
-- может ли ручной XLS-импорт создавать новые заказы и как передавать в нём отдельные экземпляры МАФ.
+- как 1С сообщает аннулирование и частичное изменение состава заказа.
 
 ## 6. График доставок
 
@@ -272,13 +271,14 @@
 - сериализованные массивы в текстовых колонках как основная модель данных;
 - перезапись ручных плановых данных значениями по умолчанию при импорте из 1С.
 
+Ручной XLS-импорт реализован по согласованным правилам: пустой `ID` создаёт заказ, неизвестный `ID` даёт ошибку, экземпляры МАФ передаются на отдельном листе. Перед записью проверяется весь файл, изменения применяются атомарно.
+
 Открыто:
 
 - точные имена таблиц и моделей;
 - стабильные идентификаторы заказа и экземпляров МАФ в 1С;
 - принадлежность синхронизируемых и локально редактируемых полей;
 - источник и обновление производственного календаря;
-- формат импорта отдельных экземпляров МАФ и обработка отсутствующего/неизвестного ID;
 - способ связи с существующими `Order`, `ProductSKU`, `Schedule` и `Reclamation` без смешивания с ДКР;
 - правила миграции дублей и некорректных дат старого `graf1`.
 
@@ -324,7 +324,8 @@
 - [x] Согласовать календарь, даты отгрузки, несколько доставок/монтажей, роль водителя, права, уведомления и аудит.
 - [x] Получить и разобрать шаблон заявки на доставку `templates/DeliveryRequest.xls`.
 - [ ] Согласовать со специалистом 1С контракт API, идентификаторы и правила повторной синхронизации.
-- [ ] Уточнить создание новых заказов и передачу экземпляров МАФ через ручной XLS-импорт.
+- [x] Уточнить создание новых заказов и передачу экземпляров МАФ через ручной XLS-импорт.
+- [x] Реализовать атомарный ручной XLS-импорт заказов и отдельных экземпляров МАФ.
 - [x] Составить базовый список полей для графика доставок.
 - [ ] Реализовать карточку заказа с выбором одного или нескольких МАФ.
 - [ ] Добавить действие `Добавить рекламацию`.
@@ -347,4 +348,4 @@
 
 ## 13. Открытые вопросы
 
-Актуальный перечень сформирован в [ответах заказчика](tz-order-schedule-answers.md#4-что-осталось-уточнить). Вопросы по 1С блокируют только интеграцию с 1С. Для ручного импорта отдельно нужно уточнить создание новых заказов, обработку неизвестного ID и передачу экземпляров МАФ.
+Актуальный перечень сформирован в [ответах заказчика](tz-order-schedule-answers.md#4-что-осталось-уточнить). Ручной XLS-импорт реализован. Оставшиеся вопросы блокируют только интеграцию с 1С.

+ 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">

+ 18 - 1
resources/views/partials/table.blade.php

@@ -103,7 +103,9 @@
             <tr
                 @if($rowAnchor) id="{{ $rowAnchor }}" data-row-id="{{ $rowId }}" @endif
                 @if($rowHref) data-row-href="{{ $rowHref }}" @endif
-                @if($id === 'notifications')
+                @if($id === 'schedule_orders')
+                    class="table-{{ $string->status->color() }} {{ $string->status === \App\Enums\ProductionOrderStatus::Closed ? 'opacity-50' : '' }}"
+                @elseif($id === 'notifications')
                     data-notification-id="{{ $string->id }}"
                     data-notification-read="{{ $string->isRead() ? '1' : '0' }}"
                     data-read-class="{{ match($string->type) { 'reclamation' => 'notification-read-reclamation', 'platform' => 'notification-read-platform', 'schedule' => 'notification-read-schedule', default => 'notification-read-platform' } }}"
@@ -199,6 +201,21 @@
                                     <option value="{{ $statusId }}" @selected($statusId == $string->status_id)>{{ $statusName }}</option>
                                 @endforeach
                             </select>
+                        @elseif($id === 'schedule_orders' && $headerName === 'status_name')
+                            <span class="badge text-bg-{{ $string->status->color() }}">
+                                {{ $string->status_name }}
+                            </span>
+                        @elseif($id === 'schedule_orders' && in_array($headerName, ['delivery_dates', 'installation_dates'], true))
+                            @if($string->$headerName)
+                                <span class="text-nowrap">{!! $string->$headerName !!}</span>
+                            @else
+                                <span class="text-muted">—</span>
+                            @endif
+                        @elseif($id === 'schedule_orders' && $headerName === 'order_number')
+                            <span class="{{ $string->isShipmentOverdue() ? 'badge text-bg-danger' : '' }}"
+                                  @if($string->isShipmentOverdue()) title="Дата отгрузки по договору просрочена" @endif>
+                                {{ $string->order_number }}
+                            </span>
                         @elseif($headerName === 'tsn_number' && $string->$headerName)
                             <span data-bs-toggle="tooltip"
                                   data-bs-placement="top"

+ 92 - 0
resources/views/production_order_deliveries/index.blade.php

@@ -0,0 +1,92 @@
+@extends('layouts.app')
+
+@section('content')
+    @php
+        $dayNames = [
+            1 => 'Понедельник',
+            2 => 'Вторник',
+            3 => 'Среда',
+            4 => 'Четверг',
+            5 => 'Пятница',
+            6 => 'Суббота',
+            7 => 'Воскресенье',
+        ];
+    @endphp
+
+    <div class="row mb-3 align-items-center">
+        <div class="col-md-5">
+            <h3 class="mb-0">График доставок</h3>
+        </div>
+        <div class="col-md-7">
+            <div class="d-flex flex-wrap gap-2 justify-content-md-end align-items-center">
+                <a href="{{ route('schedule.deliveries', ['week' => $previousWeek]) }}" class="btn btn-sm btn-primary" title="Предыдущая неделя">
+                    <i class="bi bi-arrow-left"></i>
+                </a>
+                <input type="date" class="form-control form-control-sm" style="max-width: 160px"
+                       value="{{ $weekStart->toDateString() }}"
+                       onchange="window.location='{{ route('schedule.deliveries') }}?week='+this.value"
+                       aria-label="Первый день недели">
+                <span class="small text-muted">
+                    {{ $weekStart->format('d.m.Y') }} — {{ $weekEnd->format('d.m.Y') }}
+                </span>
+                <a href="{{ route('schedule.deliveries', ['week' => now()->toDateString()]) }}" class="btn btn-sm btn-outline-primary">
+                    Текущая неделя
+                </a>
+                <a href="{{ route('schedule.deliveries', ['week' => $nextWeek]) }}" class="btn btn-sm btn-primary" title="Следующая неделя">
+                    <i class="bi bi-arrow-right"></i>
+                </a>
+            </div>
+        </div>
+    </div>
+
+    <div class="table-responsive js-subtable-scroll">
+        <table class="table table-sm table-bordered align-middle schedule-week-table">
+            <thead>
+            <tr>
+                <th>День</th>
+                <th>Время</th>
+                <th>Заказ</th>
+                <th>Заказчик</th>
+                <th>Адрес</th>
+                <th>Водитель</th>
+                <th>Перевозчик</th>
+                <th>Примечание</th>
+            </tr>
+            </thead>
+            <tbody>
+            @foreach($days as $date => $day)
+                @php($dayDeliveries = $deliveries->get($date, collect()))
+                @forelse($dayDeliveries as $delivery)
+                    <tr class="{{ $loop->last ? 'schedule-week-day-end' : '' }}">
+                        @if($loop->first)
+                            <td rowspan="{{ $dayDeliveries->count() }}" class="text-nowrap bg-body-secondary">
+                                <strong>{{ $dayNames[$day->dayOfWeekIso] }}</strong><br>
+                                {{ $day->format('d.m.Y') }}
+                            </td>
+                        @endif
+                        <td class="text-nowrap">{{ substr((string) $delivery->delivery_time, 0, 5) ?: '—' }}</td>
+                        <td>
+                            <a href="{{ route('schedule.orders.show', ['productionOrder' => $delivery->productionOrder, 'nav' => $nav]) }}">
+                                {{ $delivery->productionOrder->order_number }}
+                            </a>
+                        </td>
+                        <td>{{ $delivery->productionOrder->customer_name }}</td>
+                        <td>{{ $delivery->productionOrder->object_address }}</td>
+                        <td>{{ $delivery->driver?->name }}</td>
+                        <td>{{ $delivery->carrier ?: '—' }}</td>
+                        <td>{{ $delivery->note ?: $delivery->carrier_note ?: '—' }}</td>
+                    </tr>
+                @empty
+                    <tr class="schedule-week-day-end">
+                        <td class="text-nowrap bg-body-secondary">
+                            <strong>{{ $dayNames[$day->dayOfWeekIso] }}</strong><br>
+                            {{ $day->format('d.m.Y') }}
+                        </td>
+                        <td colspan="7" class="text-muted">Доставок нет</td>
+                    </tr>
+                @endforelse
+            @endforeach
+            </tbody>
+        </table>
+    </div>
+@endsection

+ 503 - 0
resources/views/production_orders/edit.blade.php

@@ -0,0 +1,503 @@
+@extends('layouts.app')
+
+@section('content')
+    @php
+        $canView = static fn (string $field): bool => (bool) ($readableFields[$field] ?? false);
+        $canUpdate = static fn (string $field): bool => (bool) ($writableFields[$field] ?? false);
+        $itemRows = old('items');
+        if ($itemRows === null) {
+            $itemRows = $order
+                ? $order->items->map(static fn ($item): array => [
+                    'id' => $item->id,
+                    'common_catalog_item_id' => $item->common_catalog_item_id,
+                    '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()
+                : [];
+        }
+    @endphp
+
+    <div class="px-3">
+        <div class="row mb-3">
+            <div class="col-md-7 d-flex align-items-center">
+                <h3 class="mb-0">{{ $order ? 'Заказ '.$order->order_number : 'Новый заказ' }}</h3>
+            </div>
+            @if($order && hasPermission('schedule-orders.delete'))
+                <div class="col-md-5 text-md-end">
+                    <form action="{{ route('schedule.orders.destroy', $order) }}" method="POST"
+                          onsubmit="return confirm('Удалить заказ?')">
+                        @csrf
+                        @method('DELETE')
+                        <button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
+                    </form>
+                </div>
+            @endif
+        </div>
+
+        <form action="{{ $order ? route('schedule.orders.update', $order) : route('schedule.orders.store') }}"
+              method="POST">
+            @csrf
+            @if($order) @method('PUT') @endif
+            <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+
+            <div class="row g-3 mb-3 align-items-start">
+                <div class="col-xl-5">
+            <div class="card h-100">
+                <div class="card-header"><strong>Заказ</strong></div>
+                <div class="card-body">
+                    <div class="row">
+                        <div class="col-12">
+                            @if($canView('order_number'))
+                                @include('partials.input', ['name' => 'order_number', 'title' => 'Номер заказа', 'required' => true, 'value' => $order?->order_number, 'disabled' => !$canUpdate('order_number')])
+                            @endif
+                            @if($canView('order_year'))
+                                @include('partials.input', ['name' => 'order_year', 'title' => 'Год заказа', 'type' => 'number', 'min' => 2000, 'max' => 2100, 'required' => true, 'value' => $order?->order_year ?? now()->year, 'disabled' => !$canUpdate('order_year')])
+                            @endif
+                            @if($canView('customer_name'))
+                                @include('partials.input', ['name' => 'customer_name', 'title' => 'Заказчик', 'required' => true, 'value' => $order?->customer_name, 'disabled' => !$canUpdate('customer_name')])
+                            @endif
+                            @if($canView('object_address'))
+                                <div class="row mb-2">
+                                    <label for="object_address" class="col-form-label small col-md-4 text-md-end">Адрес объекта <sup>*</sup></label>
+                                    <div class="col-md-8">
+                                        <textarea name="object_address" id="object_address" rows="3" required
+                                                  @disabled(!$canUpdate('object_address'))
+                                                  class="form-control form-control-sm @error('object_address') is-invalid @enderror">{{ old('object_address', $order?->object_address) }}</textarea>
+                                        @error('object_address') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+                                    </div>
+                                </div>
+                            @endif
+                            @if($canView('manager_id'))
+                                @include('partials.select', ['name' => 'manager_id', 'title' => 'Менеджер', 'required' => true, 'first_empty' => true, 'options' => $managers, 'value' => old('manager_id', $order?->manager_id), 'disabled' => !$canUpdate('manager_id')])
+                            @endif
+                            @if($canView('status'))
+                                @include('partials.select', ['name' => 'status', 'title' => 'Статус', 'required' => true, 'options' => $statuses, 'value' => old('status', $order?->status?->value ?? \App\Enums\ProductionOrderStatus::Placed->value), 'disabled' => !$canUpdate('status')])
+                            @endif
+                            @if($canView('execution_type'))
+                                @include('partials.select', ['name' => 'execution_type', 'title' => 'Тип исполнения', 'required' => true, 'first_empty' => true, 'options' => $executionTypes, 'value' => old('execution_type', $order?->execution_type?->value), 'disabled' => !$canUpdate('execution_type')])
+                            @endif
+                        </div>
+                        <div class="col-12">
+                            @if($canView('invoice_number'))
+                                @include('partials.input', ['name' => 'invoice_number', 'title' => '№ счёта', 'required' => true, 'value' => $order?->invoice_number, 'disabled' => !$canUpdate('invoice_number')])
+                            @endif
+                            @if($canView('invoice_date'))
+                                @include('partials.input', ['name' => 'invoice_date', 'title' => 'Дата счёта', 'type' => 'date', 'value' => $order?->invoice_date?->toDateString(), 'disabled' => !$canUpdate('invoice_date')])
+                            @endif
+                            @if($canView('contract_number'))
+                                @include('partials.input', ['name' => 'contract_number', 'title' => '№ договора', 'value' => $order?->contract_number, 'disabled' => !$canUpdate('contract_number')])
+                            @endif
+                            @if($canView('contract_date'))
+                                @include('partials.input', ['name' => 'contract_date', 'title' => 'Дата договора', 'type' => 'date', 'value' => $order?->contract_date?->toDateString(), 'disabled' => !$canUpdate('contract_date')])
+                            @endif
+                            @if($canView('payment_date'))
+                                @include('partials.input', ['name' => 'payment_date', 'title' => 'Дата оплаты', 'type' => 'date', 'required' => true, 'value' => $order?->payment_date?->toDateString(), 'disabled' => !$canUpdate('payment_date')])
+                            @endif
+                            @if($canView('supply_working_days'))
+                                @include('partials.input', ['name' => 'supply_working_days', 'title' => 'Срок поставки, раб. дней', 'type' => 'number', 'min' => 0, 'max' => 2000, 'required' => true, 'value' => $order?->supply_working_days, 'disabled' => !$canUpdate('supply_working_days')])
+                            @endif
+                            @if($order && $canView('contract_shipment_date'))
+                                @include('partials.input', ['name' => 'contract_shipment_date_display', 'title' => 'Дата отгрузки по договору', 'type' => 'date', 'value' => $order->contract_shipment_date?->toDateString(), 'disabled' => true])
+                            @endif
+                            @if($canView('application_shipment_date'))
+                                @include('partials.input', ['name' => 'application_shipment_date', 'title' => 'Дата отгрузки по заявке', 'type' => 'date', 'value' => $order?->application_shipment_date?->toDateString(), 'disabled' => !$canUpdate('application_shipment_date')])
+                            @endif
+                            @if($canView('note'))
+                                <div class="row mb-2">
+                                    <label for="note" class="col-form-label small col-md-4 text-md-end">Примечание</label>
+                                    <div class="col-md-8">
+                                        <textarea name="note" id="note" rows="3" @disabled(!$canUpdate('note'))
+                                                  class="form-control form-control-sm @error('note') is-invalid @enderror">{{ old('note', $order?->note) }}</textarea>
+                                        @error('note') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+                                    </div>
+                                </div>
+                            @endif
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+                </div>
+                <div class="col-xl-7">
+            <div class="card h-100 overflow-hidden">
+                <div class="card-header d-flex justify-content-between align-items-center">
+                    <strong>Оборудование</strong>
+                </div>
+                <div class="card-body {{ $canManageItems ? 'pb-2' : 'p-0' }}">
+                    @if($canManageItems)
+                        <div class="mb-3">
+                            <label for="production-order-catalog-search" class="form-label small mb-1">
+                                Добавить МАФ из общего каталога
+                            </label>
+                            <div class="row g-2 mb-2">
+                                <div class="col-md-9">
+                                    <input type="search" id="production-order-catalog-search"
+                                           class="form-control form-control-sm"
+                                           placeholder="Введите артикул или наименование">
+                                </div>
+                                <div class="col-md-3">
+                                    <div class="input-group input-group-sm">
+                                        <span class="input-group-text">Количество</span>
+                                        <input type="number" id="production-order-catalog-quantity"
+                                               class="form-control" min="1" max="100" value="1">
+                                    </div>
+                                </div>
+                            </div>
+                            <select id="production-order-catalog-results" class="form-select form-select-sm" size="6"
+                                    aria-label="Результаты поиска МАФ"></select>
+                            <div class="form-text">
+                                Введите не менее двух символов и выберите позицию. Для каждой единицы будет создана отдельная строка.
+                            </div>
+                        </div>
+                    @endif
+                    <div class="table-responsive">
+                        <table class="table table-sm align-middle mb-0">
+                            <thead>
+                            <tr>
+                                <th><input type="checkbox" class="form-check-input" id="production-order-items-select-all" title="Выбрать все"></th>
+                                <th>Картинка</th>
+                                <th>Позиция общего каталога</th>
+                                <th>Номер заказа МАФ</th>
+                                <th>Заводской номер</th>
+                                <th>Дата производства</th>
+                                <th>Паспорт</th>
+                                @if($canManageItems)<th></th>@endif
+                            </tr>
+                            </thead>
+                            <tbody id="production-order-items">
+                            @foreach($itemRows as $index => $itemRow)
+                                @include('production_orders.partials.item-row', ['index' => $index, 'itemRow' => $itemRow])
+                            @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                    @error('items') <div class="text-danger small p-2"><strong>{{ $message }}</strong></div> @enderror
+                </div>
+            </div>
+                </div>
+            </div>
+
+            <div class="d-flex flex-wrap gap-2">
+                @if(($order && hasPermission('schedule-orders.update')) || (!$order && hasPermission('schedule-orders.create')))
+                    <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>
+                @endif
+                <a href="{{ $back_url }}" class="btn btn-sm btn-outline-secondary">Назад</a>
+            </div>
+        </form>
+
+        @if($order)
+            <div class="d-flex flex-wrap gap-2 mt-3">
+                @if($canManageDeliveries)
+                    <a href="#production-order-delivery-new" class="btn btn-sm btn-outline-primary">
+                        <i class="bi bi-truck"></i> Перенести в график доставок
+                    </a>
+                @endif
+                @if($canManageInstallations)
+                    <a href="#production-order-installation-new" class="btn btn-sm btn-outline-primary">
+                        <i class="bi bi-tools"></i> Перенести в график монтажей
+                    </a>
+                @endif
+                @if(hasPermission('schedule-orders.export'))
+                    <form action="{{ route('schedule.orders.export-items', $order) }}" method="POST">
+                        @csrf
+                        <button type="submit" class="btn btn-sm btn-outline-primary">
+                            <i class="bi bi-file-earmark-spreadsheet"></i> Экспорт МАФ
+                        </button>
+                    </form>
+                @endif
+                @if(hasPermission('schedule-orders.documents.generate'))
+                    <form id="production-order-technical-documents"
+                          action="{{ route('schedule.orders.technical-documents', $order) }}" method="POST"
+                          onsubmit="if (!document.querySelector('.production-order-item-select:checked')) { alert('Выберите хотя бы один МАФ'); return false; }">
+                        @csrf
+                        <button type="submit" class="btn btn-sm btn-outline-primary">
+                            <i class="bi bi-file-earmark-zip"></i> Скачать техдокументацию
+                        </button>
+                    </form>
+                @endif
+                @if(hasPermission('reclamations.create'))
+                    <form id="production-order-reclamation"
+                          action="{{ route('schedule.orders.reclamations.store', $order) }}" method="POST">
+                        @csrf
+                        <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+                        <button type="submit" class="btn btn-sm btn-outline-danger">
+                            <i class="bi bi-exclamation-triangle"></i> Создать рекламацию
+                        </button>
+                    </form>
+                @endif
+            </div>
+
+            @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)
+                                <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">
+                            @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">
+                        <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 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-auto position-relative">
+                                        <a href="{{ $file->link }}" data-toggle="lightbox"
+                                           data-gallery="production-order-photos-{{ $order->id }}" data-size="fullscreen">
+                                            <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]) }}"
+                                                  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
+                    <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
+                    <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">
+                <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
+
+        @if($canManageItems)
+            <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' => '', 'passport_file_id' => null],
+                ])
+            </template>
+        @endif
+    </div>
+
+    @if($canManageItems)
+        @push('scripts')
+            <script>
+                document.addEventListener('DOMContentLoaded', () => {
+                    const rows = document.getElementById('production-order-items');
+                    const template = document.getElementById('production-order-item-template');
+                    const search = document.getElementById('production-order-catalog-search');
+                    const results = document.getElementById('production-order-catalog-results');
+                    const quantity = document.getElementById('production-order-catalog-quantity');
+                    let nextIndex = rows.querySelectorAll('tr').length;
+                    let searchTimer;
+                    let requestNumber = 0;
+
+                    search.addEventListener('input', () => {
+                        clearTimeout(searchTimer);
+                        const query = search.value.trim();
+                        results.replaceChildren();
+                        const currentRequest = ++requestNumber;
+                        if (query.length < 2) return;
+
+                        searchTimer = setTimeout(async () => {
+                            try {
+                                const url = new URL(@json(route('schedule.orders.catalog-search')), window.location.origin);
+                                url.searchParams.set('q', query);
+                                const response = await fetch(url, {headers: {'Accept': 'application/json'}});
+                                if (!response.ok || currentRequest !== requestNumber) return;
+
+                                const items = await response.json();
+                        results.replaceChildren(...items.map((item) => {
+                                    const option = document.createElement('option');
+                                    option.value = item.id;
+                                    option.textContent = item.label;
+                                    option.dataset.image = item.image || '';
+                                    option.dataset.imageFull = item.image_full || item.image || '';
+                                    return option;
+                                }));
+                            } catch (error) {
+                                if (currentRequest === requestNumber) results.replaceChildren();
+                            }
+                        }, 250);
+                    });
+
+                    results.addEventListener('change', () => {
+                        const option = results.selectedOptions[0];
+                        if (!option) return;
+
+                        const count = Math.min(100, Math.max(1, Number.parseInt(quantity.value, 10) || 1));
+                        for (let i = 0; i < count; i++) {
+                            const fragment = template.content.cloneNode(true);
+                            const row = fragment.querySelector('tr');
+                            row.innerHTML = row.innerHTML.replaceAll('__INDEX__', nextIndex++);
+                            row.querySelector('.production-order-item-catalog-id').value = option.value;
+                            row.querySelector('.production-order-item-catalog-label').textContent = option.textContent;
+                            if (option.dataset.image) {
+                                const imageLink = row.querySelector('.production-order-item-image-link');
+                                const image = row.querySelector('.production-order-item-image');
+                                imageLink.href = option.dataset.imageFull;
+                                imageLink.classList.remove('d-none');
+                                image.src = option.dataset.image;
+                                image.alt = option.textContent;
+                                row.querySelector('.production-order-item-image-empty')?.classList.add('d-none');
+                            }
+                            rows.appendChild(fragment);
+                        }
+                        search.value = '';
+                        quantity.value = '1';
+                        results.replaceChildren();
+                    });
+
+                    rows.addEventListener('click', (event) => {
+                        const button = event.target.closest('.remove-production-order-item');
+                        if (!button) return;
+                        button.closest('tr').remove();
+                    });
+
+                });
+            </script>
+        @endpush
+    @endif
+
+    @if($order)
+        @push('scripts')
+            <script>
+                document.addEventListener('DOMContentLoaded', () => {
+                    const selectAll = document.getElementById('production-order-items-select-all');
+                    if (!selectAll) return;
+                    selectAll.addEventListener('change', () => {
+                        document.querySelectorAll('.production-order-item-select').forEach((checkbox) => {
+                            checkbox.checked = selectAll.checked;
+                        });
+                    });
+
+                    const reclamationForm = document.getElementById('production-order-reclamation');
+                    reclamationForm?.addEventListener('submit', (event) => {
+                        reclamationForm.querySelectorAll('input[name="item_ids[]"]').forEach((input) => input.remove());
+                        const selectedItems = document.querySelectorAll('.production-order-item-select:checked');
+                        if (!selectedItems.length) {
+                            event.preventDefault();
+                            alert('Выберите хотя бы один МАФ');
+                            return;
+                        }
+
+                        selectedItems.forEach((checkbox) => {
+                            const input = document.createElement('input');
+                            input.type = 'hidden';
+                            input.name = 'item_ids[]';
+                            input.value = checkbox.value;
+                            reclamationForm.appendChild(input);
+                        });
+                    });
+                });
+            </script>
+        @endpush
+    @endif
+@endsection

+ 91 - 0
resources/views/production_orders/index.blade.php

@@ -0,0 +1,91 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2 page-header-row">
+        <div class="col-12 col-md-6 page-header-title">
+            <h3>График заказов</h3>
+        </div>
+        <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if(hasPermission('schedule-orders.import'))
+                <button type="button"
+                        class="btn btn-sm btn-outline-primary page-action-btn"
+                        data-bs-toggle="modal"
+                        data-bs-target="#productionOrderImportModal">
+                    <i class="bi bi-file-earmark-arrow-up page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Импорт</span>
+                </button>
+            @endif
+            @if(hasPermission('schedule-orders.export'))
+                <form action="{{ route('schedule.orders.export') }}" method="POST" class="d-inline">
+                    @csrf
+                    @foreach((array) request()->input('filters', []) as $filter => $value)
+                        <input type="hidden" name="filters[{{ $filter }}]" value="{{ $value }}">
+                    @endforeach
+                    @if(request()->filled('s'))
+                        <input type="hidden" name="s" value="{{ request()->input('s') }}">
+                    @endif
+                    <button type="submit" class="btn btn-sm btn-outline-primary page-action-btn">
+                        <i class="bi bi-file-earmark-spreadsheet page-action-btn__icon"></i>
+                        <span class="page-action-btn__label">Экспорт</span>
+                    </button>
+                </form>
+            @endif
+            @if(hasPermission('schedule-orders.create'))
+                <a href="{{ route('schedule.orders.create', ['nav' => $nav ?? null]) }}"
+                   class="btn btn-sm btn-primary page-action-btn">
+                    <i class="bi bi-plus-lg page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Добавить заказ</span>
+                </a>
+            @endif
+        </div>
+    </div>
+
+    @include('partials.table', [
+        'id' => $id,
+        'header' => $header,
+        'strings' => $orders,
+        'routeName' => 'schedule.orders.show',
+        'routeParam' => 'productionOrder',
+        'nav' => $nav,
+    ])
+
+    @include('partials.pagination', ['items' => $orders])
+
+    @if(hasPermission('schedule-orders.import'))
+        <div class="modal fade"
+             id="productionOrderImportModal"
+             tabindex="-1"
+             aria-labelledby="productionOrderImportModalLabel"
+             aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h2 class="modal-title fs-5" id="productionOrderImportModalLabel">Импорт графика заказов</h2>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <form action="{{ route('schedule.orders.import') }}" method="POST" enctype="multipart/form-data">
+                        @csrf
+                        <div class="modal-body">
+                            <p class="text-muted small">
+                                Используйте XLSX, полученный экспортом графика заказов. Заказы находятся на первом листе,
+                                отдельные экземпляры МАФ — на листе «МАФ». Состав МАФ в файле полностью заменяет
+                                текущий состав импортируемых заказов.
+                            </p>
+                            <label for="production-order-import-file" class="form-label">XLSX-файл</label>
+                            <input type="file"
+                                   id="production-order-import-file"
+                                   name="import_file"
+                                   class="form-control"
+                                   accept=".xlsx"
+                                   required>
+                        </div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                            <button type="submit" class="btn btn-primary">Импортировать</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    @endif
+@endsection

+ 120 - 0
resources/views/production_orders/partials/delivery-form.blade.php

@@ -0,0 +1,120 @@
+@php
+    $delivery = $delivery ?? null;
+    $formId = $delivery ? 'production-order-delivery-'.$delivery->id : 'production-order-delivery-new';
+    $value = static fn (string $field, mixed $default = null): mixed => $delivery?->$field ?? $default;
+@endphp
+
+<form id="{{ $formId }}" method="POST" action="{{ $delivery
+    ? route('schedule.orders.deliveries.update', [$order, $delivery])
+    : route('schedule.orders.deliveries.store', $order) }}" class="border rounded p-3 mb-2">
+    @csrf
+    @if($delivery) @method('PUT') @endif
+    <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+
+    <div class="row g-2 align-items-end">
+        <div class="col-md-3">
+            <label for="{{ $formId }}-date" class="form-label small">Дата доставки <sup>*</sup></label>
+            <input id="{{ $formId }}-date" type="date" name="delivery_date" required
+                   value="{{ $value('delivery_date')?->toDateString() }}" class="form-control form-control-sm">
+        </div>
+        <div class="col-md-2">
+            <label for="{{ $formId }}-time" class="form-label small">Время</label>
+            <input id="{{ $formId }}-time" type="time" name="delivery_time"
+                   value="{{ substr((string) $value('delivery_time'), 0, 5) }}" class="form-control form-control-sm">
+        </div>
+        <div class="col-md-4">
+            <label for="{{ $formId }}-driver" class="form-label small">Водитель <sup>*</sup></label>
+            <select id="{{ $formId }}-driver" name="driver_id" required class="form-select form-select-sm">
+                <option value="">Выберите...</option>
+                @foreach($drivers as $driverId => $driverName)
+                    <option value="{{ $driverId }}" @selected((int) $value('driver_id') === (int) $driverId)>
+                        {{ $driverName }}
+                    </option>
+                @endforeach
+            </select>
+        </div>
+        <div class="col-md-3 d-flex gap-2">
+            <button type="submit" class="btn btn-sm btn-primary">
+                {{ $delivery ? 'Сохранить' : 'Перенести в график доставок' }}
+            </button>
+            @if($delivery)
+                <button type="submit" form="{{ $formId }}-delete" class="btn btn-sm btn-outline-danger"
+                        onclick="return confirm('Удалить доставку?')">Удалить</button>
+            @endif
+        </div>
+    </div>
+
+    <details class="mt-3" @if($delivery && collect([
+        'carrier', 'request_number', 'request_date', 'carrier_note', 'contact_position', 'contact_name',
+        'contact_phone', 'desired_time', 'access_system', 'unloading_place', 'advance_notice',
+        'document_signing_method', 'note',
+    ])->contains(fn ($field) => filled($delivery->$field))) open @endif>
+        <summary class="small cursor-pointer">Данные заявки и перевозки</summary>
+        <div class="row g-2 mt-1">
+            <div class="col-md-4">
+                <label class="form-label small">Перевозчик</label>
+                <input type="text" name="carrier" maxlength="255" value="{{ $value('carrier') }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">№ заявки</label>
+                <input type="text" name="request_number" maxlength="100" value="{{ $value('request_number') }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">Дата заявки</label>
+                <input type="date" name="request_date" value="{{ $value('request_date')?->toDateString() }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">Желаемое время</label>
+                <input type="time" name="desired_time" value="{{ substr((string) $value('desired_time'), 0, 5) }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-6">
+                <label class="form-label small">Примечание перевозчику</label>
+                <textarea name="carrier_note" rows="2" maxlength="5000" class="form-control form-control-sm">{{ $value('carrier_note') }}</textarea>
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">Должность</label>
+                <input type="text" name="contact_position" maxlength="255" value="{{ $value('contact_position') }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">Контактное лицо</label>
+                <input type="text" name="contact_name" maxlength="255" value="{{ $value('contact_name') }}" class="form-control form-control-sm">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label small">Телефон</label>
+                <input type="text" name="contact_phone" maxlength="100" value="{{ $value('contact_phone') }}" class="form-control form-control-sm">
+            </div>
+            @foreach([
+                'access_system' => 'Пропускная система',
+                'unloading_place' => 'Место выгрузки',
+                'advance_notice' => 'Предварительное предупреждение',
+                'document_signing_method' => 'Способ подписания документов',
+            ] as $field => $label)
+                <div class="col-md-6">
+                    <label class="form-label small">{{ $label }}</label>
+                    <textarea name="{{ $field }}" rows="2" maxlength="5000" class="form-control form-control-sm">{{ $value($field) }}</textarea>
+                </div>
+            @endforeach
+            <div class="col-md-6">
+                <label class="form-label small">Дополнительный комментарий</label>
+                <textarea name="note" rows="2" maxlength="10000" class="form-control form-control-sm">{{ $value('note') }}</textarea>
+            </div>
+            <div class="col-md-6 d-flex align-items-end pb-1">
+                <input type="hidden" name="has_passports_and_certificates" value="0">
+                <div class="form-check">
+                    <input type="checkbox" name="has_passports_and_certificates" value="1"
+                           id="{{ $formId }}-passports" class="form-check-input" @checked((bool) $value('has_passports_and_certificates'))>
+                    <label for="{{ $formId }}-passports" class="form-check-label small">Паспорта и сертификаты в комплекте</label>
+                </div>
+            </div>
+        </div>
+    </details>
+</form>
+
+@if($delivery)
+    <form id="{{ $formId }}-delete" method="POST"
+          action="{{ route('schedule.orders.deliveries.destroy', [$order, $delivery]) }}">
+        @csrf
+        @method('DELETE')
+        <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+    </form>
+@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

+ 59 - 0
resources/views/production_orders/partials/installation-form.blade.php

@@ -0,0 +1,59 @@
+@php
+    $installation = $installation ?? null;
+    $formId = $installation ? 'production-order-installation-'.$installation->id : 'production-order-installation-new';
+@endphp
+
+<form id="{{ $formId }}" method="POST" action="{{ $installation
+    ? route('schedule.orders.installations.update', [$order, $installation])
+    : route('schedule.orders.installations.store', $order) }}" class="border rounded p-3 mb-2">
+    @csrf
+    @if($installation) @method('PUT') @endif
+    <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+
+    <div class="row g-2 align-items-end">
+        <div class="col-md-3">
+            <label for="{{ $formId }}-date" class="form-label small">Дата монтажа <sup>*</sup></label>
+            <input id="{{ $formId }}-date" type="date" name="installation_date" required
+                   value="{{ $installation?->installation_date?->toDateString() }}" class="form-control form-control-sm">
+        </div>
+        <div class="col-md-2">
+            <label for="{{ $formId }}-days" class="form-label small">Дней <sup>*</sup></label>
+            <input id="{{ $formId }}-days" type="number" name="installation_days" required min="1" max="365"
+                   value="{{ $installation?->installation_days ?? 1 }}" class="form-control form-control-sm">
+        </div>
+        <div class="col-md-3">
+            <label for="{{ $formId }}-brigadier" class="form-label small">Бригадир <sup>*</sup></label>
+            <select id="{{ $formId }}-brigadier" name="brigadier_id" required class="form-select form-select-sm">
+                <option value="">Выберите...</option>
+                @foreach($brigadiers as $brigadierId => $brigadierName)
+                    <option value="{{ $brigadierId }}" @selected((int) $installation?->brigadier_id === (int) $brigadierId)>
+                        {{ $brigadierName }}
+                    </option>
+                @endforeach
+            </select>
+        </div>
+        <div class="col-md-4">
+            <label for="{{ $formId }}-note" class="form-label small">Примечание</label>
+            <textarea id="{{ $formId }}-note" name="note" rows="1" maxlength="10000"
+                      class="form-control form-control-sm">{{ $installation?->note }}</textarea>
+        </div>
+        <div class="col-12 d-flex gap-2">
+            <button type="submit" class="btn btn-sm btn-primary">
+                {{ $installation ? 'Сохранить' : 'Перенести в график монтажей' }}
+            </button>
+            @if($installation)
+                <button type="submit" form="{{ $formId }}-delete" class="btn btn-sm btn-outline-danger"
+                        onclick="return confirm('Удалить монтаж?')">Удалить</button>
+            @endif
+        </div>
+    </div>
+</form>
+
+@if($installation)
+    <form id="{{ $formId }}-delete" method="POST"
+          action="{{ route('schedule.orders.installations.destroy', [$order, $installation]) }}">
+        @csrf
+        @method('DELETE')
+        <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+    </form>
+@endif

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

@@ -0,0 +1,95 @@
+@php($orderItem = !empty($itemRow['id']) && $order ? $order->items->firstWhere('id', (int) $itemRow['id']) : null)
+@php($catalogItem = $catalogItems->get((int) ($itemRow['common_catalog_item_id'] ?? 0)))
+<tr>
+    <td>
+        @if($orderItem)
+            <input type="checkbox" class="form-check-input production-order-item-select"
+                   name="item_ids[]" value="{{ $orderItem->id }}"
+                   form="production-order-technical-documents">
+        @endif
+    </td>
+    <td style="min-width: 86px">
+        @php($imageFile = $catalogItem?->imageFile)
+        <a href="{{ $imageFile?->link ?? '#' }}" data-toggle="lightbox"
+           data-gallery="production-order-items-{{ $order?->id ?? 'new' }}" data-size="fullscreen"
+           class="production-order-item-image-link {{ $imageFile ? '' : 'd-none' }}">
+            <img src="{{ $imageFile?->thumbnail_link }}" alt="{{ $catalogItem?->article }}"
+                 class="img-thumbnail maf-img production-order-item-image">
+        </a>
+        <span class="text-muted production-order-item-image-empty {{ $imageFile ? 'd-none' : '' }}">—</span>
+    </td>
+    <td style="min-width: 320px">
+        @if(!empty($itemRow['id']))
+            <input type="hidden" name="items[{{ $index }}][id]" value="{{ $itemRow['id'] }}">
+        @endif
+        <input type="hidden" name="items[{{ $index }}][common_catalog_item_id]"
+               class="production-order-item-catalog-id"
+               value="{{ $itemRow['common_catalog_item_id'] ?? '' }}">
+        <span class="production-order-item-catalog-label">
+            @if($catalogItem)
+                {{ $catalogItem->article }} — {{ $catalogItem->calculator_name ?: $catalogItem->print_name ?: 'Без наименования' }}
+            @endif
+        </span>
+        @error('items.'.$index.'.common_catalog_item_id') <div class="text-danger small"><strong>{{ $message }}</strong></div> @enderror
+    </td>
+    <td style="min-width: 180px">
+        @if($canManageItems)
+            <input type="text" name="items[{{ $index }}][order_item_number]" required
+                   value="{{ $itemRow['order_item_number'] ?? '' }}"
+                   class="form-control form-control-sm @error('items.'.$index.'.order_item_number') is-invalid @enderror">
+            @error('items.'.$index.'.order_item_number') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+        @else
+            <input type="hidden" name="items[{{ $index }}][order_item_number]"
+                   value="{{ $itemRow['order_item_number'] ?? '' }}">
+            {{ $itemRow['order_item_number'] ?? '' }}
+        @endif
+    </td>
+    <td style="min-width: 160px">
+        @if($canManageItems || $canUpdateFactoryNumber)
+            <input type="text" name="items[{{ $index }}][factory_number]"
+                   value="{{ $itemRow['factory_number'] ?? '' }}" class="form-control form-control-sm">
+        @else
+            {{ $itemRow['factory_number'] ?? '' }}
+        @endif
+    </td>
+    <td style="min-width: 160px">
+        @if($canManageItems || $canUpdateManufactureDate)
+            <input type="date" name="items[{{ $index }}][manufacture_date]"
+                   value="{{ $itemRow['manufacture_date'] ?? '' }}" class="form-control form-control-sm">
+        @else
+            {{ $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="Удалить">
+                <i class="bi bi-trash"></i>
+            </button>
+        </td>
+    @endif
+</tr>

+ 84 - 0
resources/views/production_orders/partials/planning.blade.php

@@ -0,0 +1,84 @@
+<div class="row mt-3">
+    <div class="col-xl-7">
+        <div class="card mb-3">
+            <div class="card-header"><strong>Доставки</strong></div>
+            <div class="card-body">
+                @forelse($order->deliveries as $delivery)
+                    @if($canManageDeliveries)
+                        @include('production_orders.partials.delivery-form', ['delivery' => $delivery])
+                    @else
+                        <div class="border rounded p-2 mb-2 small">
+                            <strong>{{ $delivery->delivery_date->format('d.m.Y') }}</strong>
+                            @if($delivery->delivery_time) в {{ substr($delivery->delivery_time, 0, 5) }} @endif
+                            — {{ $delivery->driver?->name }}
+                        </div>
+                    @endif
+                    @if(hasPermission('schedule-orders.documents.generate'))
+                        <form action="{{ route('schedule.orders.deliveries.document', [$order, $delivery]) }}" method="POST" class="mb-3">
+                            @csrf
+                            <button type="submit" class="btn btn-sm btn-outline-primary">
+                                <i class="bi bi-file-earmark-spreadsheet"></i> Документы для доставки
+                            </button>
+                        </form>
+                    @endif
+                @empty
+                    <div class="text-muted small mb-2">Доставки ещё не запланированы.</div>
+                @endforelse
+
+                @if($canManageDeliveries)
+                    <h6 class="mt-3">Новая доставка</h6>
+                    @include('production_orders.partials.delivery-form', ['delivery' => null])
+                @endif
+            </div>
+        </div>
+    </div>
+
+    <div class="col-xl-5">
+        <div class="card mb-3">
+            <div class="card-header"><strong>Монтажи</strong></div>
+            <div class="card-body">
+                @forelse($order->installations as $installation)
+                    @if($canManageInstallations)
+                        @include('production_orders.partials.installation-form', ['installation' => $installation])
+                    @else
+                        <div class="border rounded p-2 mb-2 small">
+                            <strong>{{ $installation->installation_date->format('d.m.Y') }}</strong>,
+                            {{ $installation->installation_days }} дн. — {{ $installation->brigadier?->name }}
+                        </div>
+                    @endif
+                    @if(hasPermission('schedule-orders.documents.generate'))
+                        <form action="{{ route('schedule.orders.installations.documents', [$order, $installation]) }}" method="POST" class="mb-3">
+                            @csrf
+                            <button type="submit" class="btn btn-sm btn-outline-primary">
+                                <i class="bi bi-file-earmark-zip"></i> Документы для монтажа
+                            </button>
+                        </form>
+                    @endif
+                @empty
+                    <div class="text-muted small mb-2">Монтажи ещё не запланированы.</div>
+                @endforelse
+
+                @if($canManageInstallations)
+                    <h6 class="mt-3">Новый монтаж</h6>
+                    @include('production_orders.partials.installation-form', ['installation' => null])
+                @endif
+            </div>
+        </div>
+
+        <div class="card mb-3">
+            <div class="card-header"><strong>Журнал действий</strong></div>
+            <div class="card-body">
+                @forelse($order->activities as $activity)
+                    <div class="small border-bottom py-2">
+                        <div>{{ $activity->message }}</div>
+                        <div class="text-muted">
+                            {{ $activity->created_at->format('d.m.Y H:i') }} · {{ $activity->user?->name ?? 'Система' }}
+                        </div>
+                    </div>
+                @empty
+                    <div class="text-muted small">Значимых действий пока нет.</div>
+                @endforelse
+            </div>
+        </div>
+    </div>
+</div>

+ 39 - 7
resources/views/reclamations/edit.blade.php

@@ -21,10 +21,10 @@
                         @method('DELETE')
                     </form>
                 @endif
-                @if(hasPermission('schedule.create') && !is_null($reclamation->brigadier_id) && !is_null($reclamation->start_work_date))
+                @if($reclamation->isDkr() && hasPermission('schedule.create') && !is_null($reclamation->brigadier_id) && !is_null($reclamation->start_work_date))
                     <button class="btn btn-sm btn-primary" id="createScheduleButton">Перенести в график</button>
                 @endif
-                @if(hasPermission('reclamations.update'))
+                @if(hasPermission('reclamations.update') && $reclamation->isDkr())
                     <a href="{{ route('order.generate-reclamation-pack', ['reclamation' => $reclamation, 'nav' => $nav ?? null]) }}"
                        class="btn btn-primary btn-sm">Пакет документов рекламации</a>
                 @endif
@@ -44,11 +44,19 @@
                     <input type="hidden" id="order_id" name="order_id" value="{{ $reclamation->order_id}}">
                     <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
 
-                    @include('partials.link', ['title' => 'Площадка', 'href' => route('order.show', ['order' => $reclamation->order_id, 'sync_year' => 1, 'nav' => $nav ?? null]), 'text' => $reclamation->order->common_name ?? ''])
+                    @if($reclamation->isDkr())
+                        @include('partials.link', ['title' => 'Площадка', 'href' => route('order.show', ['order' => $reclamation->order_id, 'sync_year' => 1, 'nav' => $nav ?? null]), 'text' => $reclamation->order?->common_name ?? ''])
+                    @elseif($reclamation->productionOrder)
+                        @include('partials.link', [
+                            'title' => 'Заказ',
+                            'href' => route('schedule.orders.show', ['productionOrder' => $reclamation->productionOrder, 'nav' => $nav ?? null]),
+                            'text' => $reclamation->productionOrder->customer_name.' — '.$reclamation->productionOrder->object_address,
+                        ])
+                    @endif
                     @include('partials.input', ['name' => 'reclamation_type', 'title' => 'Тип рекламации', 'type' => 'text', 'value' => $reclamation->reclamationType?->name ?? '-', 'disabled' => true])
                     @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
-                    @include('partials.input', ['name' => 'maf_installation_year', 'title' => 'Год установки МАФ', 'type' => 'text', 'value' => $reclamation->order->year, 'disabled' => true])
+                    @include('partials.input', ['name' => 'maf_installation_year', 'title' => 'Год установки МАФ', 'type' => 'text', 'value' => $reclamation->order?->year ?? $reclamation->productionOrder?->order_year, 'disabled' => true])
                     @include('partials.input', ['name' => 'create_date', 'title' => 'Дата создания', 'type' => 'date', 'required' => true, 'value' => $reclamation->create_date ?? date('Y-m-d'), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.input', ['name' => 'finish_date', 'title' => 'Дата завершения', 'type' => 'date', 'required' => true, 'value' => $reclamation->finish_date ?? date('Y-m-d', strtotime('+30 days')), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $reclamation->brigadier_id ?? old('brigadier_id'), 'disabled' => !hasPermission('reclamations.update'), 'first_empty' => true, 'classes' => ['update-once']])
@@ -69,14 +77,19 @@
                         <tr>
                             <th>Картинка</th>
                             <th>МАФ</th>
-                            <th>Тип</th>
+                            @if($reclamation->isDkr())
+                                <th>Тип</th>
+                            @endif
                             <th>Номер заказа МАФ</th>
-                            <th>RFID</th>
+                            @if($reclamation->isDkr())
+                                <th>RFID</th>
+                            @endif
                             <th>Заводской номер</th>
                             <th>Дата производства</th>
                         </tr>
                         </thead>
                         <tbody>
+                        @if($reclamation->isDkr())
                         @foreach($reclamation->skus as $p)
                             <tr>
                                 <td>
@@ -87,6 +100,7 @@
                                         </a>
                                     @endif
                                 </td>
+                                <td>{!! $p->product->nomenclature_number !!}</td>
                                 <td>
                                     @if(hasPermission('reclamations.update'))
                                         <a href="{{ route('product_sku.show', ['product_sku' => $p, 'nav' => $nav ?? null]) }}">
@@ -98,7 +112,6 @@
                                         {{ $p->product->article }}
                                     @endif
                                 </td>
-                                <td>{!! $p->product->nomenclature_number !!}</td>
                                 <td>
                                     @if($p->maf_order_id && hasPermission('maf_orders.view'))
                                         <a href="{{ route('maf_order.show', $p->maf_order) }}">{{ $p->maf_order->order_number }}</a>
@@ -111,6 +124,25 @@
                                 <td>{{ $p->manufacture_date }}</td>
                             </tr>
                         @endforeach
+                        @else
+                        @foreach($reclamation->productionOrderItems as $item)
+                            <tr>
+                                <td>
+                                    @if($item->catalogItem?->imageFile)
+                                        <a href="{{ $item->catalogItem->imageFile->link }}" data-toggle="lightbox"
+                                           data-gallery="reclamation-production-order-items" data-size="fullscreen">
+                                            <img src="{{ $item->catalogItem->imageFile->thumbnail_link }}"
+                                                 alt="{{ $item->catalogItem->article }}" class="img-thumbnail maf-img">
+                                        </a>
+                                    @endif
+                                </td>
+                                <td>{{ $item->catalogItem?->article }}</td>
+                                <td>{{ $item->order_item_number }}</td>
+                                <td>{{ $item->factory_number }}</td>
+                                <td>{{ $item->manufacture_date?->format('d.m.Y') }}</td>
+                            </tr>
+                        @endforeach
+                        @endif
                         </tbody>
                     </table>
                 </div>

+ 28 - 17
resources/views/schedule/index.blade.php

@@ -118,6 +118,8 @@
 
                                 @if($reclamationId)
                                     <a href="{{ route('reclamations.show', ['reclamation' => $reclamationId]) }}">{{ $schedule->address_code }}</a>
+                                @elseif($schedule->source === \App\Models\Schedule::SOURCE_PRODUCTION_ORDER && $schedule->productionOrderInstallation?->productionOrder)
+                                    <a href="{{ route('schedule.orders.show', $schedule->productionOrderInstallation->productionOrder) }}">{{ $schedule->address_code }}</a>
                                 @elseif($schedule->source === 'Площадки' && $schedule->order_id)
                                     <a href="{{ route('order.show', ['order' => $schedule->order_id, 'sync_year' => 1]) }}">{{ $schedule->address_code }}</a>
                                 @else
@@ -129,7 +131,9 @@
                             <td style="background: {{ $schedule->brigadier->color }}"
                                 class="align-middle">{{ $schedule?->area?->name }}</td>
                             <td style="background: {{ $schedule->brigadier->color }}" class="align-middle">
-                                @if($schedule->order_id)
+                                @if($schedule->source === \App\Models\Schedule::SOURCE_PRODUCTION_ORDER && $schedule->productionOrderInstallation?->productionOrder)
+                                    <a href="{{ route('schedule.orders.show', $schedule->productionOrderInstallation->productionOrder) }}">{{ $schedule->object_address }}</a>
+                                @elseif($schedule->order_id)
                                 <a href="{{ route('order.show', ['order' => $schedule->order_id, 'sync_year' => 1]) }}">{{ $schedule->object_address }}</a>
                                 @else
                                     {{ $schedule->object_address }}
@@ -158,22 +162,29 @@
                                 class="align-middle comment-{{ $schedule->id }}">{{ $schedule->comment }}</td>
                             @if(hasPermission('schedule.update'))
                                 <td style="background: {{ $schedule->brigadier->color }}" class="align-middle">
-                                    <i class="bi bi-pencil p-1 m-1 cursor-pointer text-primary editSchedule"
-                                       data-schedule-date="{{ $schedule->installation_date }}"
-                                       data-schedule-address="{{ $schedule->object_address }}"
-                                       data-schedule-district="{{ $schedule->district_id }}"
-                                       data-schedule-brigadier="{{ $schedule->brigadier_id }}"
-                                       data-schedule-area="{{ $schedule->area_id }}"
-                                       data-schedule-id="{{ $schedule->id }}"></i>
-                                    <i class="bi bi-trash text-danger p-1 m-1 deleteSchedule"
-                                       data-schedule-id="{{ $schedule->id }}"></i>
-                                    <div class="visually-hidden d-none">
-                                        <form action="{{ route('schedule.delete', $schedule->id) }}" method="post"
-                                              class="deleteForm-{{ $schedule->id }}">
-                                            @csrf
-                                            @method('DELETE')
-                                        </form>
-                                    </div>
+                                    @if($schedule->source === \App\Models\Schedule::SOURCE_PRODUCTION_ORDER && $schedule->productionOrderInstallation?->productionOrder)
+                                        <a href="{{ route('schedule.orders.show', $schedule->productionOrderInstallation->productionOrder) }}"
+                                           class="text-primary" title="Редактировать в карточке заказа">
+                                            <i class="bi bi-box-arrow-up-right"></i>
+                                        </a>
+                                    @else
+                                        <i class="bi bi-pencil p-1 m-1 cursor-pointer text-primary editSchedule"
+                                           data-schedule-date="{{ $schedule->installation_date }}"
+                                           data-schedule-address="{{ $schedule->object_address }}"
+                                           data-schedule-district="{{ $schedule->district_id }}"
+                                           data-schedule-brigadier="{{ $schedule->brigadier_id }}"
+                                           data-schedule-area="{{ $schedule->area_id }}"
+                                           data-schedule-id="{{ $schedule->id }}"></i>
+                                        <i class="bi bi-trash text-danger p-1 m-1 deleteSchedule"
+                                           data-schedule-id="{{ $schedule->id }}"></i>
+                                        <div class="visually-hidden d-none">
+                                            <form action="{{ route('schedule.delete', $schedule->id) }}" method="post"
+                                                  class="deleteForm-{{ $schedule->id }}">
+                                                @csrf
+                                                @method('DELETE')
+                                            </form>
+                                        </div>
+                                    @endif
                                 </td>
                             @endif
                         </tr>

+ 1 - 0
resources/views/users/edit.blade.php

@@ -65,6 +65,7 @@
                                 ['title' => 'Площадки', 'settingsKey' => 'orders', 'options' => $orderStatusOptions, 'colors' => $orderStatusColors, 'settings' => $settings['order_settings'] ?? []],
                                 ['title' => 'Рекламации', 'settingsKey' => 'reclamations', 'options' => $reclamationStatusOptions, 'colors' => $reclamationStatusColors, 'settings' => $settings['reclamation_settings'] ?? []],
                                 ['title' => 'График монтажей', 'settingsKey' => 'schedule', 'options' => $scheduleSourceOptions, 'colors' => [], 'settings' => $settings['schedule_settings'] ?? []],
+                                ['title' => 'График заказов', 'settingsKey' => 'production_orders', 'options' => $productionOrderEventOptions, 'colors' => [], 'settings' => $settings['production_order_settings'] ?? []],
                                 ['title' => 'Чат', 'settingsKey' => 'chat', 'options' => $chatSourceOptions, 'colors' => [], 'settings' => $settings['chat_settings'] ?? []],
                             ],
                         ])

+ 58 - 8
routes/web.php

@@ -18,6 +18,13 @@ use App\Http\Controllers\MafOrderController;
 use App\Http\Controllers\OrderController;
 use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
+use App\Http\Controllers\ProductionOrderChatMessageController;
+use App\Http\Controllers\ProductionOrderController;
+use App\Http\Controllers\ProductionOrderDeliveryController;
+use App\Http\Controllers\ProductionOrderDocumentController;
+use App\Http\Controllers\ProductionOrderFileController;
+use App\Http\Controllers\ProductionOrderInstallationController;
+use App\Http\Controllers\ProductionOrderReclamationController;
 use App\Http\Controllers\ProductSKUController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
@@ -176,14 +183,57 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     Route::prefix('schedules')->name('schedule.')->group(function () {
-        Route::get('orders', UnderDevelopmentController::class)
-            ->defaults('title', 'График заказов')
-            ->defaults('active', 'schedule_orders')
-            ->name('orders');
-        Route::get('deliveries', UnderDevelopmentController::class)
-            ->defaults('title', 'График доставок')
-            ->defaults('active', 'schedule_deliveries')
-            ->name('deliveries');
+        Route::get('orders', [ProductionOrderController::class, 'index'])->name('orders');
+        Route::get('orders/create', [ProductionOrderController::class, 'create'])->name('orders.create');
+        Route::get('orders/catalog-search', [ProductionOrderController::class, 'searchCatalogItems'])
+            ->name('orders.catalog-search');
+        Route::post('orders/export', [ProductionOrderController::class, 'export'])->name('orders.export');
+        Route::post('orders/import', [ProductionOrderController::class, 'import'])->name('orders.import');
+        Route::post('orders', [ProductionOrderController::class, 'store'])->name('orders.store');
+        Route::get('orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('orders.show');
+        Route::put('orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('orders.update');
+        Route::delete('orders/{productionOrder}', [ProductionOrderController::class, 'destroy'])->name('orders.destroy');
+        Route::post('orders/{productionOrder}/deliveries', [ProductionOrderDeliveryController::class, 'store'])
+            ->name('orders.deliveries.store');
+        Route::put('orders/{productionOrder}/deliveries/{delivery}', [ProductionOrderDeliveryController::class, 'update'])
+            ->name('orders.deliveries.update');
+        Route::delete('orders/{productionOrder}/deliveries/{delivery}', [ProductionOrderDeliveryController::class, 'destroy'])
+            ->name('orders.deliveries.destroy');
+        Route::post('orders/{productionOrder}/installations', [ProductionOrderInstallationController::class, 'store'])
+            ->name('orders.installations.store');
+        Route::put('orders/{productionOrder}/installations/{installation}', [ProductionOrderInstallationController::class, 'update'])
+            ->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::post('orders/{productionOrder}/export-items', [ProductionOrderDocumentController::class, 'exportItems'])
+            ->name('orders.export-items');
+        Route::post('orders/{productionOrder}/deliveries/{delivery}/document', [ProductionOrderDocumentController::class, 'deliveryRequest'])
+            ->name('orders.deliveries.document');
+        Route::post('orders/{productionOrder}/installations/{installation}/documents', [ProductionOrderDocumentController::class, 'installationPack'])
+            ->name('orders.installations.documents');
+        Route::post('orders/{productionOrder}/technical-documents', [ProductionOrderDocumentController::class, 'technicalDocuments'])
+            ->name('orders.technical-documents');
+        Route::post('orders/{productionOrder}/reclamations', [ProductionOrderReclamationController::class, 'store'])
+            ->name('orders.reclamations.store');
+        Route::get('orders/files/{file}', [ProductionOrderDocumentController::class, 'download'])
+            ->name('orders.files.download');
+        Route::get('deliveries', [ProductionOrderDeliveryController::class, 'index'])->name('deliveries');
     });
 
     Route::prefix('stock')->name('stock.')->group(function () {

+ 25 - 0
tests/Feature/CleanupGeneratedDocumentsCommandTest.php

@@ -118,6 +118,31 @@ class CleanupGeneratedDocumentsCommandTest extends TestCase
         Storage::disk('local')->assertMissing($path);
     }
 
+    public function test_cleanup_removes_private_production_order_export(): void
+    {
+        Storage::fake('public');
+        Storage::fake('local');
+        $user = User::factory()->create();
+        $path = 'generated/production-orders/'.$user->id.'/test/order.xlsx';
+        $file = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'order.xlsx',
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'path' => $path,
+            'link' => route('schedule.orders.files.download', 1),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+        Storage::disk('local')->put($path, 'generated');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $file->id]);
+        Storage::disk('local')->assertMissing($path);
+    }
+
     public function test_cleanup_generated_documents_removes_old_orphan_generated_archive(): void
     {
         Storage::fake('public');

+ 577 - 0
tests/Feature/ProductionOrderControllerTest.php

@@ -0,0 +1,577 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Jobs\Export\ExportProductionOrdersJob;
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\Import;
+use App\Models\ProductionCalendarDay;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class ProductionOrderControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    private User $assistant;
+
+    private CommonCatalogItem $catalogItem;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->admin = User::factory()->admin()->create();
+        $this->manager = User::factory()->manager()->create();
+        $this->assistant = User::factory()->assistantHead()->create();
+        $this->catalogItem = CommonCatalogItem::factory()->create([
+            'article' => 'ГЗ-001',
+            'calculator_name' => 'Игровой комплекс',
+        ]);
+    }
+
+    public function test_authorized_roles_can_view_year_independent_order_schedule(): void
+    {
+        $visible = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-2026',
+            'order_year' => 2026,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-2024',
+            'order_year' => 2024,
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->get(route('schedule.orders'))->assertRedirect(route('login'));
+
+        $this->actingAs($this->manager)
+            ->withSession(['year' => 2020])
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertViewIs('production_orders.index')
+            ->assertSee($visible->order_number)
+            ->assertSee('ГЗ-2024');
+
+        $driver = User::factory()->driver()->create();
+        $this->actingAs($driver)->get(route('schedule.orders'))->assertOk();
+    }
+
+    public function test_admin_can_create_order_with_items_and_calendar_shipment_date(): void
+    {
+        ProductionCalendarDay::factory()->create([
+            'date' => '2026-09-07',
+            'is_working_day' => false,
+            'name' => 'Перенесённый выходной',
+        ]);
+        $secondCatalogItem = CommonCatalogItem::factory()->create([
+            'article' => 'ГЗ-002',
+            'calculator_name' => 'Качели',
+        ]);
+
+        $response = $this->actingAs($this->admin)->post(
+            route('schedule.orders.store'),
+            $this->validData([
+                'order_number' => 'ЗАК-100',
+                'payment_date' => '2026-09-04',
+                'supply_working_days' => 1,
+                'items' => [
+                    [
+                        'common_catalog_item_id' => $this->catalogItem->id,
+                        'order_item_number' => 'МАФ-100/1',
+                    ],
+                    [
+                        'common_catalog_item_id' => $secondCatalogItem->id,
+                        'order_item_number' => 'МАФ-100/2',
+                        'factory_number' => 'ЗН-002',
+                        'manufacture_date' => '2026-09-03',
+                    ],
+                ],
+            ]),
+        );
+
+        $order = ProductionOrder::query()->where('order_number', 'ЗАК-100')->firstOrFail();
+
+        $response->assertRedirect();
+        $this->assertSame('2026-09-08', $order->contract_shipment_date->toDateString());
+        $this->assertSame($this->admin->id, $order->created_by);
+        $this->assertSame(2, $order->items()->count());
+        $this->assertDatabaseHas('production_order_items', [
+            'production_order_id' => $order->id,
+            'factory_number' => 'ЗН-002',
+            'manufacture_date' => '2026-09-03',
+        ]);
+    }
+
+    public function test_order_number_is_unique_within_year(): void
+    {
+        ProductionOrder::factory()->create([
+            'order_number' => 'ДУБЛЬ-1',
+            'order_year' => 2026,
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.store'), $this->validData([
+                'order_number' => 'ДУБЛЬ-1',
+                'order_year' => 2026,
+            ]))
+            ->assertSessionHasErrors('order_number');
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.store'), $this->validData([
+                'order_number' => 'ДУБЛЬ-1',
+                'order_year' => 2027,
+            ]))
+            ->assertSessionHasNoErrors();
+
+        $this->assertDatabaseHas('production_orders', [
+            'order_number' => 'ДУБЛЬ-1',
+            'order_year' => 2027,
+        ]);
+    }
+
+    public function test_manager_cannot_create_or_update_production_order(): void
+    {
+        $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.store'), $this->validData())
+            ->assertForbidden();
+
+        $this->actingAs($this->manager)
+            ->put(route('schedule.orders.update', $order), ['note' => 'Подмена'])
+            ->assertForbidden();
+    }
+
+    public function test_assistant_can_update_only_allowed_order_and_item_fields(): void
+    {
+        $otherCatalogItem = CommonCatalogItem::factory()->create();
+        $order = ProductionOrder::factory()->create([
+            'order_number' => 'ЗАК-OLD',
+            'customer_name' => 'Исходный заказчик',
+            'object_address' => 'Старый адрес',
+            'status' => ProductionOrderStatus::Placed,
+            'manager_id' => $this->manager->id,
+        ]);
+        $item = ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $this->catalogItem->id,
+            'order_item_number' => 'МАФ-OLD',
+        ]);
+
+        $this->actingAs($this->assistant)
+            ->put(route('schedule.orders.update', $order), [
+                'order_number' => 'ЗАК-FORGED',
+                'customer_name' => 'Подменённый заказчик',
+                'object_address' => 'Новый адрес',
+                'status' => ProductionOrderStatus::Closed->value,
+                'note' => 'Уточнённое примечание',
+                'items' => [[
+                    'id' => $item->id,
+                    'common_catalog_item_id' => $otherCatalogItem->id,
+                    'order_item_number' => 'МАФ-FORGED',
+                    'factory_number' => 'ЗН-NEW',
+                    'manufacture_date' => '2026-09-02',
+                ]],
+            ])
+            ->assertRedirect();
+
+        $order->refresh();
+        $item->refresh();
+
+        $this->assertSame('ЗАК-OLD', $order->order_number);
+        $this->assertSame('Исходный заказчик', $order->customer_name);
+        $this->assertSame('Новый адрес', $order->object_address);
+        $this->assertSame(ProductionOrderStatus::Placed, $order->status);
+        $this->assertSame('Уточнённое примечание', $order->note);
+        $this->assertSame($this->catalogItem->id, $item->common_catalog_item_id);
+        $this->assertSame('МАФ-OLD', $item->order_item_number);
+        $this->assertSame('ЗН-NEW', $item->factory_number);
+        $this->assertSame('2026-09-02', $item->manufacture_date?->toDateString());
+    }
+
+    public function test_order_card_displays_separate_catalog_items(): void
+    {
+        $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $this->catalogItem->id,
+            'order_item_number' => 'МАФ-КАРТОЧКА-1',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.show', $order))
+            ->assertOk()
+            ->assertSee('ГЗ-001')
+            ->assertSee('Игровой комплекс')
+            ->assertSee('МАФ-КАРТОЧКА-1');
+    }
+
+    public function test_status_filter_uses_labels_and_filters_by_enum_value(): void
+    {
+        ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-РАЗМЕЩЕН',
+            'status' => ProductionOrderStatus::Placed,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-СКЛАД',
+            'status' => ProductionOrderStatus::InStock,
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'schedule_orders',
+                'column' => 'status_name',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['Размещен'])
+            ->assertJsonFragment(['На складе']);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders', [
+                'filters' => ['status_name' => 'На складе'],
+            ]))
+            ->assertOk()
+            ->assertSee('ГЗ-СКЛАД')
+            ->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([
+            'order_number' => 'ГЗ-ЗАКРЫТ',
+            'status' => ProductionOrderStatus::Closed,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $closed->id,
+            'delivery_date' => '2026-09-14',
+        ]);
+        ProductionOrderInstallation::factory()->create([
+            'production_order_id' => $closed->id,
+            'installation_date' => '2026-09-16',
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertSee('Дата доставки')
+            ->assertSee('Дата монтажа')
+            ->assertSee('14.09.2026')
+            ->assertSee('16.09.2026')
+            ->assertSee('table-dark opacity-50', false);
+    }
+
+    public function test_planning_date_filters_match_any_related_record(): void
+    {
+        $matching = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-ДАТА-НУЖНАЯ',
+            'manager_id' => $this->manager->id,
+        ]);
+        $other = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-ДАТА-ДРУГАЯ',
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $matching->id,
+            'delivery_date' => '2026-09-20',
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $other->id,
+            'delivery_date' => '2026-09-21',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'schedule_orders',
+                'column' => 'delivery_dates',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['2026-09-20']);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders', [
+                'filters' => ['delivery_dates' => '2026-09-20'],
+            ]))
+            ->assertOk()
+            ->assertSee($matching->order_number)
+            ->assertDontSee($other->order_number);
+    }
+
+    public function test_application_shipment_date_is_admin_only_by_default(): void
+    {
+        $this->assertTrue($this->admin->canViewField('schedule-orders', 'application_shipment_date'));
+        $this->assertFalse($this->manager->canViewField('schedule-orders', 'application_shipment_date'));
+
+        $order = ProductionOrder::factory()->create([
+            'application_shipment_date' => '2026-11-27',
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertSee('Дата отгрузки по заявке')
+            ->assertSee('27.11.2026');
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertDontSee('Дата отгрузки по заявке')
+            ->assertDontSee('27.11.2026');
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.show', $order))
+            ->assertOk()
+            ->assertDontSee('Дата отгрузки по заявке')
+            ->assertDontSee('2026-11-27');
+    }
+
+    public function test_order_card_displays_catalog_item_image_and_graph_actions(): void
+    {
+        $image = File::factory()->create([
+            'link' => 'https://example.test/catalog/maf.jpg',
+            'mime_type' => 'image/jpeg',
+        ]);
+        $this->catalogItem->update(['image_file_id' => $image->id]);
+        $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $this->catalogItem->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders.show', $order))
+            ->assertOk()
+            ->assertSee('https://example.test/catalog/maf.jpg', false)
+            ->assertSee('Перенести в график доставок')
+            ->assertSee('Перенести в график монтажей')
+            ->assertSee('Создать рекламацию');
+    }
+
+    public function test_admin_can_export_only_orders_matching_active_filters_and_search(): void
+    {
+        Bus::fake([ExportProductionOrdersJob::class]);
+        $matching = ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-НУЖНЫЙ',
+            'customer_name' => 'Целевой заказчик',
+            'status' => ProductionOrderStatus::InStock,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-ДРУГОЙ-СТАТУС',
+            'customer_name' => 'Целевой заказчик',
+            'status' => ProductionOrderStatus::Placed,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-ДРУГОЙ-ПОИСК',
+            'customer_name' => 'Другой заказчик',
+            'status' => ProductionOrderStatus::InStock,
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.export'), [
+                'filters' => ['status_name' => ProductionOrderStatus::InStock->label()],
+                's' => 'Целевой',
+            ])
+            ->assertRedirect(route('schedule.orders'))
+            ->assertSessionHas('success');
+
+        Bus::assertDispatched(
+            ExportProductionOrdersJob::class,
+            static fn (ExportProductionOrdersJob $job): bool => $job->orderIds === [$matching->id]
+                && $job->userId > 0,
+        );
+    }
+
+    public function test_production_order_export_requires_export_permission(): void
+    {
+        Bus::fake([ExportProductionOrdersJob::class]);
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.export'))
+            ->assertForbidden();
+
+        Bus::assertNotDispatched(ExportProductionOrdersJob::class);
+    }
+
+    public function test_admin_can_queue_production_order_import(): void
+    {
+        Bus::fake([ImportJob::class]);
+        Storage::fake('upload');
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.import'), [
+                'import_file' => UploadedFile::fake()->create(
+                    'production-orders.xlsx',
+                    20,
+                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ),
+            ])
+            ->assertRedirect(route('schedule.orders'))
+            ->assertSessionHas('success');
+
+        $import = Import::query()->where('type', 'production_orders')->firstOrFail();
+        $this->assertSame($this->admin->id, $import->user_id);
+        $this->assertSame('production-orders.xlsx', $import->original_filename);
+        Storage::disk('upload')->assertExists($import->filename);
+        Bus::assertDispatched(ImportJob::class);
+    }
+
+    public function test_production_order_import_requires_import_permission(): void
+    {
+        Bus::fake([ImportJob::class]);
+        Storage::fake('upload');
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.import'), [
+                'import_file' => UploadedFile::fake()->create(
+                    'production-orders.xlsx',
+                    20,
+                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ),
+            ])
+            ->assertForbidden();
+
+        $this->assertDatabaseMissing('imports', ['type' => 'production_orders']);
+        Bus::assertNotDispatched(ImportJob::class);
+    }
+
+    public function test_admin_can_search_common_catalog_when_adding_order_items(): void
+    {
+        $matchingByName = CommonCatalogItem::factory()->create([
+            'article' => 'SEARCH-002',
+            'calculator_name' => 'Особенная карусель',
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => 'OTHER-003',
+            'calculator_name' => 'Другая позиция',
+        ]);
+        $deleted = CommonCatalogItem::factory()->create([
+            'article' => 'SEARCH-DELETED',
+            'calculator_name' => 'Удалённая карусель',
+        ]);
+        $deleted->delete();
+
+        $this->actingAs($this->admin)
+            ->getJson(route('schedule.orders.catalog-search', ['q' => 'карусель']))
+            ->assertOk()
+            ->assertJsonFragment([
+                'id' => $matchingByName->id,
+                'label' => 'SEARCH-002 — Особенная карусель',
+            ])
+            ->assertJsonMissing(['id' => $deleted->id]);
+
+        $this->actingAs($this->admin)
+            ->getJson(route('schedule.orders.catalog-search', ['q' => 'ГЗ-001']))
+            ->assertOk()
+            ->assertJsonFragment(['id' => $this->catalogItem->id]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('schedule.orders.catalog-search', ['q' => 'карусель']))
+            ->assertForbidden();
+    }
+
+    public function test_create_form_loads_catalog_items_only_through_search(): void
+    {
+        $unrelated = CommonCatalogItem::factory()->create([
+            'article' => 'НЕ-ЗАГРУЖАТЬ',
+            'calculator_name' => 'Тяжёлая позиция каталога',
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders.create'))
+            ->assertOk()
+            ->assertSee('Добавить МАФ из общего каталога')
+            ->assertSee('production-order-catalog-search', false)
+            ->assertDontSee($unrelated->article);
+    }
+
+    /** @return array<string, mixed> */
+    private function validData(array $overrides = []): array
+    {
+        return array_replace_recursive([
+            'order_number' => 'ЗАК-001',
+            'order_year' => 2026,
+            'customer_name' => 'ООО Заказчик',
+            'object_address' => 'г. Москва, ул. Строителей, 1',
+            'invoice_number' => 'СЧ-001',
+            'invoice_date' => '2026-09-01',
+            'contract_number' => 'Д-001',
+            'contract_date' => '2026-08-31',
+            'payment_date' => '2026-09-01',
+            'supply_working_days' => 10,
+            'application_shipment_date' => '2026-09-20',
+            'status' => ProductionOrderStatus::Placed->value,
+            'execution_type' => ProductionOrderExecutionType::Delivery->value,
+            'manager_id' => $this->manager->id,
+            'note' => 'Тестовый заказ',
+            'items' => [[
+                'common_catalog_item_id' => $this->catalogItem->id,
+                'order_item_number' => 'МАФ-001',
+                'factory_number' => null,
+                'manufacture_date' => null,
+            ]],
+        ], $overrides);
+    }
+}

+ 253 - 0
tests/Feature/ProductionOrderDocumentControllerTest.php

@@ -0,0 +1,253 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Jobs\GenerateProductionOrderFileJob;
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use App\Services\ProductionOrderDocumentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+use ZipArchive;
+
+class ProductionOrderDocumentControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    private User $driver;
+
+    private User $brigadier;
+
+    private ProductionOrder $order;
+
+    private ProductionOrderItem $item;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        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();
+        $this->brigadier = User::factory()->brigadier()->create();
+        $this->order = ProductionOrder::factory()->create([
+            'order_number' => 'ДОК-001',
+            'customer_name' => 'ООО Заказчик',
+            'object_address' => 'г. Москва, ул. Доставки, 1',
+            'invoice_number' => 'СЧ-55',
+            'invoice_date' => '2026-09-01',
+            'contract_number' => 'Д-77',
+            'contract_date' => '2026-08-20',
+            'manager_id' => $this->manager->id,
+        ]);
+        $catalogItem = CommonCatalogItem::factory()->create([
+            'article' => 'ART-100',
+            'calculator_name' => 'Качели',
+        ]);
+        $this->item = ProductionOrderItem::factory()->create([
+            'production_order_id' => $this->order->id,
+            'common_catalog_item_id' => $catalogItem->id,
+            'order_item_number' => 'МАФ-100',
+            'factory_number' => 'ЗН-100',
+            'manufacture_date' => '2026-09-02',
+        ]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $this->order->id,
+            'common_catalog_item_id' => $catalogItem->id,
+            'order_item_number' => 'МАФ-101',
+        ]);
+    }
+
+    public function test_export_items_contains_order_header_and_each_item(): void
+    {
+        $file = app(ProductionOrderDocumentService::class)->generate(
+            ProductionOrderDocumentService::TYPE_ITEMS,
+            $this->order->id,
+            $this->admin->id,
+        );
+
+        Storage::disk('local')->assertExists($file->path);
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getActiveSheet();
+        $this->assertSame('Оборудование заказа №ДОК-001', $sheet->getCell('A1')->getValue());
+        $this->assertStringContainsString('ООО Заказчик', (string) $sheet->getCell('A2')->getValue());
+        $this->assertSame('ART-100', $sheet->getCell('A5')->getValue());
+        $this->assertSame('МАФ-101', $sheet->getCell('C6')->getValue());
+    }
+
+    public function test_delivery_request_fills_provided_template_and_preserves_print_layout(): void
+    {
+        $delivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+            'delivery_date' => '2026-09-15',
+            'request_number' => 'ЗД-15',
+            'request_date' => '2026-09-10',
+            'contact_name' => 'Иван Иванов',
+            'contact_phone' => '+7 999 000-00-00',
+            'has_passports_and_certificates' => true,
+            'note' => 'Позвонить за час',
+        ]);
+
+        $file = app(ProductionOrderDocumentService::class)->generate(
+            ProductionOrderDocumentService::TYPE_DELIVERY,
+            $this->order->id,
+            $this->admin->id,
+            $delivery->id,
+        );
+
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getActiveSheet();
+        $this->assertSame('B1:AG94', $sheet->getPageSetup()->getPrintArea());
+        $this->assertCount(2, $sheet->getDrawingCollection());
+        $this->assertSame('СЧ-55', $sheet->getCell('G4')->getValue());
+        $this->assertSame('ДОК-001', $sheet->getCell('G6')->getValue());
+        $this->assertSame('Иван Иванов', $sheet->getCell('L13')->getValue());
+        $this->assertStringContainsString('ART-100 — Качели — 2 шт.', (string) $sheet->getCell('G25')->getValue());
+    }
+
+    public function test_installation_and_technical_archives_include_catalog_documents(): void
+    {
+        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',
+        ]);
+        $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,
+            'installation_date' => '2026-09-20',
+        ]);
+
+        $technicalFile = app(ProductionOrderDocumentService::class)->generate(
+            ProductionOrderDocumentService::TYPE_TECHNICAL_DOCUMENTS,
+            $this->order->id,
+            $this->admin->id,
+            itemIds: [$this->item->id],
+        );
+        $installationFile = app(ProductionOrderDocumentService::class)->generate(
+            ProductionOrderDocumentService::TYPE_INSTALLATION,
+            $this->order->id,
+            $this->admin->id,
+            $installation->id,
+        );
+
+        $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')));
+    }
+
+    public function test_document_actions_are_queued_and_related_records_are_scoped_to_order(): void
+    {
+        Bus::fake();
+        $delivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+        ]);
+        $otherOrder = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        $otherDelivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $otherOrder->id,
+            'driver_id' => $this->driver->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.export-items', $this->order))
+            ->assertRedirect();
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.deliveries.document', [$this->order, $delivery]))
+            ->assertRedirect();
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.technical-documents', $this->order), ['item_ids' => [$this->item->id]])
+            ->assertRedirect();
+        Bus::assertDispatchedTimes(GenerateProductionOrderFileJob::class, 3);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.deliveries.document', [$this->order, $otherDelivery]))
+            ->assertNotFound();
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.export-items', $this->order))
+            ->assertForbidden();
+    }
+
+    public function test_generated_file_is_private_to_owner_and_admin(): void
+    {
+        $otherManager = User::factory()->manager()->create();
+        $file = app(ProductionOrderDocumentService::class)->generate(
+            ProductionOrderDocumentService::TYPE_ITEMS,
+            $this->order->id,
+            $this->manager->id,
+        );
+
+        $this->actingAs($otherManager)
+            ->get(route('schedule.orders.files.download', $file))
+            ->assertForbidden();
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.files.download', $file))
+            ->assertOk()
+            ->assertHeader('content-disposition');
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders.files.download', $file))
+            ->assertOk()
+            ->assertHeader('content-disposition');
+    }
+
+    /** @return list<string> */
+    private function zipEntries(string $path): array
+    {
+        $zip = new ZipArchive;
+        $this->assertTrue($zip->open($path) === true);
+        $entries = [];
+        for ($index = 0; $index < $zip->numFiles; $index++) {
+            $entries[] = $zip->getNameIndex($index);
+        }
+        $zip->close();
+
+        return $entries;
+    }
+}

+ 208 - 0
tests/Feature/ProductionOrderFileAndChatControllerTest.php

@@ -0,0 +1,208 @@
+<?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_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]);
+        $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('Фотографии (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);
+    }
+
+    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();
+    }
+}

+ 144 - 0
tests/Feature/ProductionOrderNotificationTest.php

@@ -0,0 +1,144 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderNotificationEvent;
+use App\Enums\ProductionOrderStatus;
+use App\Jobs\SendUserNotificationChannelJob;
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\User;
+use App\Models\UserNotification;
+use App\Models\UserNotificationSetting;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ProductionOrderNotificationTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Queue::fake();
+    }
+
+    public function test_order_creation_notifies_manager_when_event_channel_is_enabled(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+        $catalogItem = CommonCatalogItem::factory()->create();
+        $this->enableEvent($manager, ProductionOrderNotificationEvent::Created);
+
+        $this->actingAs($admin)
+            ->post(route('schedule.orders.store'), [
+                'order_number' => 'УВ-001',
+                'order_year' => 2026,
+                'customer_name' => 'Заказчик',
+                'object_address' => 'ул. Тестовая, 1',
+                'invoice_number' => 'СЧ-1',
+                'payment_date' => '2026-09-01',
+                'supply_working_days' => 5,
+                'status' => ProductionOrderStatus::Placed->value,
+                'execution_type' => ProductionOrderExecutionType::Delivery->value,
+                'manager_id' => $manager->id,
+                'items' => [[
+                    'common_catalog_item_id' => $catalogItem->id,
+                    'order_item_number' => 'МАФ-1',
+                ]],
+            ])
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('user_notifications', [
+            'user_id' => $manager->id,
+            'type' => UserNotification::TYPE_PRODUCTION_ORDER,
+            'event' => ProductionOrderNotificationEvent::Created->value,
+        ]);
+        Queue::assertPushed(SendUserNotificationChannelJob::class);
+    }
+
+    public function test_delivery_and_installation_notify_bound_driver_and_brigadier(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+        $driver = User::factory()->driver()->create();
+        $brigadier = User::factory()->brigadier()->create();
+        $order = ProductionOrder::factory()->create(['manager_id' => $manager->id]);
+        $this->enableEvent($driver, ProductionOrderNotificationEvent::DeliveryAdded);
+        $this->enableEvent($brigadier, ProductionOrderNotificationEvent::InstallationAdded);
+
+        $this->actingAs($admin)
+            ->post(route('schedule.orders.deliveries.store', $order), [
+                'delivery_date' => '2026-09-10',
+                'driver_id' => $driver->id,
+                'has_passports_and_certificates' => true,
+            ])
+            ->assertRedirect();
+        $this->actingAs($admin)
+            ->post(route('schedule.orders.installations.store', $order), [
+                'installation_date' => '2026-09-12',
+                'installation_days' => 2,
+                'brigadier_id' => $brigadier->id,
+            ])
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('user_notifications', [
+            'user_id' => $driver->id,
+            'event' => ProductionOrderNotificationEvent::DeliveryAdded->value,
+        ]);
+        $this->assertDatabaseHas('user_notifications', [
+            'user_id' => $brigadier->id,
+            'event' => ProductionOrderNotificationEvent::InstallationAdded->value,
+        ]);
+        $this->assertDatabaseMissing('user_notifications', [
+            'user_id' => $manager->id,
+            'type' => UserNotification::TYPE_PRODUCTION_ORDER,
+        ]);
+    }
+
+    public function test_admin_can_configure_production_order_events_for_user(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+
+        $this->actingAs($admin)
+            ->get(route('user.show', $manager))
+            ->assertOk()
+            ->assertSee('График заказов')
+            ->assertSee('Добавлена доставка');
+
+        $this->actingAs($admin)
+            ->post(route('user.store'), [
+                'id' => $manager->id,
+                'name' => $manager->name,
+                'email' => $manager->email,
+                'role_id' => $manager->role_id,
+                'notification_settings' => [
+                    'production_orders' => [
+                        ProductionOrderNotificationEvent::StatusChanged->value => ['browser' => '1'],
+                    ],
+                ],
+            ])
+            ->assertRedirect(route('user.index'));
+
+        $settings = UserNotificationSetting::query()->where('user_id', $manager->id)->firstOrFail();
+        $this->assertTrue($settings->production_order_settings['status_changed']['browser']);
+        $this->assertFalse($settings->production_order_settings['created']['browser']);
+    }
+
+    private function enableEvent(User $user, ProductionOrderNotificationEvent $event): void
+    {
+        UserNotificationSetting::query()->create([
+            'user_id' => $user->id,
+            'production_order_settings' => [
+                $event->value => ['browser' => true, 'push' => false, 'email' => false],
+            ],
+        ]);
+    }
+}

+ 326 - 0
tests/Feature/ProductionOrderPlanningControllerTest.php

@@ -0,0 +1,326 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Enums\ProductionOrderActivityType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ProductionOrderPlanningControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $assistant;
+
+    private User $manager;
+
+    private User $driver;
+
+    private User $brigadier;
+
+    private ProductionOrder $order;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->admin = User::factory()->admin()->create();
+        $this->assistant = User::factory()->assistantHead()->create();
+        $this->manager = User::factory()->manager()->create();
+        $this->driver = User::factory()->driver()->create();
+        $this->brigadier = User::factory()->brigadier()->create();
+        $this->order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+    }
+
+    public function test_admin_can_add_multiple_deliveries_and_installations_with_audit(): void
+    {
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.deliveries.store', $this->order), $this->deliveryData([
+                'delivery_date' => '2026-09-10',
+            ]))
+            ->assertRedirect();
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.deliveries.store', $this->order), $this->deliveryData([
+                'delivery_date' => '2026-09-12',
+                'driver_id' => $this->brigadier->id,
+            ]))
+            ->assertRedirect();
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.installations.store', $this->order), $this->installationData())
+            ->assertRedirect();
+
+        $this->assertSame(2, $this->order->deliveries()->count());
+        $this->assertSame(1, $this->order->installations()->count());
+        $installation = $this->order->installations()->firstOrFail();
+        $this->assertSame(3, $installation->schedules()->count());
+        $this->assertSame(
+            ['2026-09-20', '2026-09-21', '2026-09-22'],
+            $installation->schedules()->orderBy('installation_date')->pluck('installation_date')->all(),
+        );
+        $this->assertDatabaseHas('production_order_activities', [
+            'production_order_id' => $this->order->id,
+            'type' => ProductionOrderActivityType::DeliveryScheduled->value,
+            'user_id' => $this->admin->id,
+            'message' => 'Назначена доставка на 10.09.2026, водитель '.$this->driver->name.'.',
+        ]);
+        $this->assertDatabaseHas('production_order_activities', [
+            'production_order_id' => $this->order->id,
+            'type' => ProductionOrderActivityType::InstallationScheduled->value,
+            'user_id' => $this->admin->id,
+            'message' => 'Назначен монтаж на 20.09.2026, бригадир '.$this->brigadier->name.'.',
+        ]);
+        $this->assertSame(3, $this->order->activities()->count());
+    }
+
+    public function test_assistant_can_update_and_delete_planning_records_without_extra_audit(): void
+    {
+        $delivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+            'delivery_date' => '2026-09-10',
+        ]);
+        $installation = ProductionOrderInstallation::factory()->create([
+            'production_order_id' => $this->order->id,
+            'brigadier_id' => $this->brigadier->id,
+        ]);
+
+        $this->actingAs($this->assistant)
+            ->put(
+                route('schedule.orders.deliveries.update', [$this->order, $delivery]),
+                $this->deliveryData(['delivery_date' => '2026-09-15']),
+            )
+            ->assertRedirect();
+
+        $this->actingAs($this->assistant)
+            ->put(
+                route('schedule.orders.installations.update', [$this->order, $installation]),
+                $this->installationData(['installation_days' => 5]),
+            )
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('production_order_deliveries', [
+            'id' => $delivery->id,
+            'delivery_date' => '2026-09-15',
+            'updated_by' => $this->assistant->id,
+        ]);
+        $this->assertDatabaseHas('production_order_installations', [
+            'id' => $installation->id,
+            'installation_days' => 5,
+            'updated_by' => $this->assistant->id,
+        ]);
+        $this->assertSame(5, $installation->schedules()->count());
+        $this->assertDatabaseHas('schedules', [
+            'production_order_installation_id' => $installation->id,
+            'installation_date' => '2026-09-24',
+            'source' => \App\Models\Schedule::SOURCE_PRODUCTION_ORDER,
+            'brigadier_id' => $this->brigadier->id,
+        ]);
+        $this->assertSame(0, $this->order->activities()->count());
+
+        $this->actingAs($this->assistant)
+            ->delete(route('schedule.orders.deliveries.destroy', [$this->order, $delivery]))
+            ->assertRedirect();
+        $this->actingAs($this->assistant)
+            ->delete(route('schedule.orders.installations.destroy', [$this->order, $installation]))
+            ->assertRedirect();
+
+        $this->assertSoftDeleted('production_order_deliveries', ['id' => $delivery->id]);
+        $this->assertSoftDeleted('production_order_installations', ['id' => $installation->id]);
+        $this->assertDatabaseMissing('schedules', ['production_order_installation_id' => $installation->id]);
+    }
+
+    public function test_manager_cannot_manage_delivery_or_installation(): void
+    {
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.deliveries.store', $this->order), $this->deliveryData())
+            ->assertForbidden();
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.installations.store', $this->order), $this->installationData())
+            ->assertForbidden();
+    }
+
+    public function test_delivery_and_installation_require_users_with_allowed_roles(): void
+    {
+        $wrongRole = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.deliveries.store', $this->order), $this->deliveryData([
+                'driver_id' => $wrongRole->id,
+            ]))
+            ->assertSessionHasErrors('driver_id');
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.installations.store', $this->order), $this->installationData([
+                'brigadier_id' => $this->driver->id,
+            ]))
+            ->assertSessionHasErrors('brigadier_id');
+    }
+
+    public function test_planning_record_from_another_order_returns_not_found(): void
+    {
+        $otherOrder = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        $delivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $otherOrder->id,
+            'driver_id' => $this->driver->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->put(
+                route('schedule.orders.deliveries.update', [$this->order, $delivery]),
+                $this->deliveryData(),
+            )
+            ->assertNotFound();
+    }
+
+    public function test_order_card_shows_planning_and_audit_to_read_only_role(): void
+    {
+        $delivery = ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+            'delivery_date' => '2026-09-18',
+        ]);
+        $this->order->activities()->create([
+            'type' => ProductionOrderActivityType::DeliveryScheduled,
+            'user_id' => $this->admin->id,
+            'delivery_id' => $delivery->id,
+            'message' => 'Заказ добавлен в график доставок.',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.show', $this->order))
+            ->assertOk()
+            ->assertSee('18.09.2026')
+            ->assertSee($this->driver->name)
+            ->assertSee('Заказ добавлен в график доставок.')
+            ->assertDontSee('Добавить доставку');
+    }
+
+    public function test_status_change_is_the_only_order_field_update_written_to_audit(): void
+    {
+        $this->order->update(['note' => 'Обычное изменение']);
+        $this->assertSame(0, $this->order->activities()->count());
+
+        $service = app(\App\Services\ProductionOrderService::class);
+        $service->update(
+            $this->order,
+            ['status' => ProductionOrderStatus::InTransit->value],
+            $this->admin,
+            false,
+        );
+
+        $this->assertDatabaseHas('production_order_activities', [
+            'production_order_id' => $this->order->id,
+            'type' => ProductionOrderActivityType::StatusChanged->value,
+            'message' => 'Статус изменён на «В пути».',
+        ]);
+        $this->assertSame(1, $this->order->activities()->count());
+    }
+
+    public function test_weekly_delivery_schedule_contains_only_selected_week(): void
+    {
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+            'delivery_date' => '2026-09-10',
+            'delivery_time' => '10:30',
+            'carrier' => 'Нужный перевозчик',
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $this->order->id,
+            'driver_id' => $this->driver->id,
+            'delivery_date' => '2026-09-20',
+            'carrier' => 'Перевозчик другой недели',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->withSession(['year' => 2020])
+            ->get(route('schedule.deliveries', ['week' => '2026-09-07']))
+            ->assertOk()
+            ->assertViewIs('production_order_deliveries.index')
+            ->assertSee('Нужный перевозчик')
+            ->assertSee($this->order->order_number)
+            ->assertSee($this->driver->name)
+            ->assertDontSee('Перевозчик другой недели');
+
+        $this->actingAs($this->driver)
+            ->get(route('schedule.deliveries', ['week' => '2026-09-07']))
+            ->assertOk();
+    }
+
+    public function test_installation_is_visible_in_existing_schedule_and_managed_from_order_card(): void
+    {
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.installations.store', $this->order), $this->installationData([
+                'installation_date' => '2026-09-15',
+                'installation_days' => 2,
+            ]))
+            ->assertRedirect();
+
+        $installation = $this->order->installations()->firstOrFail();
+        $schedule = $installation->schedules()->firstOrFail();
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.index', ['tab' => 'week', 'year' => 2026, 'week' => 38]))
+            ->assertOk()
+            ->assertSee($this->order->order_number)
+            ->assertSee($this->order->object_address)
+            ->assertSee('Размещен')
+            ->assertSee(route('schedule.orders.show', $this->order), false);
+
+        $this->actingAs($this->admin)
+            ->delete(route('schedule.delete', $schedule))
+            ->assertForbidden();
+
+        $this->assertDatabaseHas('schedules', ['id' => $schedule->id]);
+    }
+
+    /** @return array<string, mixed> */
+    private function deliveryData(array $overrides = []): array
+    {
+        return array_replace([
+            'delivery_date' => '2026-09-10',
+            'delivery_time' => '10:30',
+            'driver_id' => $this->driver->id,
+            'carrier' => 'ООО Перевозчик',
+            'request_number' => 'ЗД-100',
+            'request_date' => '2026-09-08',
+            'carrier_note' => 'Позвонить заранее',
+            'contact_position' => 'Прораб',
+            'contact_name' => 'Иван Иванов',
+            'contact_phone' => '+7 900 000-00-00',
+            'desired_time' => '11:00',
+            'access_system' => 'По пропуску',
+            'unloading_place' => 'Вторые ворота',
+            'advance_notice' => 'За один час',
+            'document_signing_method' => 'Оригинал на объекте',
+            'has_passports_and_certificates' => true,
+            'note' => 'Без опозданий',
+        ], $overrides);
+    }
+
+    /** @return array<string, mixed> */
+    private function installationData(array $overrides = []): array
+    {
+        return array_replace([
+            'installation_date' => '2026-09-20',
+            'installation_days' => 3,
+            'brigadier_id' => $this->brigadier->id,
+            'note' => 'Первый этап',
+        ], $overrides);
+    }
+}

+ 119 - 0
tests/Feature/ProductionOrderReclamationControllerTest.php

@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\Reclamation;
+use App\Models\ReclamationType;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ProductionOrderReclamationControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_admin_can_create_other_reclamation_for_selected_order_items(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+        $order = ProductionOrder::factory()->create([
+            'customer_name' => 'Заказчик рекламации',
+            'object_address' => 'Адрес производственного заказа',
+            'manager_id' => $manager->id,
+        ]);
+        $catalogItem = CommonCatalogItem::factory()->create(['article' => 'РЕКЛ-МАФ-1']);
+        $item = ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $catalogItem->id,
+            'order_item_number' => 'ПОЗ-1',
+            'factory_number' => 'ЗАВ-1',
+            'manufacture_date' => '2026-08-30',
+        ]);
+
+        $response = $this->actingAs($admin)->post(
+            route('schedule.orders.reclamations.store', $order),
+            ['item_ids' => [$item->id]],
+        );
+
+        $reclamation = Reclamation::query()->where('production_order_id', $order->id)->firstOrFail();
+        $response->assertRedirectContains('/reclamations/show/'.$reclamation->id.'?nav=');
+        $this->assertNull($reclamation->order_id);
+        $this->assertSame($manager->id, $reclamation->user_id);
+        $this->assertSame(
+            ReclamationType::CODE_OTHER,
+            $reclamation->reclamationType()->value('code'),
+        );
+        $this->assertDatabaseHas('production_order_item_reclamation', [
+            'reclamation_id' => $reclamation->id,
+            'production_order_item_id' => $item->id,
+        ]);
+
+        $this->actingAs($admin)
+            ->get(route('reclamations.show', $reclamation))
+            ->assertOk()
+            ->assertSee('Заказчик рекламации')
+            ->assertSee('Адрес производственного заказа')
+            ->assertSee('РЕКЛ-МАФ-1')
+            ->assertSee('ПОЗ-1')
+            ->assertSee('ЗАВ-1')
+            ->assertDontSee('Пакет документов на оплату')
+            ->assertDontSee('Пакет документов рекламации');
+    }
+
+    public function test_reclamation_items_must_belong_to_selected_production_order(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+        $order = ProductionOrder::factory()->create(['manager_id' => $manager->id]);
+        $otherOrder = ProductionOrder::factory()->create(['manager_id' => $manager->id]);
+        $otherItem = ProductionOrderItem::factory()->create([
+            'production_order_id' => $otherOrder->id,
+        ]);
+
+        $this->actingAs($admin)
+            ->post(route('schedule.orders.reclamations.store', $order), [
+                'item_ids' => [$otherItem->id],
+            ])
+            ->assertSessionHasErrors('item_ids.0');
+
+        $this->assertDatabaseMissing('reclamations', ['production_order_id' => $order->id]);
+    }
+
+    public function test_other_reclamation_is_visible_in_all_tab_and_excluded_from_dkr_tab(): void
+    {
+        $admin = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create();
+        $order = ProductionOrder::factory()->create([
+            'customer_name' => 'Заказчик только прочее',
+            'object_address' => 'Адрес только прочее',
+            'manager_id' => $manager->id,
+        ]);
+        $reclamation = Reclamation::query()->create([
+            'order_id' => null,
+            'production_order_id' => $order->id,
+            'reclamation_type_id' => ReclamationType::idForCode(ReclamationType::CODE_OTHER),
+            'user_id' => $manager->id,
+            'status_id' => Reclamation::STATUS_NEW,
+            'create_date' => now(),
+            'finish_date' => now()->addDays(30),
+        ]);
+
+        $this->actingAs($admin)
+            ->get(route('reclamations.index'))
+            ->assertOk()
+            ->assertSee((string) $reclamation->id)
+            ->assertSee('Адрес только прочее');
+
+        $this->actingAs($admin)
+            ->get(route('reclamations.index', ['tab' => ReclamationType::CODE_DKR]))
+            ->assertOk()
+            ->assertDontSee('Адрес только прочее');
+    }
+}

+ 4 - 1
tests/Unit/Helpers/RolesHelperTest.php

@@ -22,6 +22,7 @@ class RolesHelperTest extends TestCase
         $this->assertArrayHasKey(Role::BRIGADIER, $roles);
         $this->assertArrayHasKey(Role::WAREHOUSE_HEAD, $roles);
         $this->assertArrayHasKey(Role::ASSISTANT_HEAD, $roles);
+        $this->assertArrayHasKey(Role::DRIVER, $roles);
     }
 
     public function test_get_roles_returns_name_by_key(): void
@@ -31,6 +32,7 @@ class RolesHelperTest extends TestCase
         $this->assertEquals('Бригадир', getRoles(Role::BRIGADIER));
         $this->assertEquals('Рук. Склада', getRoles(Role::WAREHOUSE_HEAD));
         $this->assertEquals('Помощник рук.', getRoles(Role::ASSISTANT_HEAD));
+        $this->assertEquals('Водитель', getRoles(Role::DRIVER));
     }
 
     public function test_get_roles_returns_all_when_key_not_found(): void
@@ -38,7 +40,7 @@ class RolesHelperTest extends TestCase
         $result = getRoles('nonexistent');
 
         $this->assertIsArray($result);
-        $this->assertCount(5, $result);
+        $this->assertCount(6, $result);
     }
 
     public function test_has_role_returns_true_for_correct_role(): void
@@ -85,6 +87,7 @@ class RolesHelperTest extends TestCase
         $this->assertEquals('Бригадир', roleName(Role::BRIGADIER));
         $this->assertEquals('Рук. Склада', roleName(Role::WAREHOUSE_HEAD));
         $this->assertEquals('Помощник рук.', roleName(Role::ASSISTANT_HEAD));
+        $this->assertEquals('Водитель', roleName(Role::DRIVER));
     }
 
     public function test_file_name_replaces_special_chars(): void

+ 39 - 0
tests/Unit/Models/ProductionOrderTest.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Models;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\ProductionOrderItem;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ProductionOrderTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_order_relations_keep_items_deliveries_and_installations_normalized(): void
+    {
+        $order = ProductionOrder::factory()->create([
+            'status' => ProductionOrderStatus::InTransit,
+            'execution_type' => ProductionOrderExecutionType::DeliveryAndInstallation,
+        ]);
+        ProductionOrderItem::factory()->count(2)->for($order)->create();
+        ProductionOrderDelivery::factory()->count(2)->for($order)->create();
+        ProductionOrderInstallation::factory()->count(2)->for($order)->create();
+
+        $order->load(['items', 'deliveries', 'installations']);
+
+        $this->assertCount(2, $order->items);
+        $this->assertCount(2, $order->deliveries);
+        $this->assertCount(2, $order->installations);
+        $this->assertSame(ProductionOrderStatus::InTransit, $order->status);
+        $this->assertSame('В пути', $order->statusLabel());
+        $this->assertSame('Доставка + Монтаж', $order->executionTypeLabel());
+    }
+}

+ 18 - 0
tests/Unit/Services/AccessServiceTest.php

@@ -144,6 +144,24 @@ class AccessServiceTest extends TestCase
         $this->assertTrue(app(AccessService::class)->can($assistantHead, 'maf_orders.delete'));
     }
 
+    public function test_production_order_default_permissions_follow_agreed_roles(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $driver = User::factory()->create(['role' => Role::DRIVER]);
+
+        $this->assertTrue($admin->hasPermission('schedule-orders.create'));
+        $this->assertTrue($assistant->hasPermission('schedule-orders.update'));
+        $this->assertTrue($assistant->canUpdateField('schedule-orders', 'object_address'));
+        $this->assertFalse($assistant->canUpdateField('schedule-orders', 'order_number'));
+        $this->assertFalse($assistant->hasPermission('schedule-orders.create'));
+        $this->assertTrue($manager->hasPermission('schedule-orders.view'));
+        $this->assertFalse($manager->hasPermission('schedule-orders.update'));
+        $this->assertTrue($driver->hasPermission('schedule-orders.view'));
+        $this->assertFalse($driver->hasPermission('schedule-orders.update'));
+    }
+
     public function test_custom_role_with_admin_permissions_has_same_action_access(): void
     {
         $this->seed(RbacSeeder::class);

+ 128 - 0
tests/Unit/Services/Export/ExportProductionOrdersServiceTest.php

@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Export;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use App\Services\Export\ExportProductionOrdersService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportProductionOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_export_contains_all_order_fields_and_aggregated_equipment(): void
+    {
+        Storage::fake('local');
+        $user = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create(['name' => 'Менеджер экспорта']);
+        $playground = CommonCatalogItem::factory()->create([
+            'article' => '0007',
+            'calculator_name' => 'Игровой комплекс',
+        ]);
+        $swing = CommonCatalogItem::factory()->create([
+            'article' => '0011',
+            'calculator_name' => 'Качели',
+        ]);
+        $order = ProductionOrder::factory()->create([
+            'order_number' => '0000123',
+            'order_year' => 2026,
+            'customer_name' => 'ООО Заказчик',
+            'object_address' => 'г. Москва, ул. Строителей, 1',
+            'invoice_number' => '0000456',
+            'invoice_date' => '2026-08-30',
+            'contract_number' => '0000789',
+            'contract_date' => '2026-08-31',
+            'payment_date' => '2026-09-01',
+            'supply_working_days' => 10,
+            'contract_shipment_date' => '2026-09-15',
+            'application_shipment_date' => '2026-09-20',
+            'status' => ProductionOrderStatus::InStock,
+            'execution_type' => ProductionOrderExecutionType::DeliveryAndInstallation,
+            'manager_id' => $manager->id,
+            'note' => 'Примечание к заказу',
+        ]);
+        ProductionOrderItem::factory()->count(2)->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $playground->id,
+        ]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $swing->id,
+        ]);
+        $excluded = ProductionOrder::factory()->create(['manager_id' => $manager->id]);
+
+        $file = app(ExportProductionOrdersService::class)->handle([$order->id], $user->id);
+
+        Storage::disk('local')->assertExists($file->path);
+        $this->assertTrue($file->is_generated);
+        $this->assertSame($user->id, $file->user_id);
+        $this->assertStringContainsString('/schedules/orders/files/', $file->link);
+
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
+        $this->assertNotNull($sheet);
+        $this->assertSame(
+            ExportProductionOrdersService::HEADERS,
+            $sheet->rangeToArray('A1:R1')[0],
+        );
+        $this->assertSame($order->id, $sheet->getCell('A2')->getValue());
+        $this->assertSame('0000123', $sheet->getCell('B2')->getValue());
+        $this->assertSame('0000456', $sheet->getCell('F2')->getValue());
+        $this->assertSame('0000789', $sheet->getCell('H2')->getValue());
+        $this->assertSame('01.09.2026', $sheet->getCell('J2')->getValue());
+        $this->assertSame(ProductionOrderStatus::InStock->label(), $sheet->getCell('N2')->getValue());
+        $this->assertSame(
+            ProductionOrderExecutionType::DeliveryAndInstallation->label(),
+            $sheet->getCell('O2')->getValue(),
+        );
+        $this->assertSame('Менеджер экспорта', $sheet->getCell('P2')->getValue());
+        $this->assertStringContainsString('0007 — Игровой комплекс — 2 шт.', $sheet->getCell('Q2')->getValue());
+        $this->assertStringContainsString('0011 — Качели — 1 шт.', $sheet->getCell('Q2')->getValue());
+        $this->assertSame('Примечание к заказу', $sheet->getCell('R2')->getValue());
+        $this->assertNull($sheet->getCell('A3')->getValue());
+        $this->assertNotSame($excluded->id, $sheet->getCell('A2')->getValue());
+
+        $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
+        $this->assertNotNull($itemsSheet);
+        $this->assertSame(
+            ExportProductionOrdersService::ITEM_HEADERS,
+            $itemsSheet->rangeToArray('A1:H1')[0],
+        );
+        $this->assertSame(2, $itemsSheet->getCell('A2')->getValue());
+        $this->assertSame($playground->id, $itemsSheet->getCell('C2')->getValue());
+        $this->assertSame('0007', $itemsSheet->getCell('D2')->getValue());
+        $this->assertSame('Игровой комплекс', $itemsSheet->getCell('E2')->getValue());
+        $this->assertSame(2, $itemsSheet->getCell('A3')->getValue());
+        $this->assertSame(2, $itemsSheet->getCell('A4')->getValue());
+    }
+
+    public function test_export_can_create_an_empty_workbook(): void
+    {
+        Storage::fake('local');
+        $user = User::factory()->admin()->create();
+
+        $file = app(ExportProductionOrdersService::class)->handle([], $user->id);
+
+        Storage::disk('local')->assertExists($file->path);
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
+        $this->assertNotNull($sheet);
+        $this->assertSame(ExportProductionOrdersService::HEADERS, $sheet->rangeToArray('A1:R1')[0]);
+        $this->assertNull($sheet->getCell('A2')->getValue());
+        $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
+        $this->assertNotNull($itemsSheet);
+        $this->assertSame(ExportProductionOrdersService::ITEM_HEADERS, $itemsSheet->rangeToArray('A1:H1')[0]);
+    }
+}

+ 211 - 0
tests/Unit/Services/Import/ImportProductionOrdersServiceTest.php

@@ -0,0 +1,211 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Import;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use App\Services\Export\ExportProductionOrdersService;
+use App\Services\Import\ImportProductionOrdersService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use RuntimeException;
+use Tests\TestCase;
+
+final class ImportProductionOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_empty_order_id_creates_order_and_imports_items_from_separate_sheet(): void
+    {
+        Storage::fake('upload');
+        $actor = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create(['name' => 'Менеджер Импорта']);
+        $catalogItem = CommonCatalogItem::factory()->create([
+            'article' => 'IMP-001',
+            'calculator_name' => 'Импортируемый комплекс',
+        ]);
+        $import = $this->createImport($actor, [
+            $this->orderRow(null, 'НОВЫЙ-001', $manager->name),
+        ], [[
+            2,
+            null,
+            $catalogItem->id,
+            $catalogItem->article,
+            $catalogItem->calculator_name,
+            'МАФ-НОВЫЙ-1',
+            'ЗН-001',
+            '03.09.2026',
+        ]]);
+
+        $this->service($import, $actor)->handle();
+
+        $order = ProductionOrder::query()->where('order_number', 'НОВЫЙ-001')->firstOrFail();
+        $this->assertSame(ProductionOrderSource::Spreadsheet, $order->source);
+        $this->assertSame('ООО Новый заказчик', $order->customer_name);
+        $this->assertSame($manager->id, $order->manager_id);
+        $this->assertSame('2026-09-09', $order->contract_shipment_date->toDateString());
+        $this->assertDatabaseHas('production_order_items', [
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $catalogItem->id,
+            'order_item_number' => 'МАФ-НОВЫЙ-1',
+            'factory_number' => 'ЗН-001',
+            'manufacture_date' => '2026-09-03',
+        ]);
+        $this->assertSame('DONE', $import->refresh()->status);
+    }
+
+    public function test_known_order_id_updates_order_and_synchronizes_items(): void
+    {
+        Storage::fake('upload');
+        $actor = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create(['name' => 'Менеджер Обновления']);
+        $catalogItem = CommonCatalogItem::factory()->create([
+            'article' => 'IMP-UPDATE',
+            'calculator_name' => 'Обновляемый комплекс',
+        ]);
+        $order = ProductionOrder::factory()->create([
+            'order_number' => 'ОБНОВИТЬ-001',
+            'order_year' => 2026,
+            'customer_name' => 'Старый заказчик',
+            'manager_id' => $manager->id,
+            'source' => ProductionOrderSource::Manual,
+        ]);
+        $item = ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $catalogItem->id,
+            'order_item_number' => 'МАФ-СТАРЫЙ',
+        ]);
+        $row = $this->orderRow($order->id, $order->order_number, $manager->name);
+        $row[3] = 'ООО Обновлённый заказчик';
+        $row[13] = ProductionOrderStatus::InStock->label();
+        $import = $this->createImport($actor, [$row], [[
+            2,
+            $item->id,
+            $catalogItem->id,
+            $catalogItem->article,
+            $catalogItem->calculator_name,
+            'МАФ-ОБНОВЛЁННЫЙ',
+            'ЗН-UPDATED',
+            '04.09.2026',
+        ]]);
+
+        $this->service($import, $actor)->handle();
+
+        $order->refresh();
+        $item->refresh();
+        $this->assertSame('ООО Обновлённый заказчик', $order->customer_name);
+        $this->assertSame(ProductionOrderStatus::InStock, $order->status);
+        $this->assertSame(ProductionOrderSource::Manual, $order->source);
+        $this->assertSame('МАФ-ОБНОВЛЁННЫЙ', $item->order_item_number);
+        $this->assertSame('ЗН-UPDATED', $item->factory_number);
+        $this->assertSame('2026-09-04', $item->manufacture_date?->toDateString());
+        $this->assertSame('DONE', $import->refresh()->status);
+    }
+
+    public function test_unknown_order_id_marks_import_as_error_without_partial_changes(): void
+    {
+        Storage::fake('upload');
+        $actor = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create(['name' => 'Менеджер Ошибки']);
+        $order = ProductionOrder::factory()->create([
+            'order_number' => 'БЕЗ-ИЗМЕНЕНИЙ',
+            'order_year' => 2026,
+            'customer_name' => 'Исходный заказчик',
+            'manager_id' => $manager->id,
+        ]);
+        $validRow = $this->orderRow($order->id, $order->order_number, $manager->name);
+        $validRow[3] = 'Не должно сохраниться';
+        $import = $this->createImport($actor, [
+            $validRow,
+            $this->orderRow(999999, 'НЕИЗВЕСТНЫЙ-ID', $manager->name),
+        ], []);
+
+        try {
+            $this->service($import, $actor)->handle();
+            $this->fail('Импорт с неизвестным ID должен завершиться ошибкой.');
+        } catch (RuntimeException $exception) {
+            $this->assertStringContainsString('заказ с ID 999999 не найден', $exception->getMessage());
+        }
+
+        $this->assertSame('Исходный заказчик', $order->refresh()->customer_name);
+        $this->assertSame('ERROR', $import->refresh()->status);
+        $this->assertStringContainsString('ID 999999', (string) $import->result);
+    }
+
+    /**
+     * @param  list<list<mixed>>  $orderRows
+     * @param  list<list<mixed>>  $itemRows
+     */
+    private function createImport(User $actor, array $orderRows, array $itemRows): Import
+    {
+        $path = 'production_orders/test-'.uniqid().'.xlsx';
+        Storage::disk('upload')->makeDirectory(dirname($path));
+
+        $spreadsheet = new Spreadsheet;
+        $ordersSheet = $spreadsheet->getActiveSheet();
+        $ordersSheet->setTitle(ExportProductionOrdersService::ORDERS_SHEET);
+        $ordersSheet->fromArray(ExportProductionOrdersService::HEADERS, null, 'A1');
+        if ($orderRows !== []) {
+            $ordersSheet->fromArray($orderRows, null, 'A2');
+        }
+        $itemsSheet = $spreadsheet->createSheet();
+        $itemsSheet->setTitle(ExportProductionOrdersService::ITEMS_SHEET);
+        $itemsSheet->fromArray(ExportProductionOrdersService::ITEM_HEADERS, null, 'A1');
+        if ($itemRows !== []) {
+            $itemsSheet->fromArray($itemRows, null, 'A2');
+        }
+        (new Xlsx($spreadsheet))->save(Storage::disk('upload')->path($path));
+        $spreadsheet->disconnectWorksheets();
+
+        return Import::factory()->create([
+            'type' => 'production_orders',
+            'year' => null,
+            'filename' => $path,
+            'status' => 'new',
+            'user_id' => $actor->id,
+        ]);
+    }
+
+    /** @return list<mixed> */
+    private function orderRow(?int $id, string $number, string $managerName): array
+    {
+        return [
+            $id,
+            $number,
+            2026,
+            'ООО Новый заказчик',
+            'г. Москва, ул. Тестовая, 1',
+            'СЧ-001',
+            '01.09.2026',
+            'Д-001',
+            '31.08.2026',
+            '02.09.2026',
+            5,
+            null,
+            '15.09.2026',
+            ProductionOrderStatus::Placed->label(),
+            ProductionOrderExecutionType::Delivery->label(),
+            $managerName,
+            'Сводка оборудования не импортируется',
+            'Примечание импорта',
+        ];
+    }
+
+    private function service(Import $import, User $actor): ImportProductionOrdersService
+    {
+        return app(ImportProductionOrdersService::class, [
+            'import' => $import,
+            'userId' => $actor->id,
+        ]);
+    }
+}

+ 56 - 0
tests/Unit/Services/ProductionCalendarServiceTest.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services;
+
+use App\Models\ProductionCalendarDay;
+use App\Services\ProductionCalendarService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use InvalidArgumentException;
+use Tests\TestCase;
+
+class ProductionCalendarServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_it_skips_weekends_by_default(): void
+    {
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-08', $result->toDateString());
+    }
+
+    public function test_calendar_override_can_mark_a_weekday_as_non_working(): void
+    {
+        ProductionCalendarDay::factory()->create([
+            'date' => '2024-01-08',
+            'is_working_day' => false,
+            'name' => 'Праздник',
+        ]);
+
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-09', $result->toDateString());
+    }
+
+    public function test_calendar_override_can_mark_a_weekend_as_working(): void
+    {
+        ProductionCalendarDay::factory()->create([
+            'date' => '2024-01-06',
+            'is_working_day' => true,
+            'name' => 'Рабочая суббота',
+        ]);
+
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-06', $result->toDateString());
+    }
+
+    public function test_negative_working_days_are_rejected(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+
+        app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', -1);
+    }
+}

+ 0 - 3
vite.config.js

@@ -7,9 +7,6 @@ export default defineConfig({
         port: 5172,
         hmr: {
             host: '127.0.0.1'
-        },
-        watch: {
-            usePolling: true,
         }
     },
     plugins: [