Kaynağa Gözat

fix production order schedule requirements

Alexander Musikhin 2 hafta önce
ebeveyn
işleme
89ac489c25

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

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

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

@@ -77,6 +77,8 @@ class FilterController extends Controller
         'schedule_orders' => [
             'status_name' => 'status',
             'execution_type_name' => 'execution_type',
+            'delivery_dates' => 'delivery_date',
+            'installation_dates' => 'installation_date',
         ],
     ];
 
@@ -169,6 +171,9 @@ class FilterController extends Controller
         if ($table === 'stock_orders') {
             return $this->stockOrderFilters($column);
         }
+        if ($table === 'schedule_orders' && in_array($column, ['delivery_dates', 'installation_dates'], true)) {
+            return $this->productionOrderPlanningFilters($column);
+        }
 
         $gp = session(self::SESSION_KEYS[$table] ?? 'gp_'.$table);
 
@@ -322,6 +327,21 @@ class FilterController extends Controller
         return $this->filterValuesResponse($values);
     }
 
+    private function productionOrderPlanningFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'delivery_dates' => DB::table('production_order_deliveries')
+                ->whereNull('deleted_at')
+                ->pluck('delivery_date'),
+            'installation_dates' => DB::table('production_order_installations')
+                ->whereNull('deleted_at')
+                ->pluck('installation_date'),
+            default => [],
+        };
+
+        return $this->filterValuesResponse($values);
+    }
+
     private function filterValuesResponse(iterable $values): JsonResponse
     {
         $normalized = collect($values)

+ 97 - 29
app/Http/Controllers/ProductionOrderController.php

@@ -17,6 +17,7 @@ use App\Services\Access\FieldAccessService;
 use App\Services\NotificationService;
 use App\Services\ProductionOrderService;
 use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
@@ -24,44 +25,54 @@ use Illuminate\Support\Arr;
 
 class ProductionOrderController extends Controller
 {
+    private const INDEX_HEADER = [
+        'id' => 'ID',
+        'order_number' => 'Номер заказа',
+        'customer_name' => 'Заказчик',
+        'object_address' => 'Адрес объекта',
+        'invoice_number' => '№ счёта',
+        'payment_date' => 'Дата оплаты',
+        'supply_working_days' => 'Срок поставки',
+        'contract_shipment_date' => 'Дата отгрузки по договору',
+        'application_shipment_date' => 'Дата отгрузки по заявке',
+        'status_name' => 'Статус',
+        'delivery_dates' => 'Дата доставки',
+        'installation_dates' => 'Дата монтажа',
+        'manager_id' => 'Менеджер',
+        'note' => 'Примечание',
+    ];
+
+    private const SEARCH_FIELDS = [
+        'order_number',
+        'customer_name',
+        'object_address',
+        'invoice_number',
+        'contract_number',
+        'note',
+    ];
+
     private const FIELD_MAP = [
         'status_name' => 'status',
         'execution_type_name' => 'execution_type',
         'items_count' => 'id',
+        'delivery_dates' => 'delivery_date',
+        'installation_dates' => 'installation_date',
     ];
 
     protected array $data = [
         'active' => 'schedule_orders',
         'title' => 'График заказов',
         'id' => 'schedule_orders',
-        'header' => [
-            'id' => 'ID',
-            'order_number' => 'Номер заказа',
-            'customer_name' => 'Заказчик',
-            'object_address' => 'Адрес объекта',
-            'invoice_number' => '№ счёта',
-            'payment_date' => 'Дата оплаты',
-            'supply_working_days' => 'Срок поставки',
-            'contract_shipment_date' => 'Дата отгрузки по договору',
-            'application_shipment_date' => 'Дата отгрузки по заявке',
-            'status_name' => 'Статус',
-            'execution_type_name' => 'Тип',
-            'manager_id' => 'Менеджер',
-            'items_count' => 'МАФ',
-            'note' => 'Примечание',
-        ],
-        'searchFields' => [
-            'order_number',
-            'customer_name',
-            'object_address',
-            'invoice_number',
-            'contract_number',
-            'note',
-        ],
+        'header' => self::INDEX_HEADER,
+        'searchFields' => self::SEARCH_FIELDS,
     ];
 
     public function index(Request $request): View
     {
+        $this->data['header'] = self::INDEX_HEADER;
+        $this->data['searchFields'] = self::SEARCH_FIELDS;
+        unset($this->data['dates'], $this->data['ranges'], $this->data['filters']);
+
         session(['gp_schedule_orders' => $request->query()]);
         $nav = $this->startNavigationContext($request);
         $model = new ProductionOrder;
@@ -98,8 +109,16 @@ class ProductionOrderController extends Controller
             }
         }
 
-        $query = ProductionOrder::query()->with('manager')->withCount('items');
-        $this->acceptFilters($query, $request);
+        $query = ProductionOrder::query()
+            ->with([
+                'manager',
+                'deliveries:id,production_order_id,delivery_date',
+                'installations:id,production_order_id,installation_date',
+            ])
+            ->withCount('items')
+            ->withMin('deliveries', 'delivery_date')
+            ->withMin('installations', 'installation_date');
+        $this->acceptOrderFilters($query, $request);
         $this->acceptSearch($query, $request);
 
         $requestedSort = $request->string('sortBy')->toString();
@@ -107,6 +126,8 @@ class ProductionOrderController extends Controller
             'status_name' => 'status',
             'execution_type_name' => 'execution_type',
             'items_count' => 'items_count',
+            'delivery_dates' => 'deliveries_min_delivery_date',
+            'installation_dates' => 'installations_min_installation_date',
         ];
         if (isset($sortMap[$requestedSort])) {
             $request->merge(['sortBy' => $sortMap[$requestedSort]]);
@@ -160,6 +181,8 @@ class ProductionOrderController extends Controller
                 'status_name',
                 'execution_type_name',
                 'manager_id',
+                'delivery_dates',
+                'installation_dates',
                 'note',
             ]),
             's' => $validated['s'] ?? null,
