Quellcode durchsuchen

implement production order planning

Alexander Musikhin vor 2 Wochen
Ursprung
Commit
2bf8c56fe2

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

@@ -136,7 +136,13 @@ class ProductionOrderController extends Controller
         return $this->orderView(
             $request,
             $fieldAccess,
-            $productionOrder->load(['manager', 'items.catalogItem.imageFile']),
+            $productionOrder->load([
+                'manager',
+                'items.catalogItem.imageFile',
+                'deliveries.driver',
+                'installations.brigadier',
+                'activities.user',
+            ]),
         );
     }
 
@@ -270,12 +276,22 @@ class ProductionOrderController extends Controller
                 $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'),
             '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()
                 ->whereKey($catalogItemIds)
                 ->get(['id', 'article', 'calculator_name', 'print_name'])

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

@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\SaveProductionOrderDeliveryRequest;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Services\ProductionOrderPlanningService;
+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,
+    ): RedirectResponse {
+        $service->createDelivery($productionOrder, $request->validated(), $request->user());
+
+        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),
+        ));
+    }
+}

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

@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\SaveProductionOrderInstallationRequest;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderInstallation;
+use App\Services\ProductionOrderPlanningService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class ProductionOrderInstallationController extends Controller
+{
+    public function store(
+        SaveProductionOrderInstallationRequest $request,
+        ProductionOrder $productionOrder,
+        ProductionOrderPlanningService $service,
+    ): RedirectResponse {
+        $service->createInstallation($productionOrder, $request->validated(), $request->user());
+
+        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),
+        ));
+    }
+}

+ 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) {

+ 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' => 'бригадир',
+        ];
+    }
+}

+ 6 - 0
app/Models/ProductionOrderInstallation.php

@@ -8,6 +8,7 @@ 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
@@ -48,4 +49,9 @@ class ProductionOrderInstallation extends Model
     {
         return $this->belongsTo(User::class, 'brigadier_id');
     }
+
+    public function schedules(): HasMany
+    {
+        return $this->hasMany(Schedule::class);
+    }
 }

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

+ 119 - 0
app/Services/ProductionOrderPlanningService.php

@@ -0,0 +1,119 @@
+<?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' => 'Заказ добавлен в график доставок.',
+            ]);
+
+            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' => 'Заказ добавлен в график монтажей.',
+            ]);
+            $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,
+            ]);
+        }
+    }
+}

+ 9 - 0
app/Services/ProductionOrderService.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Services;
 
+use App\Enums\ProductionOrderActivityType;
 use App\Enums\ProductionOrderSource;
 use App\Models\ProductionOrder;
 use App\Models\User;
@@ -62,6 +63,14 @@ class ProductionOrderService
             $data['updated_by'] = $actor->id;
             $order->update($data);
 
