Procházet zdrojové kódy

add production order notifications and documents

Alexander Musikhin před 2 týdny
rodič
revize
c120680919
26 změnil soubory, kde provedl 1258 přidání a 14 odebrání
  1. 4 1
      app/Console/Commands/CleanupGeneratedDocuments.php
  2. 33 0
      app/Enums/ProductionOrderNotificationEvent.php
  3. 18 1
      app/Http/Controllers/ProductionOrderController.php
  4. 10 1
      app/Http/Controllers/ProductionOrderDeliveryController.php
  5. 126 0
      app/Http/Controllers/ProductionOrderDocumentController.php
  6. 10 1
      app/Http/Controllers/ProductionOrderInstallationController.php
  7. 10 0
      app/Http/Controllers/UserController.php
  8. 64 0
      app/Jobs/GenerateProductionOrderFileJob.php
  9. 6 0
      app/Models/UserNotification.php
  10. 3 0
      app/Models/UserNotificationSetting.php
  11. 103 0
      app/Services/NotificationService.php
  12. 353 0
      app/Services/ProductionOrderDocumentService.php
  13. 10 2
      app/Services/ProductionOrderPlanningService.php
  14. 1 1
      app/Services/ProductionOrderService.php
  15. 5 0
      config/access_routes.php
  16. 24 0
      database/migrations/2026_09_03_000005_add_production_order_notification_settings.php
  17. 4 4
      docs/refactor/plan.md
  18. 38 0
      resources/views/production_orders/edit.blade.php
  19. 7 0
      resources/views/production_orders/partials/item-row.blade.php
  20. 16 0
      resources/views/production_orders/partials/planning.blade.php
  21. 1 0
      resources/views/users/edit.blade.php
  22. 11 0
      routes/web.php
  23. 25 0
      tests/Feature/CleanupGeneratedDocumentsCommandTest.php
  24. 229 0
      tests/Feature/ProductionOrderDocumentControllerTest.php
  25. 144 0
      tests/Feature/ProductionOrderNotificationTest.php
  26. 3 3
      tests/Feature/ProductionOrderPlanningControllerTest.php

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

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

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

@@ -5,6 +5,7 @@ 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\Models\CommonCatalogItem;
@@ -12,6 +13,7 @@ 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\Http\JsonResponse;
@@ -157,6 +159,7 @@ class ProductionOrderController extends Controller
         SaveProductionOrderRequest $request,
         FieldAccessService $fieldAccess,
         ProductionOrderService $service,
+        NotificationService $notificationService,
     ): RedirectResponse {
         $validated = $request->validated();
         $items = $validated['items'];
@@ -169,6 +172,11 @@ class ProductionOrderController extends Controller
         $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],
@@ -181,6 +189,7 @@ class ProductionOrderController extends Controller
         ProductionOrder $productionOrder,
         FieldAccessService $fieldAccess,
         ProductionOrderService $service,
+        NotificationService $notificationService,
     ): RedirectResponse {
         $validated = $request->validated();
         $fields = array_keys(config('access.schedule-orders.fields', []));
@@ -198,13 +207,21 @@ class ProductionOrderController extends Controller
             $payload['items'] = $validated['items'];
         }
 
-        $service->update(
+        $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],

+ 10 - 1
app/Http/Controllers/ProductionOrderDeliveryController.php

@@ -4,10 +4,12 @@ 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;
@@ -56,8 +58,15 @@ class ProductionOrderDeliveryController extends Controller
         SaveProductionOrderDeliveryRequest $request,
         ProductionOrder $productionOrder,
         ProductionOrderPlanningService $service,
+        NotificationService $notificationService,
     ): RedirectResponse {
-        $service->createDelivery($productionOrder, $request->validated(), $request->user());
+        $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', 'Доставка добавлена.');

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

+ 10 - 1
app/Http/Controllers/ProductionOrderInstallationController.php

@@ -4,10 +4,12 @@ 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;
 
@@ -17,8 +19,15 @@ class ProductionOrderInstallationController extends Controller
         SaveProductionOrderInstallationRequest $request,
         ProductionOrder $productionOrder,
         ProductionOrderPlanningService $service,
+        NotificationService $notificationService,
     ): RedirectResponse {
-        $service->createInstallation($productionOrder, $request->validated(), $request->user());
+        $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', 'Монтаж добавлен.');

+ 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]);

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

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

+ 103 - 0
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([
@@ -499,6 +589,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()

+ 353 - 0
app/Services/ProductionOrderDocumentService.php

@@ -0,0 +1,353 @@
+<?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) {
+            foreach ($catalogItem->documents as $document) {
+                if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
+                    continue;
+                }
+                $archiveName = 'Техдокументация/'.$this->safeFilename((string) $catalogItem->article).'/'.$this->safeFilename($document->original_name);
+                $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
+                $added++;
+            }
+        }
+
+        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;
+    }
+}

+ 10 - 2
app/Services/ProductionOrderPlanningService.php