@@ -172,7 +195,7 @@ class ProductionOrderController extends Controller
         $this->data['ranges'] = ['supply_working_days' => true];
 
         $query = ProductionOrder::query();
-        $this->acceptFilters($query, $request);
+        $this->acceptOrderFilters($query, $request);
         $this->acceptSearch($query, $request);
         $orderIds = $query->orderByDesc('created_at')->orderByDesc('id')->pluck('id')->all();
 
@@ -302,6 +325,7 @@ class ProductionOrderController extends Controller
         }
 
         $items = CommonCatalogItem::query()
+            ->with('imageFile')
             ->where(function ($query) use ($search): void {
                 $query
                     ->where('article', 'like', "%{$search}%")
@@ -310,10 +334,12 @@ class ProductionOrderController extends Controller
             })
             ->orderBy('article')
             ->limit(50)
-            ->get(['id', 'article', 'calculator_name', 'print_name'])
+            ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
             ->map(static fn (CommonCatalogItem $item): array => [
                 'id' => $item->id,
                 'label' => $item->article.' — '.($item->calculator_name ?: $item->print_name ?: 'Без наименования'),
+                'image' => $item->imageFile?->thumbnail_link,
+                'image_full' => $item->imageFile?->link,
             ])
             ->values();
 
@@ -387,8 +413,9 @@ class ProductionOrderController extends Controller
                 ->orderBy('name')
                 ->pluck('name', 'id'),
             'catalogItems' => CommonCatalogItem::query()
+                ->with('imageFile')
                 ->whereKey($catalogItemIds)
-                ->get(['id', 'article', 'calculator_name', 'print_name'])
+                ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
                 ->keyBy('id'),
             'statuses' => ProductionOrderStatus::options(),
             'executionTypes' => ProductionOrderExecutionType::options(),
@@ -400,4 +427,45 @@ class ProductionOrderController extends Controller
             'chatResponsibleUserIds' => $responsibleUserIds,
         ]);
     }
+
+    private function acceptOrderFilters(Builder $query, Request $request): void
+    {
+        $filters = (array) $request->input('filters', []);
+        $relationFilters = [
+            'delivery_dates' => ['deliveries', 'delivery_date'],
+            'installation_dates' => ['installations', 'installation_date'],
+        ];
+
+        foreach ($relationFilters as $filterName => [$relation, $column]) {
+            $value = trim((string) ($filters[$filterName] ?? ''));
+            if ($value === '') {
+                continue;
+            }
+
+            $values = explode('||', $value);
+            $dates = array_values(array_filter(
+                $values,
+                static fn (string $date): bool => $date !== '-пусто-',
+            ));
+            $includeEmpty = in_array('-пусто-', $values, true);
+
+            $query->where(function ($relationQuery) use ($relation, $column, $dates, $includeEmpty): void {
+                if ($dates !== []) {
+                    $relationQuery->whereHas(
+                        $relation,
+                        static fn ($dateQuery) => $dateQuery->whereIn($column, $dates),
+                    );
+                }
+                if ($includeEmpty) {
+                    $dates !== []
+                        ? $relationQuery->orWhereDoesntHave($relation)
+                        : $relationQuery->whereDoesntHave($relation);
+                }
+            });
+        }
+
+        $request->merge(['filters' => Arr::except($filters, array_keys($relationFilters))]);
+        $this->acceptFilters($query, $request);
+        $request->merge(['filters' => $filters]);
+    }
 }

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

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

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

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

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

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