+            if ($order->wasChanged('status')) {
+                $order->activities()->create([
+                    'type' => ProductionOrderActivityType::StatusChanged,
+                    'user_id' => $actor->id,
+                    'message' => 'Изменён статус заказа.',
+                ]);
+            }
+
             if ($syncItems && is_array($items)) {
                 $this->syncItems($order, $items, $actor);
             } elseif (is_array($items) && $editableItemFields !== []) {

+ 7 - 1
config/access_routes.php

@@ -13,7 +13,7 @@ return [
         'pricing_codes.get_description' => 'pricing_codes.search',
         'pricing_codes.search' => 'pricing_codes.search',
         'schedule.orders' => 'schedule-orders.view',
-        'schedule.deliveries' => true,
+        'schedule.deliveries' => 'schedule-orders.view',
         'user.profile' => 'profile.view',
         'profile.store' => 'profile.update',
         'profile.delete' => 'profile.delete',
@@ -100,6 +100,12 @@ return [
             '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',
         ],
         'contract.' => [
             'index' => 'contracts.view',

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

+ 7 - 7
docs/refactor/plan.md

@@ -85,7 +85,7 @@
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
 | 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
-| 9. Графики заказов и доставок | В работе | Реализованы нормализованное ядро, права и первый ручной контур заказов с отдельными МАФ; доставки/монтажи и обмен с 1С остаются следующими подэтапами. |
+| 9. Графики заказов и доставок | В работе | Реализованы нормализованное ядро, ручной контур заказов, несколько доставок/монтажей, недельный график доставок и связь с графиком монтажей; следующим идёт файловый контур. |
 | 10. Рекламации из графика заказов | ТЗ получено, реализация после основы графика | Подтверждены выбор отдельных МАФ и тип `Прочее`, но новая связь рекламации зависит от моделей заказа и его экземпляров. |
 
 ## Этап 1. Реорганизация меню и заглушки
@@ -271,11 +271,11 @@
 6. **9.6. Обмен данными** — экспорт списка, XLS-импорт после уточнения формата, API 1С с журналом загрузок и отдельная миграция старых данных.
 7. **9.7. Рекламации и приёмка** — связь с этапом 10, полные автотесты и пользовательская проверка.
 
-Текущий подэтап: **9.3. Доставки и монтажи**.
+Текущий подэтап: **9.4. Файлы и коммуникации**.
 
 - [x] **9.1. Основа данных и права:** добавлены нормализованные таблицы и модели, enum статусов/типов, производственный календарь, роль `driver`, действия и permissions отдельных полей.
 - [x] **9.2. Ручной заказ:** реализованы список с фильтрами, цветовой статус, карточка, создание/редактирование, расчёт договорной даты отгрузки и отдельные экземпляры МАФ из общего каталога.
-- [ ] **9.3. Доставки и монтажи:** реализовать несколько доставок/монтажей на заказ и недельный график доставок.
+- [x] **9.3. Доставки и монтажи:** реализованы несколько доставок/монтажей на заказ, выбор водителя/бригадира, недельный график доставок и синхронизация с действующим графиком монтажей.
 - [ ] **9.4. Файлы и коммуникации:** реализовать документы, фотографии, чат и паспорт отдельного МАФ.
 - [ ] **9.5. События и документы:** реализовать аудит, уведомления и согласованные экспорты/пакеты.
 - [ ] **9.6. Обмен данными:** реализовать согласованную часть XLS/1С и отдельный перенос истории.
@@ -307,10 +307,10 @@
 - [ ] Реализовать пакеты документов для монтажа, доставки и технической документации.
 - [ ] Добавить создание рекламации из заказа через текущую форму.
 - [x] Получить базовые требования и шаблон заявки для `Графика доставок`.
-- [ ] Реализовать `График доставок` в виде недельного списка.
-- [ ] Добавить ручное планирование занятости водителей.
-- [ ] Связать доставки с адресами из графика заказов.
-- [ ] Проверить связи монтажей с ДКР-площадками и графиком заказов.
+- [x] Реализовать `График доставок` в виде недельного списка.
+- [x] Добавить ручное планирование занятости водителей.
+- [x] Связать доставки с адресами из графика заказов.
+- [x] Проверить связи монтажей с ДКР-площадками и графиком заказов.
 
 ## Этап 10. Рекламации из графика заказов
 

+ 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

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

@@ -177,6 +177,10 @@
             </div>
         </form>
 
+        @if($order)
+            @include('production_orders.partials.planning')
+        @endif
+
         @if($canManageItems)
             <template id="production-order-item-template">
                 @include('production_orders.partials.item-row', [

+ 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

+ 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

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

@@ -0,0 +1,68 @@
+<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
+                @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
+                @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>

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

+ 15 - 4
routes/web.php

@@ -20,6 +20,8 @@ use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
 use App\Http\Controllers\ProductSKUController;
 use App\Http\Controllers\ProductionOrderController;
+use App\Http\Controllers\ProductionOrderDeliveryController;
+use App\Http\Controllers\ProductionOrderInstallationController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
 use App\Http\Controllers\ResponsibleController;
@@ -185,10 +187,19 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         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::get('deliveries', UnderDevelopmentController::class)
-            ->defaults('title', 'График доставок')
-            ->defaults('active', 'schedule_deliveries')
-            ->name('deliveries');
+        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::get('deliveries', [ProductionOrderDeliveryController::class, 'index'])->name('deliveries');
     });
 
     Route::prefix('stock')->name('stock.')->group(function () {

+ 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' => 'Заказ добавлен в график доставок.',
+        ]);
+        $this->assertDatabaseHas('production_order_activities', [
+            'production_order_id' => $this->order->id,
+            'type' => ProductionOrderActivityType::InstallationScheduled->value,
+            'user_id' => $this->admin->id,
+            'message' => 'Заказ добавлен в график монтажей.',
+        ]);
+        $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);
+    }
+}