@@ -27,7 +27,11 @@ class ProductionOrderPlanningService
                 'type' => ProductionOrderActivityType::DeliveryScheduled,
                 'user_id' => $actor->id,
                 'delivery_id' => $delivery->id,
-                'message' => 'Заказ добавлен в график доставок.',
+                'message' => sprintf(
+                    'Назначена доставка на %s, водитель %s.',
+                    $delivery->delivery_date->format('d.m.Y'),
+                    $delivery->driver()->value('name') ?? '—',
+                ),
             ]);
 
             return $delivery;
@@ -59,7 +63,11 @@ class ProductionOrderPlanningService
                 'type' => ProductionOrderActivityType::InstallationScheduled,
                 'user_id' => $actor->id,
                 'installation_id' => $installation->id,
-                'message' => 'Заказ добавлен в график монтажей.',
+                'message' => sprintf(
+                    'Назначен монтаж на %s, бригадир %s.',
+                    $installation->installation_date->format('d.m.Y'),
+                    $installation->brigadier()->value('name') ?? '—',
+                ),
             ]);
             $this->syncInstallationSchedules($installation);
 

+ 1 - 1
app/Services/ProductionOrderService.php

@@ -67,7 +67,7 @@ class ProductionOrderService
                 $order->activities()->create([
                     'type' => ProductionOrderActivityType::StatusChanged,
                     'user_id' => $actor->id,
-                    'message' => 'Изменён статус заказа.',
+                    'message' => 'Статус изменён на «'.$order->statusLabel().'».',
                 ]);
             }
 

+ 5 - 0
config/access_routes.php

@@ -114,6 +114,11 @@ return [
             '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',

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

+ 4 - 4
docs/refactor/plan.md

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

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

@@ -152,6 +152,7 @@
                         <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>
@@ -180,6 +181,27 @@
         </form>
 
         @if($order)
+            <div class="d-flex flex-wrap gap-2 mt-3">
+                @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
+            </div>
+
             @foreach($order->items as $orderItem)
                 @if($canManagePassports)
                     <form id="production-order-passport-upload-{{ $orderItem->id }}"
@@ -387,4 +409,20 @@
             </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;
+                        });
+                    });
+                });
+            </script>
+        @endpush
+    @endif
 @endsection

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

@@ -1,5 +1,12 @@
 @php($orderItem = !empty($itemRow['id']) && $order ? $order->items->firstWhere('id', (int) $itemRow['id']) : null)
 <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: 320px">
         @if(!empty($itemRow['id']))
             <input type="hidden" name="items[{{ $index }}][id]" value="{{ $itemRow['id'] }}">

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

@@ -13,6 +13,14 @@
                             — {{ $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
@@ -38,6 +46,14 @@
                             {{ $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

+ 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'] ?? []],
                             ],
                         ])

+ 11 - 0
routes/web.php

@@ -22,6 +22,7 @@ use App\Http\Controllers\ProductSKUController;
 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\ReclamationController;
@@ -217,6 +218,16 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             ->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::get('orders/files/{file}', [ProductionOrderDocumentController::class, 'download'])
+            ->name('orders.files.download');
         Route::get('deliveries', [ProductionOrderDeliveryController::class, 'index'])->name('deliveries');
     });
 

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

+ 229 - 0
tests/Feature/ProductionOrderDocumentControllerTest.php

@@ -0,0 +1,229 @@
+<?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');
+        $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
+    {
+        $document = File::factory()->pdf()->create([
+            'user_id' => $this->admin->id,
+            'path' => 'catalog/ART-100/manual.pdf',
+            'original_name' => 'manual.pdf',
+        ]);
+        Storage::disk('public')->put($document->path, 'pdf-content');
+        $this->item->catalogItem->documents()->attach($document->id);
+        $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/manual.pdf', $installationEntries);
+        $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;
+    }
+}

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

+ 3 - 3
tests/Feature/ProductionOrderPlanningControllerTest.php

@@ -75,13 +75,13 @@ class ProductionOrderPlanningControllerTest extends TestCase
             'production_order_id' => $this->order->id,
             'type' => ProductionOrderActivityType::DeliveryScheduled->value,
             'user_id' => $this->admin->id,
-            'message' => 'Заказ добавлен в график доставок.',
+            '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' => 'Заказ добавлен в график монтажей.',
+            'message' => 'Назначен монтаж на 20.09.2026, бригадир '.$this->brigadier->name.'.',
         ]);
         $this->assertSame(3, $this->order->activities()->count());
     }
@@ -226,7 +226,7 @@ class ProductionOrderPlanningControllerTest extends TestCase
         $this->assertDatabaseHas('production_order_activities', [
             'production_order_id' => $this->order->id,
             'type' => ProductionOrderActivityType::StatusChanged->value,
-            'message' => 'Изменён статус заказа.',
+            'message' => 'Статус изменён на «В пути».',
         ]);
         $this->assertSame(1, $this->order->activities()->count());
     }