+ 29 - 0
app/Models/ProductionOrder.php

@@ -103,6 +103,11 @@ class ProductionOrder extends Model
         return $this->hasMany(ProductionOrderActivity::class)->latest('created_at')->latest('id');
     }
 
+    public function reclamations(): HasMany
+    {
+        return $this->hasMany(Reclamation::class);
+    }
+
     public function documents(): BelongsToMany
     {
         return $this->belongsToMany(File::class, 'production_order_document')->withTimestamps();
@@ -137,4 +142,28 @@ class ProductionOrder extends Model
     {
         return $this->executionTypeLabel();
     }
+
+    public function getDeliveryDatesAttribute(): string
+    {
+        return $this->deliveries
+            ->pluck('delivery_date')
+            ->filter()
+            ->map(static fn ($date): string => $date->format('d.m.Y'))
+            ->implode('<br>');
+    }
+
+    public function getInstallationDatesAttribute(): string
+    {
+        return $this->installations
+            ->pluck('installation_date')
+            ->filter()
+            ->map(static fn ($date): string => $date->format('d.m.Y'))
+            ->implode('<br>');
+    }
+
+    public function isShipmentOverdue(): bool
+    {
+        return $this->contract_shipment_date?->isPast()
+            && ! in_array($this->status, [ProductionOrderStatus::InStock, ProductionOrderStatus::Closed], true);
+    }
 }

+ 7 - 0
app/Models/ProductionOrderItem.php

@@ -8,6 +8,7 @@ use Database\Factories\ProductionOrderItemFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 class ProductionOrderItem extends Model
@@ -60,4 +61,10 @@ class ProductionOrderItem extends Model
     {
         return $this->belongsTo(User::class, 'created_by');
     }
+
+    public function reclamations(): BelongsToMany
+    {
+        return $this->belongsToMany(Reclamation::class, 'production_order_item_reclamation')
+            ->withTimestamps();
+    }
 }

+ 14 - 0
app/Models/Reclamation.php

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

+ 1 - 0
app/Models/ReclamationView.php

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

+ 22 - 9
app/Services/NotificationService.php

@@ -118,6 +118,7 @@ class NotificationService
             'order.user',
             'order.brigadier',
             'reclamation.order',
+            'reclamation.productionOrder',
             'reclamation.user',
             'reclamation.brigadier',
         ]);
@@ -201,23 +202,28 @@ class NotificationService
     public function notifyReclamationCreated(Reclamation $reclamation, ?User $author = null): void
     {
         $order = $reclamation->order;
-        if (!$order) {
+        $productionOrder = $reclamation->productionOrder;
+        if (!$order && !$productionOrder) {
             return;
         }
 
         $authorSuffix = $author ? sprintf(' Изменил %s.', $author->name) : '';
         $authorSuffixHtml = $author ? sprintf(' Изменил %s.', e($author->name)) : '';
 
+        $address = $order?->object_address ?? $productionOrder?->object_address ?? '—';
+        $sourceUrl = $order
+            ? route('order.show', ['order' => $order->id, 'sync_year' => 1])
+            : route('schedule.orders.show', ['productionOrder' => $productionOrder]);
         $message = sprintf(
             'Добавлена новая рекламация по адресу %s #%d.',
-            $order->object_address,
+            $address,
             $reclamation->id,
         ) . $authorSuffix;
 
         $messageHtml = sprintf(
             'Добавлена новая рекламация по адресу <a href="%s">%s</a> <a href="%s">#%d</a>.',
-            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
-            e($order->object_address),
+            $sourceUrl,
+            e($address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
         ) . $authorSuffixHtml;
@@ -235,7 +241,8 @@ class NotificationService
     public function notifyReclamationStatusChanged(Reclamation $reclamation, ?User $author = null): void
     {
         $order = $reclamation->order;
-        if (!$order) {
+        $productionOrder = $reclamation->productionOrder;
+        if (!$order && !$productionOrder) {
             return;
         }
 
@@ -243,17 +250,21 @@ class NotificationService
         $authorSuffix = $author ? sprintf(' Изменил %s.', $author->name) : '';
         $authorSuffixHtml = $author ? sprintf(' Изменил %s.', e($author->name)) : '';
 
+        $address = $order?->object_address ?? $productionOrder?->object_address ?? '—';
+        $sourceUrl = $order
+            ? route('order.show', ['order' => $order->id, 'sync_year' => 1])
+            : route('schedule.orders.show', ['productionOrder' => $productionOrder]);
         $message = sprintf(
             'Статус рекламации %s #%d изменен на %s.',
-            $order->object_address,
+            $address,
             $reclamation->id,
             $statusName,
         ) . $authorSuffix;
 
         $messageHtml = sprintf(
             'Статус рекламации по адресу <a href="%s">%s</a> <a href="%s">#%d</a> изменен на %s.',
-            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
-            e($order->object_address),
+            $sourceUrl,
+            e($address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
             e($statusName),
@@ -785,7 +796,9 @@ class NotificationService
         }
 
         $reclamation = $chatMessage->reclamation;
-        $address = $reclamation?->order?->object_address ?? '-';
+        $address = $reclamation?->order?->object_address
+            ?? $reclamation?->productionOrder?->object_address
+            ?? '-';
         $reclamationUrl = $reclamation
             ? route('reclamations.show', ['reclamation' => $reclamation->id, 'sync_year' => 1])
             : route('reclamations.index');

+ 2 - 0
config/access.php

@@ -100,6 +100,8 @@ return [
             'supply_working_days' => 'Срок поставки',
             'contract_shipment_date' => 'Дата отгрузки по договору',
             'application_shipment_date' => 'Дата отгрузки по заявке',
+            'delivery_date' => 'Дата доставки',
+            'installation_date' => 'Дата монтажа',
             'status' => 'Статус',
             'execution_type' => 'Тип исполнения',
             'manager_id' => 'Менеджер',

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

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

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

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

+ 12 - 2
database/seeders/RbacSeeder.php

@@ -151,7 +151,14 @@ class RbacSeeder extends Seeder
             fn (string $field): string => "common-catalog.fields.{$field}.view",
             $commonCatalogPublicFields,
         ));
-        $auth = array_merge($auth, $this->fieldPermissions('schedule-orders', ['view'], $permissions));
+        $scheduleOrderPublicFields = array_diff(
+            array_keys(config('access.schedule-orders.fields', [])),
+            ['application_shipment_date'],
+        );
+        $auth = array_merge($auth, array_map(
+            fn (string $field): string => "schedule-orders.fields.{$field}.view",
+            $scheduleOrderPublicFields,
+        ));
 
         $manager = array_merge($auth, [
             'orders.update',
@@ -234,7 +241,10 @@ class RbacSeeder extends Seeder
                 'schedule-orders.passports.manage',
                 'schedule-orders.chat.create',
             ],
-            $this->fieldPermissions('schedule-orders', ['view'], $permissions),
+            array_map(
+                fn (string $field): string => "schedule-orders.fields.{$field}.view",
+                $scheduleOrderPublicFields,
+            ),
             [
                 'schedule-orders.fields.object_address.update',
                 'schedule-orders.fields.note.update',

+ 13 - 13
docs/refactor/plan.md

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

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

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

+ 63 - 5
resources/views/production_orders/edit.blade.php

@@ -42,11 +42,13 @@
             @if($order) @method('PUT') @endif
             <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
 
-            <div class="card mb-3">
+            <div class="row g-3 mb-3 align-items-start">
+                <div class="col-xl-5">
+            <div class="card h-100">
                 <div class="card-header"><strong>Заказ</strong></div>
                 <div class="card-body">
                     <div class="row">
-                        <div class="col-xl-6">
+                        <div class="col-12">
                             @if($canView('order_number'))
                                 @include('partials.input', ['name' => 'order_number', 'title' => 'Номер заказа', 'required' => true, 'value' => $order?->order_number, 'disabled' => !$canUpdate('order_number')])
                             @endif
@@ -77,7 +79,7 @@
                                 @include('partials.select', ['name' => 'execution_type', 'title' => 'Тип исполнения', 'required' => true, 'first_empty' => true, 'options' => $executionTypes, 'value' => old('execution_type', $order?->execution_type?->value), 'disabled' => !$canUpdate('execution_type')])
                             @endif
                         </div>
-                        <div class="col-xl-6">
+                        <div class="col-12">
                             @if($canView('invoice_number'))
                                 @include('partials.input', ['name' => 'invoice_number', 'title' => '№ счёта', 'required' => true, 'value' => $order?->invoice_number, 'disabled' => !$canUpdate('invoice_number')])
                             @endif
@@ -117,7 +119,9 @@
                 </div>
             </div>
 
-            <div class="card mb-3">
+                </div>
+                <div class="col-xl-7">
+            <div class="card h-100">
                 <div class="card-header d-flex justify-content-between align-items-center">
                     <strong>Оборудование</strong>
                 </div>
@@ -153,6 +157,7 @@
                             <thead>
                             <tr>
                                 <th><input type="checkbox" class="form-check-input" id="production-order-items-select-all" title="Выбрать все"></th>
+                                <th>Картинка</th>
                                 <th>Позиция общего каталога</th>
                                 <th>Номер заказа МАФ</th>
                                 <th>Заводской номер</th>
@@ -171,6 +176,8 @@
                     @error('items') <div class="text-danger small p-2"><strong>{{ $message }}</strong></div> @enderror
                 </div>
             </div>
+                </div>
+            </div>
 
             <div class="d-flex flex-wrap gap-2">
                 @if(($order && hasPermission('schedule-orders.update')) || (!$order && hasPermission('schedule-orders.create')))
@@ -182,6 +189,16 @@
 
         @if($order)
             <div class="d-flex flex-wrap gap-2 mt-3">
+                @if($canManageDeliveries)
+                    <a href="#production-order-delivery-new" class="btn btn-sm btn-outline-primary">
+                        <i class="bi bi-truck"></i> Перенести в график доставок
+                    </a>
+                @endif
+                @if($canManageInstallations)
+                    <a href="#production-order-installation-new" class="btn btn-sm btn-outline-primary">
+                        <i class="bi bi-tools"></i> Перенести в график монтажей
+                    </a>
+                @endif
                 @if(hasPermission('schedule-orders.export'))
                     <form action="{{ route('schedule.orders.export-items', $order) }}" method="POST">
                         @csrf
@@ -200,6 +217,16 @@
                         </button>
                     </form>
                 @endif
+                @if(hasPermission('reclamations.create'))
+                    <form id="production-order-reclamation"
+                          action="{{ route('schedule.orders.reclamations.store', $order) }}" method="POST">
+                        @csrf
+                        <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+                        <button type="submit" class="btn btn-sm btn-outline-danger">
+                            <i class="bi bi-exclamation-triangle"></i> Создать рекламацию
+                        </button>
+                    </form>
+                @endif
             </div>
 
             @foreach($order->items as $orderItem)
@@ -370,10 +397,12 @@
                                 if (!response.ok || currentRequest !== requestNumber) return;
 
                                 const items = await response.json();
-                                results.replaceChildren(...items.map((item) => {
+                        results.replaceChildren(...items.map((item) => {
                                     const option = document.createElement('option');
                                     option.value = item.id;
                                     option.textContent = item.label;
+                                    option.dataset.image = item.image || '';
+                                    option.dataset.imageFull = item.image_full || item.image || '';
                                     return option;
                                 }));
                             } catch (error) {
@@ -393,6 +422,15 @@
                             row.innerHTML = row.innerHTML.replaceAll('__INDEX__', nextIndex++);
                             row.querySelector('.production-order-item-catalog-id').value = option.value;
                             row.querySelector('.production-order-item-catalog-label').textContent = option.textContent;
+                            if (option.dataset.image) {
+                                const imageLink = row.querySelector('.production-order-item-image-link');
+                                const image = row.querySelector('.production-order-item-image');
+                                imageLink.href = option.dataset.imageFull;
+                                imageLink.classList.remove('d-none');
+                                image.src = option.dataset.image;
+                                image.alt = option.textContent;
+                                row.querySelector('.production-order-item-image-empty')?.classList.add('d-none');
+                            }
                             rows.appendChild(fragment);
                         }
                         search.value = '';
@@ -405,6 +443,7 @@
                         if (!button) return;
                         button.closest('tr').remove();
                     });
+
                 });
             </script>
         @endpush
@@ -421,6 +460,25 @@
                             checkbox.checked = selectAll.checked;
                         });
                     });
+
+                    const reclamationForm = document.getElementById('production-order-reclamation');
+                    reclamationForm?.addEventListener('submit', (event) => {
+                        reclamationForm.querySelectorAll('input[name="item_ids[]"]').forEach((input) => input.remove());
+                        const selectedItems = document.querySelectorAll('.production-order-item-select:checked');
+                        if (!selectedItems.length) {
+                            event.preventDefault();
+                            alert('Выберите хотя бы один МАФ');
+                            return;
+                        }
+
+                        selectedItems.forEach((checkbox) => {
+                            const input = document.createElement('input');
+                            input.type = 'hidden';
+                            input.name = 'item_ids[]';
+                            input.value = checkbox.value;
+                            reclamationForm.appendChild(input);
+                        });
+                    });
                 });
             </script>
         @endpush

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

@@ -35,7 +35,7 @@
         </div>
         <div class="col-md-3 d-flex gap-2">
             <button type="submit" class="btn btn-sm btn-primary">
-                {{ $delivery ? 'Сохранить' : 'Добавить доставку' }}
+                {{ $delivery ? 'Сохранить' : 'Перенести в график доставок' }}
             </button>
             @if($delivery)
                 <button type="submit" form="{{ $formId }}-delete" class="btn btn-sm btn-outline-danger"

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

@@ -39,7 +39,7 @@
         </div>
         <div class="col-12 d-flex gap-2">
             <button type="submit" class="btn btn-sm btn-primary">
-                {{ $installation ? 'Сохранить' : 'Добавить монтаж' }}
+                {{ $installation ? 'Сохранить' : 'Перенести в график монтажей' }}
             </button>
             @if($installation)
                 <button type="submit" form="{{ $formId }}-delete" class="btn btn-sm btn-outline-danger"

+ 11 - 1
resources/views/production_orders/partials/item-row.blade.php

@@ -1,4 +1,5 @@
 @php($orderItem = !empty($itemRow['id']) && $order ? $order->items->firstWhere('id', (int) $itemRow['id']) : null)
+@php($catalogItem = $catalogItems->get((int) ($itemRow['common_catalog_item_id'] ?? 0)))
 <tr>
     <td>
         @if($orderItem)
@@ -7,11 +8,20 @@
                    form="production-order-technical-documents">
         @endif
     </td>
+    <td style="min-width: 86px">
+        @php($imageFile = $catalogItem?->imageFile)
+        <a href="{{ $imageFile?->link ?? '#' }}" data-toggle="lightbox"
+           data-gallery="production-order-items-{{ $order?->id ?? 'new' }}" data-size="fullscreen"
+           class="production-order-item-image-link {{ $imageFile ? '' : 'd-none' }}">
+            <img src="{{ $imageFile?->thumbnail_link }}" alt="{{ $catalogItem?->article }}"
+                 class="img-thumbnail maf-img production-order-item-image">
+        </a>
+        <span class="text-muted production-order-item-image-empty {{ $imageFile ? 'd-none' : '' }}">—</span>
+    </td>
     <td style="min-width: 320px">
         @if(!empty($itemRow['id']))
             <input type="hidden" name="items[{{ $index }}][id]" value="{{ $itemRow['id'] }}">
         @endif
-        @php($catalogItem = $catalogItems->get((int) ($itemRow['common_catalog_item_id'] ?? 0)))
         <input type="hidden" name="items[{{ $index }}][common_catalog_item_id]"
                class="production-order-item-catalog-id"
                value="{{ $itemRow['common_catalog_item_id'] ?? '' }}">

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

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

+ 3 - 0
routes/web.php

@@ -25,6 +25,7 @@ use App\Http\Controllers\ProductionOrderDeliveryController;
 use App\Http\Controllers\ProductionOrderDocumentController;
 use App\Http\Controllers\ProductionOrderFileController;
 use App\Http\Controllers\ProductionOrderInstallationController;
+use App\Http\Controllers\ProductionOrderReclamationController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
 use App\Http\Controllers\ResponsibleController;
@@ -227,6 +228,8 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             ->name('orders.installations.documents');
         Route::post('orders/{productionOrder}/technical-documents', [ProductionOrderDocumentController::class, 'technicalDocuments'])
             ->name('orders.technical-documents');
+        Route::post('orders/{productionOrder}/reclamations', [ProductionOrderReclamationController::class, 'store'])
+            ->name('orders.reclamations.store');
         Route::get('orders/files/{file}', [ProductionOrderDocumentController::class, 'download'])
             ->name('orders.files.download');
         Route::get('deliveries', [ProductionOrderDeliveryController::class, 'index'])->name('deliveries');

+ 116 - 0
tests/Feature/ProductionOrderControllerTest.php

@@ -8,9 +8,12 @@ use App\Enums\ProductionOrderExecutionType;
 use App\Enums\ProductionOrderStatus;
 use App\Jobs\Export\ExportProductionOrdersJob;
 use App\Models\CommonCatalogItem;
+use App\Models\File;
 use App\Models\ProductionCalendarDay;
 use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
 use App\Models\ProductionOrderItem;
+use App\Models\ProductionOrderInstallation;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Facades\Bus;
@@ -252,6 +255,119 @@ class ProductionOrderControllerTest extends TestCase
             ->assertDontSee('ГЗ-РАЗМЕЩЕН');
     }
 
+    public function test_order_schedule_shows_planning_dates_and_status_row_styles(): void
+    {
+        $closed = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-ЗАКРЫТ',
+            'status' => ProductionOrderStatus::Closed,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $closed->id,
+            'delivery_date' => '2026-09-14',
+        ]);
+        ProductionOrderInstallation::factory()->create([
+            'production_order_id' => $closed->id,
+            'installation_date' => '2026-09-16',
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertSee('Дата доставки')
+            ->assertSee('Дата монтажа')
+            ->assertSee('14.09.2026')
+            ->assertSee('16.09.2026')
+            ->assertSee('table-dark opacity-50', false);
+    }
+
+    public function test_planning_date_filters_match_any_related_record(): void
+    {
+        $matching = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-ДАТА-НУЖНАЯ',
+            'manager_id' => $this->manager->id,
+        ]);
+        $other = ProductionOrder::factory()->create([
+            'order_number' => 'ГЗ-ДАТА-ДРУГАЯ',
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $matching->id,
+            'delivery_date' => '2026-09-20',
+        ]);
+        ProductionOrderDelivery::factory()->create([
+            'production_order_id' => $other->id,
+            'delivery_date' => '2026-09-21',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'schedule_orders',
+                'column' => 'delivery_dates',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['2026-09-20']);
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders', [
+                'filters' => ['delivery_dates' => '2026-09-20'],
+            ]))
+            ->assertOk()
+            ->assertSee($matching->order_number)
+            ->assertDontSee($other->order_number);
+    }
+
+    public function test_application_shipment_date_is_admin_only_by_default(): void
+    {
+        $this->assertTrue($this->admin->canViewField('schedule-orders', 'application_shipment_date'));
+        $this->assertFalse($this->manager->canViewField('schedule-orders', 'application_shipment_date'));
+
+        $order = ProductionOrder::factory()->create([
+            'application_shipment_date' => '2026-11-27',
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertSee('Дата отгрузки по заявке')
+            ->assertSee('27.11.2026');
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders'))
+            ->assertOk()
+            ->assertDontSee('Дата отгрузки по заявке')
+            ->assertDontSee('27.11.2026');
+
+        $this->actingAs($this->manager)
+            ->get(route('schedule.orders.show', $order))
+            ->assertOk()
+            ->assertDontSee('Дата отгрузки по заявке')
+            ->assertDontSee('2026-11-27');
+    }
+
+    public function test_order_card_displays_catalog_item_image_and_graph_actions(): void
+    {
+        $image = File::factory()->create([
+            'link' => 'https://example.test/catalog/maf.jpg',
+            'mime_type' => 'image/jpeg',
+        ]);
+        $this->catalogItem->update(['image_file_id' => $image->id]);
+        $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $this->catalogItem->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('schedule.orders.show', $order))
+            ->assertOk()
+            ->assertSee('https://example.test/catalog/maf.jpg', false)
+            ->assertSee('Перенести в график доставок')
+            ->assertSee('Перенести в график монтажей')
+            ->assertSee('Создать рекламацию');
+    }
+
     public function test_admin_can_export_only_orders_matching_active_filters_and_search(): void
     {
         Bus::fake([ExportProductionOrdersJob::class]);

+ 119 - 0
tests/Feature/ProductionOrderReclamationControllerTest.php

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