7 Commity b90aad9102 ... 2d97181384

Autor SHA1 Wiadomość Data
  Alexander Musikhin 2d97181384 schedule export: split long pdf output into pages 2 tygodni temu
  Alexander Musikhin 8a5d1b7df0 reclamations/spare-parts: selectable source order for issue flow 2 tygodni temu
  Alexander Musikhin 2d47409283 spare-parts/reclamations: ordered row and article note in autocomplete 2 tygodni temu
  Alexander Musikhin 878bce61fa chat/responsibles/mafs: fix brigadier recipients and broken filters 2 tygodni temu
  Alexander Musikhin a879cb0390 orders: totals column and filtered footer stats 2 tygodni temu
  Alexander Musikhin 560910c7aa orders: manager access to ready date, object info, move-maf label 2 tygodni temu
  Alexander Musikhin 21341d6e3d fix brigadier can send to chat 2 tygodni temu
34 zmienionych plików z 1502 dodań i 119 usunięć
  1. 1 1
      app/Http/Controllers/ChatMessageController.php
  2. 10 2
      app/Http/Controllers/Controller.php
  3. 19 4
      app/Http/Controllers/FilterController.php
  4. 12 6
      app/Http/Controllers/OrderController.php
  5. 2 2
      app/Http/Controllers/ReclamationController.php
  6. 4 2
      app/Http/Controllers/ResponsibleController.php
  7. 2 2
      app/Http/Controllers/SparePartController.php
  8. 6 1
      app/Http/Controllers/SparePartReservationController.php
  9. 8 1
      app/Models/Order.php
  10. 9 1
      app/Models/OrderView.php
  11. 38 0
      app/Models/ReclamationSparePart.php
  12. 26 0
      app/Models/ResponsibleView.php
  13. 24 0
      app/Models/SparePart.php
  14. 113 59
      app/Services/ExportScheduleService.php
  15. 167 0
      app/Services/SparePartIssueService.php
  16. 59 0
      database/migrations/2026_04_11_120000_add_products_total_to_orders_view.php
  17. 27 0
      database/migrations/2026_04_11_130000_create_responsibles_view.php
  18. 78 0
      database/migrations/2026_04_11_131000_normalize_empty_fields_in_mafs_view.php
  19. 1 1
      resources/views/orders/edit.blade.php
  20. 7 1
      resources/views/orders/index.blade.php
  21. 5 3
      resources/views/orders/show.blade.php
  22. 12 0
      resources/views/partials/pagination.blade.php
  23. 82 12
      resources/views/reclamations/edit.blade.php
  24. 12 6
      resources/views/spare_parts/edit.blade.php
  25. 76 0
      tests/Feature/ChatMessageControllerTest.php
  26. 180 12
      tests/Feature/OrderControllerTest.php
  27. 91 0
      tests/Feature/ProductSKUControllerTest.php
  28. 60 0
      tests/Feature/ReclamationControllerTest.php
  29. 86 0
      tests/Feature/ResponsibleControllerTest.php
  30. 43 2
      tests/Feature/SparePartControllerTest.php
  31. 57 0
      tests/Feature/SparePartReservationControllerTest.php
  32. 7 1
      tests/TestCase.php
  33. 72 0
      tests/Unit/Services/Export/ExportScheduleServiceTest.php
  34. 106 0
      tests/Unit/Services/SparePartIssueServiceTest.php

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

@@ -132,7 +132,7 @@ class ChatMessageController extends Controller
             $recipientIds = [$targetUserId];
         }
 
-        if (in_array($notificationType, [
+        if (!$isBrigadier && in_array($notificationType, [
             ChatMessage::NOTIFICATION_RESPONSIBLES,
             ChatMessage::NOTIFICATION_ALL,
         ], true)) {

+ 10 - 2
app/Http/Controllers/Controller.php

@@ -222,7 +222,7 @@ class Controller extends BaseController
                         $nonNullValues = [];
                         foreach ($values as $v) {
                             if ($v == '-пусто-') {
-                                $q->orWhereNull($dbColumn);
+                                $this->applyEmptyFilterCondition($q, $dbColumn);
                             } else {
                                 $nonNullValues[] = $v;
                             }
@@ -233,7 +233,9 @@ class Controller extends BaseController
                     });
                 } else {
                     if($dbValue == '-пусто-') {
-                        $query->whereNull($dbColumn);
+                        $query->where(function ($q) use ($dbColumn) {
+                            $this->applyEmptyFilterCondition($q, $dbColumn);
+                        });
                     } else {
                         $query->where($dbColumn, $dbValue);
                     }
@@ -306,6 +308,12 @@ class Controller extends BaseController
         return [$dbColumn, $filterValue];
     }
 
+    protected function applyEmptyFilterCondition(Builder $query, string $dbColumn): void
+    {
+        $query->orWhereNull($dbColumn)
+            ->orWhereRaw("TRIM(CAST({$dbColumn} AS CHAR)) = ''");
+    }
+
     /**
      * @param Builder $query
      * @param Request $request

+ 19 - 4
app/Http/Controllers/FilterController.php

@@ -15,7 +15,7 @@ class FilterController extends Controller
         'reclamations'  => 'reclamations_view',
         'maf_order'     => 'maf_orders_view',
         'import'        => 'imports',
-        'responsibles'  => 'responsibles',
+        'responsibles'  => 'responsibles_view',
         'users'         => 'users',
         'contracts'     => 'contracts',
         'spare_parts'   => 'spare_parts_view',
@@ -49,6 +49,9 @@ class FilterController extends Controller
             'installation_price_txt' => 'installation_price',
             'total_price_txt' => 'total_price',
         ],
+        'responsibles' => [
+            'area-name' => 'area_name',
+        ],
     ];
 
     /**
@@ -110,7 +113,8 @@ class FilterController extends Controller
         $dbColumn = self::resolveDbColumn($table, $dbTable, $column);
 
         if ($dbColumn && Schema::hasColumn($dbTable, $dbColumn)) {
-            $q = DB::table($dbTable)->select($dbColumn)->distinct();
+            $normalizedColumn = self::normalizedSelectExpression($dbColumn);
+            $q = DB::table($dbTable)->selectRaw($normalizedColumn . ' as filter_value')->distinct();
             if (!in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
                 $q->where('year' , year());
             }
@@ -126,7 +130,7 @@ class FilterController extends Controller
                     $q->where(function ($query) use ($filterDbColumn, $vals) {
                         foreach (explode('||', $vals) as $val) {
                             if($val == '-пусто-') {
-                                $query->orWhereNull($filterDbColumn);
+                                self::applyEmptyFilterConditionForFilterQuery($query, $filterDbColumn);
                             } else {
                                 $query->orWhere($filterDbColumn, '=', $val);
                             }
@@ -135,7 +139,7 @@ class FilterController extends Controller
                 }
             }
 
-            $result = $q->orderBy($dbColumn)->get()->pluck($dbColumn)->toArray();
+            $result = $q->orderBy('filter_value')->get()->pluck('filter_value')->toArray();
 
             // Конвертация цен из копеек в рубли для отображения
             if (str_ends_with($dbColumn, '_price')) {
@@ -159,6 +163,17 @@ class FilterController extends Controller
         return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
     }
 
+    private static function normalizedSelectExpression(string $column): string
+    {
+        return "CASE WHEN {$column} IS NULL OR TRIM(CAST({$column} AS CHAR)) = '' THEN '-пусто-' ELSE CAST({$column} AS CHAR) END";
+    }
+
+    private static function applyEmptyFilterConditionForFilterQuery($query, string $column): void
+    {
+        $query->orWhereNull($column)
+            ->orWhereRaw("TRIM(CAST({$column} AS CHAR)) = ''");
+    }
+
     /**
      * Определяет реальный столбец БД по имени столбца из заголовка.
      * Приоритет: прямое совпадение в БД → COLUMN_MAP для конкретной таблицы.

+ 12 - 6
app/Http/Controllers/OrderController.php

@@ -51,6 +51,7 @@ class OrderController extends Controller
             'object_address'            => 'Адрес объекта',
             'object_type_name'          => 'Тип объекта',
             'comment'                   => 'Комментарий',
+            'products_total'            => 'Всего МАФ',
             'installation_date'         => 'Дата выхода на монтаж',
             'ready_date'                => 'Дата готовности площадки',
             'brigadier_name'            => 'Бригадир',
@@ -113,6 +114,9 @@ class OrderController extends Controller
             $q->whereNotNull('installation_date');
         }
 
+        $this->data['filtered_orders_count'] = (clone $q)->count();
+        $this->data['filtered_products_total'] = (clone $q)->sum('products_total');
+
         $this->applyStableSorting($q);
         $this->data['orders'] = $q->paginate($this->data['per_page'])->withQueryString();
 
@@ -491,6 +495,7 @@ class OrderController extends Controller
     {
         $ret = [];
         $s = $request->get('s');
+        $currentOrderId = $request->integer('current_order_id');
         $searchFields = $this->data['searchFields'];
         $result = OrderView::query();
         if($s) {
@@ -501,9 +506,13 @@ class OrderController extends Controller
             });
         }
 
+        if ($currentOrderId > 0) {
+            $result->where('id', '!=', $currentOrderId);
+        }
+
         $result->orderBy('object_address');
         foreach ($result->get() as $p) {
-            $ret[$p->id] = $p->common_name;
+            $ret[$p->id] = $p->move_maf_name;
         }
         return $ret;
     }
@@ -511,7 +520,7 @@ class OrderController extends Controller
     public function uploadPhoto(Request $request, Order $order, FileService $fileService)
     {
         $data = $request->validate([
-            'photo.*' => 'mimes:jpeg,jpg,png',
+            'photo.*' => 'mimes:jpeg,jpg,png,webp',
         ]);
 
         try {
@@ -634,10 +643,7 @@ class OrderController extends Controller
 
     public function generateInstallationPack(Order $order)
     {
-        $errors = [];
-        if(!in_array($order->order_status_id, [Order::STATUS_READY_TO_MOUNT, Order::STATUS_IN_MOUNT]))
-            $errors[] = 'Статус должен быть "Готов к монтажу" или "В монтаже"!';
-        $errors = array_merge($errors, $order->isAllMafConnected());
+        $errors = $order->isAllMafConnected();
         if($errors) {
             return redirect()->route('order.show', $order)->with(['danger' => $errors]);
         }

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

@@ -229,7 +229,7 @@ class ReclamationController extends Controller
         $this->ensureCanViewReclamation($reclamation);
 
         $data = $request->validate([
-            'photo.*' => 'mimes:jpeg,jpg,png|max:8192',
+            'photo.*' => 'mimes:jpeg,jpg,png,webp|max:8192',
         ]);
 
         try {
@@ -253,7 +253,7 @@ class ReclamationController extends Controller
         $this->ensureCanViewReclamation($reclamation);
 
         $data = $request->validate([
-            'photo.*' => 'mimes:jpeg,jpg,png|max:8192',
+            'photo.*' => 'mimes:jpeg,jpg,png,webp|max:8192',
         ]);
 
         try {

+ 4 - 2
app/Http/Controllers/ResponsibleController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Http\Requests\StoreResponsibleRequest;
 use App\Models\Dictionary\Area;
 use App\Models\Responsible;
+use App\Models\ResponsibleView;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 
@@ -17,7 +18,7 @@ class ResponsibleController extends Controller
         'id'        => 'responsibles',
         'header'    => [
             'id'            => 'ID',
-            'area-name'     => 'Район',
+            'area_name'     => 'Район',
             'name'          => 'ФИО',
             'phone'         => 'Телефон',
             'post'          => 'Должность',
@@ -51,7 +52,8 @@ class ResponsibleController extends Controller
     {
         session(['gp_responsibles' => $request->query()]);
 
-        $model = new Responsible;
+        $model = new ResponsibleView;
+        $this->createFilters($model, 'area_name');
         $this->createDateFilters($model, 'created_at');
 
         $q = $model::query();

+ 2 - 2
app/Http/Controllers/SparePartController.php

@@ -294,11 +294,11 @@ class SparePartController extends Controller
         $spareParts = SparePart::query()
             ->where(function ($q) use ($query) {
                 $q->where('article', 'LIKE', '%' . $query . '%')
-                    ->orWhere('used_in_maf', 'LIKE', '%' . $query . '%');
+                    ->orWhere('note', 'LIKE', '%' . $query . '%');
             })
             ->orderBy('article')
             ->limit(20)
-            ->get(['id', 'article', 'used_in_maf']);
+            ->get(['id', 'article', 'note']);
 
         return response()->json($spareParts);
     }

+ 6 - 1
app/Http/Controllers/SparePartReservationController.php

@@ -91,13 +91,18 @@ class SparePartReservationController extends Controller
     public function issue(Request $request, Reservation $reservation): RedirectResponse
     {
         $note = $request->input('note', '');
+        $selectedOrderId = (int) $request->input('selected_order_id', 0);
 
         if (!$reservation->isActive()) {
             return back()->with(['error' => 'Резерв не активен!']);
         }
 
         try {
-            $this->issueService->issueReservation($reservation, $note);
+            if ($selectedOrderId > 0) {
+                $this->issueService->issueReservationFromSelectedOrder($reservation, $selectedOrderId, $note);
+            } else {
+                $this->issueService->issueReservation($reservation, $note);
+            }
             return back()->with(['success' => 'Списание выполнено!']);
         } catch (\Exception $e) {
             return back()->with(['error' => $e->getMessage()]);

+ 8 - 1
app/Models/Order.php

@@ -104,7 +104,7 @@ class Order extends Model
         'install_days',
     ];
 
-    public $appends = ['common_name', 'products_with_count'];
+    public $appends = ['common_name', 'move_maf_name', 'products_with_count'];
 
     public function products_sku(): HasMany
     {
@@ -251,6 +251,13 @@ class Order extends Model
         );
     }
 
+    public function moveMafName(): Attribute
+    {
+        return Attribute::make(
+            get: fn ($value) => trim($this->common_name . ($this->name ? ' | ' . $this->name : '')),
+        );
+    }
+
     public function productsWithCount(): Attribute
     {
         $products = $this->products_sku;

+ 9 - 1
app/Models/OrderView.php

@@ -38,9 +38,10 @@ class OrderView extends Model
         'object_type_name',
         'brigadier_name',
         'order_status_name',
+        'products_total',
     ];
 
-    public $appends = ['common_name', 'products_with_count'];
+    public $appends = ['common_name', 'move_maf_name', 'products_with_count'];
 
 
     public function commonName(): Attribute
@@ -50,6 +51,13 @@ class OrderView extends Model
         );
     }
 
+    public function moveMafName(): Attribute
+    {
+        return Attribute::make(
+            get: fn ($value) => trim($this->common_name . ($this->name ? ' | ' . $this->name : '')),
+        );
+    }
+
     public function productsWithCount(): Attribute
     {
         $products = ProductSKU::query()->where('order_id', $this->id)->get(); //$this->products_sku;

+ 38 - 0
app/Models/ReclamationSparePart.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ReclamationSparePart extends Model
+{
+    protected $table = 'reclamation_spare_part';
+
+    protected $fillable = [
+        'reclamation_id',
+        'spare_part_id',
+        'quantity',
+        'with_documents',
+        'status',
+        'reserved_qty',
+        'issued_qty',
+    ];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'with_documents' => 'boolean',
+        'reserved_qty' => 'integer',
+        'issued_qty' => 'integer',
+    ];
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+}

+ 26 - 0
app/Models/ResponsibleView.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ResponsibleView extends Model
+{
+    use SoftDeletes;
+
+    public const DEFAULT_SORT_BY = 'updated_at';
+
+    protected $table = 'responsibles_view';
+
+    protected $fillable = [
+        'name',
+        'phone',
+        'post',
+        'area_id',
+        'area_name',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+    ];
+}

+ 24 - 0
app/Models/SparePart.php

@@ -66,6 +66,9 @@ class SparePart extends Model
         'quantity_without_docs',
         'quantity_with_docs',
         'total_quantity',
+        'ordered_without_docs',
+        'ordered_with_docs',
+        'total_ordered',
     ];
 
     // Аксессоры для цен (копейки -> рубли)
@@ -263,6 +266,27 @@ class SparePart extends Model
         return $this->reserved_without_docs + $this->reserved_with_docs;
     }
 
+    public function getOrderedWithoutDocsAttribute(): int
+    {
+        return (int) ($this->orders()
+            ->where('status', SparePartOrder::STATUS_ORDERED)
+            ->where('with_documents', false)
+            ->sum('ordered_quantity') ?? 0);
+    }
+
+    public function getOrderedWithDocsAttribute(): int
+    {
+        return (int) ($this->orders()
+            ->where('status', SparePartOrder::STATUS_ORDERED)
+            ->where('with_documents', true)
+            ->sum('ordered_quantity') ?? 0);
+    }
+
+    public function getTotalOrderedAttribute(): int
+    {
+        return $this->ordered_without_docs + $this->ordered_with_docs;
+    }
+
     /**
      * Общий свободный остаток
      */

+ 113 - 59
app/Services/ExportScheduleService.php

@@ -14,10 +14,14 @@ use PhpOffice\PhpSpreadsheet\Style\Border;
 use PhpOffice\PhpSpreadsheet\Style\Color;
 use PhpOffice\PhpSpreadsheet\Style\Fill;
 use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class ExportScheduleService
 {
+    private const DATA_START_ROW = 3;
+    private const DATA_END_COLUMN = 'K';
+    private const PDF_ROWS_PER_PAGE = 24;
 
 
     /**
@@ -25,89 +29,139 @@ class ExportScheduleService
      */
     public function handle(Collection $schedules, int $userId): string
     {
+        $spreadsheet = $this->buildSpreadsheet($schedules);
+        $sheet = $spreadsheet->getActiveSheet();
+        $header = (string) $sheet->getCell('A1')->getValue();
+        $fileName = $header . '.xlsx';
+
+        $writer = new Xlsx($spreadsheet);
+        $fd = 'export/schedule/tmp';
+        Storage::disk('public')->makeDirectory($fd);
+        $fp = storage_path('app/public/export/schedule/') . '/tmp/' . $fileName;
+        Storage::disk('public')->delete($fd . '/' . $fileName);
+        $writer->save($fp);
+        PdfConverterClient::convert($fp);
+
+        // create zip archive
+        $fileModel = (new FileService())->createZipArchive($fd, Str::replace('.xlsx', '.zip', $fileName), $userId);
+
+        //   remove temp files
+        Storage::disk('public')->deleteDirectory($fd);
+
+        // return link
+        return $fileModel?->link ?? '';
 
-        $inputFileType = 'Xlsx'; // Xlsx - Xml - Ods - Slk - Gnumeric - Csv
-        $inputFileName = './templates/Schedule.xlsx';
+    }
 
-        $reader = IOFactory::createReader($inputFileType);
-        $spreadsheet = $reader->load($inputFileName);
+    public function buildSpreadsheet(Collection $schedules): \PhpOffice\PhpSpreadsheet\Spreadsheet
+    {
+        $reader = IOFactory::createReader('Xlsx');
+        $spreadsheet = $reader->load('./templates/Schedule.xlsx');
         $sheet = $spreadsheet->getActiveSheet();
         $sheet->getPageSetup()->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
 
-        $i = 3;
-        $first = true;
+        $row = self::DATA_START_ROW;
         $from = '-';
-        $prevInstDate = '';
-        $j = 1;
+        $prevInstDate = null;
+        $sameDateRows = 0;
+        $rowsOnCurrentPage = 0;
+        $lastScheduleDate = null;
+
         foreach ($schedules as $schedule) {
-            if($first) {
-                $first = false;
+            if ($prevInstDate === null) {
                 $from = $schedule->installation_date;
                 $prevInstDate = $schedule->installation_date;
-
-            } elseif($prevInstDate === $schedule->installation_date) {
-                $j++;
+                $sameDateRows = 1;
+            } elseif ($prevInstDate === $schedule->installation_date) {
+                $sameDateRows++;
             } else {
-                if($j > 1) {
-                    $sheet->mergeCells('A' . $i - $j . ':A' . $i - 1);
-                    $sheet->mergeCells('B' . $i - $j . ':B' . $i - 1);
+                if ($sameDateRows > 1) {
+                    $this->mergeDateColumns($sheet, $row - $sameDateRows, $row - 1);
+                }
+
+                if ($rowsOnCurrentPage >= self::PDF_ROWS_PER_PAGE) {
+                    $sheet->setBreak('A' . $row, Worksheet::BREAK_ROW);
+                    $rowsOnCurrentPage = 0;
                 }
-                $j = 1;
+
+                $sameDateRows = 1;
                 $prevInstDate = $schedule->installation_date;
             }
 
-            $sheet->setCellValue('A' . $i, DateHelper::getHumanDayOfWeek($schedule->installation_date));
-            $sheet->setCellValue('B' . $i, DateHelper::getHumanDate($schedule->installation_date, true));
-
-            $sheet->setCellValue('C' . $i, $schedule->address_code);
-            $sheet->setCellValue('D' . $i, $schedule->district->shortname);
-            $sheet->setCellValue('E' . $i, $schedule->area->name);
-            $sheet->setCellValue('F' . $i, $schedule->object_address);
-            $sheet->setCellValue('G' . $i, $schedule->object_type);
-            $sheet->setCellValue('H' . $i, Str::trim($schedule->mafs));
-            $sheet->setCellValue('I' . $i, $schedule->mafs_count);
-            $sheet->setCellValue('J' . $i, $schedule->brigadier->name);
-            $sheet->setCellValue('K' . $i, $schedule->comment);
-
-            $sheet->getStyle('C' . $i . ':K' . $i)->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(Str::replace('#', '', $schedule->brigadier->color));
-            $i++;
+            $sheet->setCellValue('A' . $row, DateHelper::getHumanDayOfWeek($schedule->installation_date));
+            $sheet->setCellValue('B' . $row, DateHelper::getHumanDate($schedule->installation_date, true));
+
+            $sheet->setCellValue('C' . $row, $schedule->address_code);
+            $sheet->setCellValue('D' . $row, $schedule->district->shortname);
+            $sheet->setCellValue('E' . $row, $schedule->area->name);
+            $sheet->setCellValue('F' . $row, $schedule->object_address);
+            $sheet->setCellValue('G' . $row, $schedule->object_type);
+            $sheet->setCellValue('H' . $row, Str::trim($schedule->mafs));
+            $sheet->setCellValue('I' . $row, $schedule->mafs_count);
+            $sheet->setCellValue('J' . $row, $schedule->brigadier->name);
+            $sheet->setCellValue('K' . $row, $schedule->comment);
+
+            $sheet->getStyle('C' . $row . ':K' . $row)
+                ->getFill()
+                ->setFillType(Fill::FILL_SOLID)
+                ->getStartColor()
+                ->setRGB(Str::replace('#', '', $schedule->brigadier->color));
+
+            $lastScheduleDate = $schedule->installation_date;
+            $row++;
+            $rowsOnCurrentPage++;
         }
-        // merge last cells if $j > 1
-        if($j > 1) {
-            $sheet->mergeCells('A' . $i - $j . ':A' . $i - 1);
-            $sheet->mergeCells('B' . $i - $j . ':B' . $i - 1);
+
+        if ($sameDateRows > 1) {
+            $this->mergeDateColumns($sheet, $row - $sameDateRows, $row - 1);
         }
 
-        $sheet->getStyle('A2:B' . $i -1)->getAlignment()->setTextRotation(90);
+        $lastDataRow = max($row - 1, 2);
 
-        $sheet->getStyle('A2:K' . $i - 1)->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN)->setColor(new Color('777777'));
-        $sheet->getStyle('A2:K' . $i - 1)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
-        $sheet->getStyle('C2:K' . $i - 1)->getAlignment()->setWrapText(true);
+        $sheet->getStyle('A2:B' . $lastDataRow)->getAlignment()->setTextRotation(90);
+        $sheet->getStyle('A2:' . self::DATA_END_COLUMN . $lastDataRow)
+            ->getBorders()
+            ->getAllBorders()
+            ->setBorderStyle(Border::BORDER_THIN)
+            ->setColor(new Color('777777'));
+        $sheet->getStyle('A2:' . self::DATA_END_COLUMN . $lastDataRow)
+            ->getAlignment()
+            ->setHorizontal(Alignment::HORIZONTAL_CENTER);
+        $sheet->getStyle('C2:' . self::DATA_END_COLUMN . $lastDataRow)->getAlignment()->setWrapText(true);
 
-        $fromText = (DateHelper::isDate($from)) ? DateHelper::getHumanDate($from) : '-';
-        $toText = (isset($schedule) && $schedule->installation_date) ? DateHelper::getHumanDate($schedule->installation_date) : '-';
+        $this->configurePdfLayout($sheet, $lastDataRow);
+
+        $fromText = DateHelper::isDate($from) ? DateHelper::getHumanDate($from) : '-';
+        $toText = DateHelper::isDate((string) $lastScheduleDate) ? DateHelper::getHumanDate($lastScheduleDate) : '-';
         $header = 'График монтажей с ' . $fromText . ' по ' . $toText;
 
         $sheet->setCellValue('A1', $header);
         $sheet->getStyle('A1')->getFont()->setBold(true);
-        $fileName = $header . '.xlsx';
-
-        $writer = new Xlsx($spreadsheet);
-        $fd = 'export/schedule/tmp';
-        Storage::disk('public')->makeDirectory($fd);
-        $fp = storage_path('app/public/export/schedule/') . '/tmp/' . $fileName;
-        Storage::disk('public')->delete($fd . '/' . $fileName);
-        $writer->save($fp);
-        PdfConverterClient::convert($fp);
-
-        // create zip archive
-        $fileModel = (new FileService())->createZipArchive($fd, Str::replace('.xlsx', '.zip', $fileName), $userId);
 
-        //   remove temp files
-        Storage::disk('public')->deleteDirectory($fd);
+        return $spreadsheet;
+    }
 
-        // return link
-        return $fileModel?->link ?? '';
+    private function mergeDateColumns(Worksheet $sheet, int $startRow, int $endRow): void
+    {
+        $sheet->mergeCells('A' . $startRow . ':A' . $endRow);
+        $sheet->mergeCells('B' . $startRow . ':B' . $endRow);
+    }
 
+    private function configurePdfLayout(Worksheet $sheet, int $lastDataRow): void
+    {
+        $pageSetup = $sheet->getPageSetup();
+        $pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
+        $pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
+        $pageSetup->setFitToWidth(1);
+        $pageSetup->setFitToHeight(0);
+
+        $sheet->getPageMargins()
+            ->setTop(0.4)
+            ->setBottom(0.4)
+            ->setLeft(0.25)
+            ->setRight(0.25);
+
+        $pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
+        $pageSetup->setPrintArea('A1:' . self::DATA_END_COLUMN . $lastDataRow);
     }
-}
+}

+ 167 - 0
app/Services/SparePartIssueService.php

@@ -3,7 +3,9 @@
 namespace App\Services;
 
 use App\Models\InventoryMovement;
+use App\Models\ReclamationSparePart;
 use App\Models\Reservation;
+use App\Models\Shortage;
 use App\Models\SparePartOrder;
 use Illuminate\Support\Facades\DB;
 
@@ -114,6 +116,92 @@ class SparePartIssueService
         return $results;
     }
 
+    public function issueReservationFromSelectedOrder(
+        Reservation $reservation,
+        int $selectedOrderId,
+        string $note = ''
+    ): IssueResult {
+        if (!$reservation->isActive()) {
+            throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
+        }
+
+        if ((int) $reservation->spare_part_order_id === $selectedOrderId) {
+            $result = $this->issueReservation($reservation, $note);
+            $this->syncPivotRowForReservation($reservation);
+
+            return $result;
+        }
+
+        return DB::transaction(function () use ($reservation, $selectedOrderId, $note) {
+            $reservation = Reservation::query()->lockForUpdate()->findOrFail($reservation->id);
+            $selectedOrder = SparePartOrder::query()->lockForUpdate()->findOrFail($selectedOrderId);
+
+            if (!$reservation->isActive()) {
+                throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
+            }
+
+            if ((int) $selectedOrder->spare_part_id !== (int) $reservation->spare_part_id) {
+                throw new \InvalidArgumentException('Выбран заказ другой запчасти');
+            }
+
+            if ((bool) $selectedOrder->with_documents !== (bool) $reservation->with_documents) {
+                throw new \InvalidArgumentException('Выбран заказ с другим признаком документов');
+            }
+
+            if ($selectedOrder->status !== SparePartOrder::STATUS_IN_STOCK) {
+                throw new \InvalidArgumentException('Списание возможно только из партии на складе');
+            }
+
+            $alreadyReservedOnSelected = Reservation::query()
+                ->where('spare_part_order_id', $selectedOrder->id)
+                ->where('status', Reservation::STATUS_ACTIVE)
+                ->sum('reserved_qty');
+
+            $freeQty = max(0, (int) $selectedOrder->available_qty - (int) $alreadyReservedOnSelected);
+            if ($freeQty < 1) {
+                throw new \RuntimeException('В выбранном заказе нет доступного остатка');
+            }
+
+            $requestedQty = (int) $reservation->reserved_qty;
+            $issueQty = min($requestedQty, $freeQty);
+
+            app(SparePartReservationService::class)
+                ->cancelReservation($reservation, 'Перенос резерва на другой заказ для списания');
+
+            $reserveMovement = InventoryMovement::create([
+                'spare_part_order_id' => $selectedOrder->id,
+                'spare_part_id' => $reservation->spare_part_id,
+                'qty' => $issueQty,
+                'movement_type' => InventoryMovement::TYPE_RESERVE,
+                'source_type' => InventoryMovement::SOURCE_RECLAMATION,
+                'source_id' => $reservation->reclamation_id,
+                'with_documents' => $reservation->with_documents,
+                'user_id' => auth()->id(),
+                'note' => "Резервирование для списания из выбранного заказа #{$selectedOrder->id}",
+            ]);
+
+            $selectedReservation = Reservation::create([
+                'spare_part_id' => $reservation->spare_part_id,
+                'spare_part_order_id' => $selectedOrder->id,
+                'reclamation_id' => $reservation->reclamation_id,
+                'reserved_qty' => $issueQty,
+                'with_documents' => $reservation->with_documents,
+                'status' => Reservation::STATUS_ACTIVE,
+                'movement_id' => $reserveMovement->id,
+            ]);
+
+            $result = $this->issueReservation($selectedReservation, $note);
+
+            if ($issueQty < $requestedQty) {
+                $this->splitPivotRowAfterPartialIssue($reservation, $issueQty, $requestedQty - $issueQty);
+            } else {
+                $this->syncPivotRowForReservation($reservation);
+            }
+
+            return $result;
+        });
+    }
+
     /**
      * Прямое списание без резерва (для ручных операций)
      *
@@ -216,6 +304,85 @@ class SparePartIssueService
             ]);
         });
     }
+
+    private function syncPivotRowForReservation(Reservation $reservation): void
+    {
+        $row = ReclamationSparePart::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->orderBy('id')
+            ->first();
+
+        if (!$row) {
+            return;
+        }
+
+        $activeQty = (int) Reservation::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty');
+
+        $issuedQty = (int) Reservation::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('status', Reservation::STATUS_ISSUED)
+            ->sum('reserved_qty');
+
+        $row->update([
+            'reserved_qty' => $activeQty,
+            'issued_qty' => min($row->quantity, $issuedQty),
+            'status' => $issuedQty >= $row->quantity
+                ? 'issued'
+                : ($activeQty > 0 ? 'reserved' : 'pending'),
+        ]);
+    }
+
+    private function splitPivotRowAfterPartialIssue(Reservation $reservation, int $issuedQty, int $remainingQty): void
+    {
+        $row = ReclamationSparePart::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('quantity', '>=', $reservation->reserved_qty)
+            ->orderBy('id')
+            ->first();
+
+        if (!$row) {
+            return;
+        }
+
+        $row->update([
+            'quantity' => $issuedQty,
+            'reserved_qty' => 0,
+            'issued_qty' => $issuedQty,
+            'status' => 'issued',
+        ]);
+
+        ReclamationSparePart::query()->create([
+            'reclamation_id' => $row->reclamation_id,
+            'spare_part_id' => $row->spare_part_id,
+            'quantity' => $remainingQty,
+            'with_documents' => $row->with_documents,
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+
+        Shortage::query()->create([
+            'spare_part_id' => $row->spare_part_id,
+            'reclamation_id' => $row->reclamation_id,
+            'with_documents' => $row->with_documents,
+            'required_qty' => $remainingQty,
+            'reserved_qty' => 0,
+            'missing_qty' => $remainingQty,
+            'status' => Shortage::STATUS_OPEN,
+            'note' => 'Остаток после частичного списания из выбранного заказа',
+        ]);
+    }
 }
 
 /**

+ 59 - 0
database/migrations/2026_04_11_120000_add_products_total_to_orders_view.php

@@ -0,0 +1,59 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS orders_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW orders_view AS
+            SELECT
+                o.*,
+                (
+                    SELECT COUNT(*)
+                    FROM products_sku ps
+                    WHERE ps.order_id = o.id
+                      AND ps.deleted_at IS NULL
+                ) AS products_total,
+                u.name AS user_name,
+                d.shortname AS district_name,
+                a.name AS area_name,
+                ot.name AS object_type_name,
+                u2.name AS brigadier_name,
+                os.name AS order_status_name
+            FROM orders o
+                LEFT JOIN users u ON u.id = o.user_id
+                LEFT JOIN districts d ON d.id = o.district_id
+                LEFT JOIN areas a ON a.id = o.area_id
+                LEFT JOIN object_types ot ON ot.id = o.object_type_id
+                LEFT JOIN users u2 ON u2.id = o.brigadier_id
+                LEFT JOIN order_statuses os ON os.id = o.order_status_id
+            SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS orders_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW orders_view AS
+            SELECT o.*, u.name as user_name,
+                d.shortname as district_name,
+                a.name as area_name,
+                ot.name as object_type_name,
+                u2.name as brigadier_name,
+                os.name as order_status_name
+            FROM orders o
+                LEFT JOIN users u ON u.id = o.user_id
+                LEFT JOIN districts d ON d.id = o.district_id
+                LEFT JOIN areas as a ON a.id = o.area_id
+                LEFT JOIN object_types ot ON ot.id = o.object_type_id
+                LEFT JOIN users u2 ON u2.id = o.brigadier_id
+                LEFT JOIN order_statuses os ON os.id = o.order_status_id
+            SQL);
+    }
+};

+ 27 - 0
database/migrations/2026_04_11_130000_create_responsibles_view.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS responsibles_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW responsibles_view AS
+            SELECT
+                r.*,
+                a.id AS area_id,
+                a.name AS area_name
+            FROM responsibles r
+                LEFT JOIN areas a ON a.responsible_id = r.id AND a.deleted_at IS NULL
+            SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS responsibles_view');
+    }
+};

+ 78 - 0
database/migrations/2026_04_11_131000_normalize_empty_fields_in_mafs_view.php

@@ -0,0 +1,78 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS mafs_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW mafs_view AS
+            SELECT
+                ps.id,
+                ps.year,
+                ps.product_id,
+                ps.order_id,
+                ps.maf_order_id,
+                ps.status,
+                NULLIF(TRIM(ps.rfid), '') AS rfid,
+                NULLIF(TRIM(ps.factory_number), '') AS factory_number,
+                ps.manufacture_date,
+                NULLIF(TRIM(ps.statement_number), '') AS statement_number,
+                ps.statement_date,
+                NULLIF(TRIM(ps.upd_number), '') AS upd_number,
+                ps.comment,
+                ps.passport_id,
+                ps.created_at,
+                ps.updated_at,
+                ps.deleted_at,
+                ov.district_name,
+                ov.area_name,
+                ov.user_name,
+                ov.object_address,
+                mo.order_number,
+                p.article,
+                p.nomenclature_number,
+                p.name_tz,
+                p.type_tz,
+                p.`type`,
+                p.manufacturer_name,
+                f.original_name AS passport_name
+            FROM products_sku ps
+                LEFT JOIN orders_view ov ON ov.id = ps.order_id
+                LEFT JOIN maf_orders mo ON ps.maf_order_id = mo.id
+                LEFT JOIN products p ON p.id = ps.product_id
+                LEFT JOIN files f ON f.id = ps.passport_id
+            SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP VIEW IF EXISTS mafs_view');
+
+        DB::statement(<<<'SQL'
+            CREATE VIEW mafs_view AS
+            SELECT ps.*,
+                ov.district_name,
+                ov.area_name,
+                ov.user_name,
+                ov.object_address,
+                mo.order_number,
+                p.article,
+                p.nomenclature_number,
+                p.name_tz,
+                p.type_tz,
+                p.`type`,
+                p.manufacturer_name,
+                f.original_name as passport_name
+            FROM products_sku ps
+                LEFT JOIN orders_view ov ON ov.id = ps.order_id
+                LEFT JOIN maf_orders mo ON ps.maf_order_id = mo.id
+                LEFT JOIN products p ON p.id = ps.product_id
+                LEFT JOIN files f ON f.id = ps.passport_id
+            SQL);
+    }
+};

+ 1 - 1
resources/views/orders/edit.blade.php

@@ -32,7 +32,7 @@
 
                 @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasRole('admin')])
 
-                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin')])
+                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin,manager')])
 
                 @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasRole('admin')])
 

+ 7 - 1
resources/views/orders/index.blade.php

@@ -31,7 +31,13 @@
         'routeName' => 'order.show',
     ])
 
-    @include('partials.pagination', ['items' => $orders])
+    @include('partials.pagination', [
+        'items' => $orders,
+        'summary' => [
+            'Адресов' => $filtered_orders_count ?? 0,
+            'МАФ' => $filtered_products_total ?? 0,
+        ],
+    ])
 
     @if(hasRole('admin,manager'))
         <div class="modal fade" id="exportOrdersModal" tabindex="-1" aria-labelledby="exportOrdersModalLabel" aria-hidden="true">

+ 5 - 3
resources/views/orders/show.blade.php

@@ -59,12 +59,14 @@
 
                 @include('partials.input',  ['name' => 'name', 'title' => 'Название', 'value' => $order->name ?? old('name'), 'required' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
                 @include('partials.input',  ['name' => 'object_address', 'title' => 'Адрес объекта', 'value' => $order->object_address ?? old('object_address'), 'required' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
+                @include('partials.input',  ['name' => 'district_name', 'title' => 'Округ', 'value' => $order->district?->name, 'disabled' => true])
+                @include('partials.input',  ['name' => 'area_name', 'title' => 'Район', 'value' => $order->area?->name, 'disabled' => true])
                 @include('partials.select', ['name' => 'object_type_id', 'title' => 'Тип объекта', 'options' => $objectTypes, 'value' => $order->object_type_id ?? old('object_type_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
                 @include('partials.select', ['name' => 'order_status_id', 'title' => 'Статус', 'options' => $orderStatuses, 'value' => $order->order_status_id ?? old('order_status_id'), 'required' => true, 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
                 @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'value' => $order->comment ?? old('comment'), 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
                 @include('partials.input', ['name' => 'installation_date', 'title' => 'Дата выхода на монтаж', 'type' => 'date', 'value' => $order->installation_date ?? old('installation_date'), 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
                 @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
+                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
                 @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
                 @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $order->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
                 @include('partials.input', ['name' => 'tg_group_name', 'title' => 'Название группы в ТГ', 'value' => $order->tg_group_name ?? old('tg_group_name'), 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
@@ -187,7 +189,7 @@
                           class="visually-hidden">
                         @csrf
                         <input required type="file" id="upl-photo" onchange="$(this).parent().submit()" multiple
-                               name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png">
+                               name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                     </form>
                     @if(hasRole('admin'))
                         <button class="btn btn-sm text-danger" onclick="customConfirm('Удалить все фотографии?', function () { $('#delete-all-photos').submit(); }, 'Подтверждение удаления'); return false;"><i
@@ -449,7 +451,7 @@
             // select order
             $('#search_order').on('keyup', function () {
                 // search products on backend
-                $.get('{{ route('order.search') }}?s=' + $(this).val(),
+                $.get('{{ route('order.search') }}?s=' + $(this).val() + '&current_order_id={{ $order->id }}',
                     function (data) {
                         $('#select_order').children().remove()
                         $.each(data, function (id, name) {

+ 12 - 0
resources/views/partials/pagination.blade.php

@@ -15,6 +15,18 @@
     </div>
 </div>
 
+@if(isset($summary) && is_array($summary))
+    <div class="row px-3 pb-2">
+        <div class="col-12">
+            <div class="small text-muted">
+                @foreach($summary as $label => $value)
+                    <span class="me-3">{{ $label }}: {{ $value }}</span>
+                @endforeach
+            </div>
+        </div>
+    </div>
+@endif
+
 
 @push('scripts')
     <script type="module">

+ 82 - 12
resources/views/reclamations/edit.blade.php

@@ -134,7 +134,7 @@
                                     <div class="col-12 col-md-6 position-relative">
                                         <input type="hidden" name="rows[{{ $idx }}][spare_part_id]" value="{{ $sp->id }}" class="spare-part-id">
                                         <input type="text" class="form-control form-control-sm spare-part-search"
-                                               value="{{ $sp->article }}@if($sp->used_in_maf) ({{ $sp->used_in_maf }})@endif"
+                                               value="{{ $sp->article }}@if($sp->note) ({{ $sp->note }})@endif"
                                                placeholder="Введите артикул или название"
                                                autocomplete="off"
                                                @disabled(!hasRole('admin,manager'))>
@@ -266,6 +266,19 @@
                                         </thead>
                                         <tbody>
                                             @foreach($reservations->where('status', 'active') as $reservation)
+                                                @php
+                                                    $issueCandidateOrders = \App\Models\SparePartOrder::query()
+                                                        ->where('spare_part_id', $reservation->spare_part_id)
+                                                        ->where('with_documents', $reservation->with_documents)
+                                                        ->where(function ($query) use ($reservation) {
+                                                            $query->where(function ($inner) {
+                                                                $inner->where('status', \App\Models\SparePartOrder::STATUS_IN_STOCK)
+                                                                    ->where('available_qty', '>', 0);
+                                                            })->orWhere('id', $reservation->spare_part_order_id);
+                                                        })
+                                                        ->orderBy('created_at')
+                                                        ->get();
+                                                @endphp
                                                 <tr>
                                                     <td>
                                                         @if($reservation->sparePart)
@@ -303,12 +316,69 @@
                                                     </td>
                                                     @if(hasRole('admin,manager'))
                                                         <td class="text-end">
-                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST" class="d-inline">
-                                                                @csrf
-                                                                <button type="submit" class="btn btn-sm btn-success" title="Списать">
+                                                            @if($issueCandidateOrders->count() > 1)
+                                                                <button type="button"
+                                                                        class="btn btn-sm btn-success"
+                                                                        title="Выбрать заказ для списания"
+                                                                        data-bs-toggle="modal"
+                                                                        data-bs-target="#issueReservationModal-{{ $reservation->id }}">
                                                                     <i class="bi bi-check-lg"></i>
                                                                 </button>
-                                                            </form>
+
+                                                                <div class="modal fade" id="issueReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="issueReservationModalLabel-{{ $reservation->id }}" aria-hidden="true">
+                                                                    <div class="modal-dialog modal-dialog-scrollable">
+                                                                        <div class="modal-content">
+                                                                            <div class="modal-header">
+                                                                                <h1 class="modal-title fs-5" id="issueReservationModalLabel-{{ $reservation->id }}">Выбор заказа для списания</h1>
+                                                                                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                                                                            </div>
+                                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST">
+                                                                                @csrf
+                                                                                <div class="modal-body">
+                                                                                    <div class="mb-2 small text-muted">
+                                                                                        {{ $reservation->sparePart?->article ?? 'Запчасть' }}, {{ $reservation->reserved_qty }} шт.
+                                                                                    </div>
+                                                                                    @foreach($issueCandidateOrders as $candidateOrder)
+                                                                                        @php
+                                                                                            $activeReservedForCandidate = \App\Models\Reservation::query()
+                                                                                                ->where('spare_part_order_id', $candidateOrder->id)
+                                                                                                ->where('status', \App\Models\Reservation::STATUS_ACTIVE)
+                                                                                                ->sum('reserved_qty');
+                                                                                            $freeCandidateQty = max(0, (int) $candidateOrder->available_qty - (int) $activeReservedForCandidate);
+                                                                                            $isCurrentCandidate = (int) $candidateOrder->id === (int) $reservation->spare_part_order_id;
+                                                                                        @endphp
+                                                                                        <label class="form-check mb-2">
+                                                                                            <input class="form-check-input"
+                                                                                                   type="radio"
+                                                                                                   name="selected_order_id"
+                                                                                                   value="{{ $candidateOrder->id }}"
+                                                                                                   @checked($isCurrentCandidate)>
+                                                                                            <span class="form-check-label">
+                                                                                                {{ $candidateOrder->display_order_number }}
+                                                                                                @if($isCurrentCandidate)
+                                                                                                    <span class="text-muted">, текущий резерв</span>
+                                                                                                @endif
+                                                                                                <span class="text-muted">, доступно {{ $freeCandidateQty }}</span>
+                                                                                            </span>
+                                                                                        </label>
+                                                                                    @endforeach
+                                                                                </div>
+                                                                                <div class="modal-footer">
+                                                                                    <button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
+                                                                                    <button type="submit" class="btn btn-success btn-sm">Списать</button>
+                                                                                </div>
+                                                                            </form>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            @else
+                                                                <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST" class="d-inline">
+                                                                    @csrf
+                                                                    <button type="submit" class="btn btn-sm btn-success" title="Списать">
+                                                                        <i class="bi bi-check-lg"></i>
+                                                                    </button>
+                                                                </form>
+                                                            @endif
                                                             <form action="{{ route('spare_part_reservations.cancel', $reservation) }}" method="POST" class="d-inline">
                                                                 @csrf
                                                                 <button type="submit" class="btn btn-sm btn-outline-warning" title="Отменить резерв">
@@ -533,7 +603,7 @@
                               enctype="multipart/form-data" method="post" class="visually-hidden">
                             @csrf
                             <input required type="file" id="upl-photo-before" onchange="$(this).parent().submit()" multiple
-                                   name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png">
+                                   name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                         </form>
                     @endif
                     <div class="row my-2 g-1 collapse" id="photos_before">
@@ -578,7 +648,7 @@
                               enctype="multipart/form-data" method="post" class="visually-hidden">
                             @csrf
                             <input required type="file" id="upl-photo-after" onchange="$(this).parent().submit()" multiple
-                                   name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png">
+                                   name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                         </form>
                     @endif
                     <div class="row my-2 g-1 collapse" id="photos_after">
@@ -699,13 +769,13 @@
                 items.forEach(function(item) {
                     const $item = $('<div class="sp-item"></div>');
                     const highlightedArticle = highlightMatch(item.article, query);
-                    const highlightedUsed = item.used_in_maf ? highlightMatch(item.used_in_maf, query) : '';
+                    const highlightedNote = item.note ? highlightMatch(item.note, query) : '';
 
                     $item.html('<span class="sp-article">' + highlightedArticle + '</span>' +
-                               (item.used_in_maf ? ' <span class="sp-used">(' + highlightedUsed + ')</span>' : ''));
+                               (item.note ? ' <span class="sp-used">(' + highlightedNote + ')</span>' : ''));
                     $item.data('id', item.id);
                     $item.data('article', item.article);
-                    $item.data('used', item.used_in_maf || '');
+                    $item.data('note', item.note || '');
 
                     $item.on('click', function() {
                         selectItem($(this));
@@ -729,8 +799,8 @@
 
             function selectItem($item) {
                 const article = $item.data('article');
-                const used = $item.data('used');
-                const displayText = article + (used ? ' (' + used + ')' : '');
+                const note = $item.data('note');
+                const displayText = article + (note ? ' (' + note + ')' : '');
 
                 $input.val(displayText);
                 $hiddenId.val($item.data('id'));

+ 12 - 6
resources/views/spare_parts/edit.blade.php

@@ -259,18 +259,24 @@
                                         <td class="text-center">{{ $spare_part->reserved_with_docs }}</td>
                                         <td class="text-center fw-bold">{{ $spare_part->total_reserved }}</td>
                                     </tr>
-                                    <tr class="table-success">
-                                        <td>Свободный остаток</td>
-                                        <td class="text-center">{{ $spare_part->free_stock_without_docs }}</td>
-                                        <td class="text-center">{{ $spare_part->free_stock_with_docs }}</td>
-                                        <td class="text-center fw-bold">{{ $spare_part->total_free_stock }}</td>
-                                    </tr>
                                     @if($spare_part->min_stock > 0)
                                         <tr class="@if($spare_part->total_free_stock < $spare_part->min_stock) table-danger @endif">
                                             <td>Минимальный остаток</td>
                                             <td colspan="3" class="text-center">{{ $spare_part->min_stock }}</td>
                                         </tr>
                                     @endif
+                                    <tr class="table-info">
+                                        <td>Заказано</td>
+                                        <td class="text-center">{{ $spare_part->ordered_without_docs }}</td>
+                                        <td class="text-center">{{ $spare_part->ordered_with_docs }}</td>
+                                        <td class="text-center fw-bold">{{ $spare_part->total_ordered }}</td>
+                                    </tr>
+                                    <tr class="table-success">
+                                        <td>Свободный остаток</td>
+                                        <td class="text-center">{{ $spare_part->free_stock_without_docs }}</td>
+                                        <td class="text-center">{{ $spare_part->free_stock_with_docs }}</td>
+                                        <td class="text-center fw-bold">{{ $spare_part->total_free_stock }}</td>
+                                    </tr>
                                 </tbody>
                             </table>
                         </div>

+ 76 - 0
tests/Feature/ChatMessageControllerTest.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\ChatMessage;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ChatMessageControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_brigadier_can_send_order_chat_message_without_manual_recipient_selection(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $order = Order::factory()->create([
+            'user_id' => $manager->id,
+            'brigadier_id' => $brigadier->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+        ]);
+
+        $response = $this->actingAs($brigadier)
+            ->post(route('order.chat-messages.store', $order), [
+                'message' => 'Сообщение бригадира по площадке',
+                'notification_type' => ChatMessage::NOTIFICATION_RESPONSIBLES,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $message = ChatMessage::query()->where('order_id', $order->id)->first();
+        $this->assertNotNull($message);
+        $this->assertSame(ChatMessage::NOTIFICATION_RESPONSIBLES, $message->notification_type);
+        $recipientIds = $message->notifiedUsers->pluck('id')->all();
+        $this->assertContains($admin->id, $recipientIds);
+        $this->assertContains($manager->id, $recipientIds);
+        $this->assertNotContains($brigadier->id, $recipientIds);
+    }
+
+    public function test_brigadier_can_send_reclamation_chat_message_without_manual_recipient_selection(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $reclamation = Reclamation::factory()->create([
+            'user_id' => $manager->id,
+            'brigadier_id' => $brigadier->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+        ]);
+
+        $response = $this->actingAs($brigadier)
+            ->post(route('reclamations.chat-messages.store', $reclamation), [
+                'message' => 'Сообщение бригадира по рекламации',
+                'notification_type' => ChatMessage::NOTIFICATION_RESPONSIBLES,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $message = ChatMessage::query()->where('reclamation_id', $reclamation->id)->first();
+        $this->assertNotNull($message);
+        $this->assertSame(ChatMessage::NOTIFICATION_RESPONSIBLES, $message->notification_type);
+        $recipientIds = $message->notifiedUsers->pluck('id')->all();
+        $this->assertContains($admin->id, $recipientIds);
+        $this->assertContains($manager->id, $recipientIds);
+        $this->assertNotContains($brigadier->id, $recipientIds);
+    }
+}

+ 180 - 12
tests/Feature/OrderControllerTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Feature;
 
+use App\Jobs\GenerateInstallationPack;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\File;
@@ -15,6 +16,7 @@ use App\Models\Role;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
 use Illuminate\Support\Facades\Storage;
 use Tests\TestCase;
 
@@ -70,6 +72,61 @@ class OrderControllerTest extends TestCase
         $response->assertSee($order->object_address);
     }
 
+    public function test_orders_index_displays_products_total_column_value(): void
+    {
+        $order = Order::factory()->create([
+            'object_address' => 'ул. Колоночная, д. 10',
+        ]);
+        $product = Product::factory()->create();
+
+        ProductSKU::factory()->count(3)->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('order.index'));
+
+        $response->assertOk();
+        $response->assertSee('Всего МАФ');
+        $response->assertSee('ул. Колоночная, д. 10');
+        $response->assertViewHas('orders', static function ($orders) use ($order) {
+            return (int) $orders->firstWhere('id', $order->id)?->products_total === 3;
+        });
+    }
+
+    public function test_orders_index_displays_filtered_footer_stats(): void
+    {
+        $district = District::factory()->create(['shortname' => 'ЦАО']);
+        $area = Area::factory()->create(['district_id' => $district->id, 'name' => 'Тверской']);
+        $matchingOrder = Order::factory()->create([
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+            'object_address' => 'ул. Итоговая, д. 1',
+        ]);
+        $otherOrder = Order::factory()->create([
+            'object_address' => 'ул. Неучтенная, д. 2',
+        ]);
+        $product = Product::factory()->create();
+
+        ProductSKU::factory()->count(2)->create([
+            'order_id' => $matchingOrder->id,
+            'product_id' => $product->id,
+        ]);
+        ProductSKU::factory()->count(5)->create([
+            'order_id' => $otherOrder->id,
+            'product_id' => $product->id,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('order.index', ['s' => 'Итоговая']));
+
+        $response->assertOk();
+        $response->assertSee('Адресов: 1', false);
+        $response->assertSee('МАФ: 2', false);
+        $response->assertDontSee('МАФ: 7', false);
+    }
+
     public function test_brigadier_sees_only_assigned_orders(): void
     {
         $assignedOrder = Order::factory()->create([
@@ -194,6 +251,25 @@ class OrderControllerTest extends TestCase
         ]);
     }
 
+    public function test_manager_can_update_ready_date(): void
+    {
+        $order = Order::factory()->create([
+            'ready_date' => '2026-04-01',
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('order.store'), [
+                'id' => $order->id,
+                'ready_date' => '2026-04-25',
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('orders', [
+            'id' => $order->id,
+            'ready_date' => '2026-04-25',
+        ]);
+    }
+
     // ==================== Show ====================
 
     public function test_can_view_order_details(): void
@@ -210,6 +286,26 @@ class OrderControllerTest extends TestCase
         $response->assertSee($order->object_address);
     }
 
+    public function test_order_details_show_area_and_district(): void
+    {
+        $district = District::factory()->create(['name' => 'Центральный округ']);
+        $area = Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Тверской район',
+        ]);
+        $order = Order::factory()->create([
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('order.show', $order));
+
+        $response->assertOk();
+        $response->assertSee('Центральный округ');
+        $response->assertSee('Тверской район');
+    }
+
     public function test_brigadier_cannot_view_handed_over_order_details(): void
     {
         $order = Order::factory()->create([
@@ -236,6 +332,20 @@ class OrderControllerTest extends TestCase
         $response->assertViewIs('orders.edit');
     }
 
+    public function test_manager_can_edit_ready_date_in_order_edit_form(): void
+    {
+        $order = Order::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('order.edit', $order));
+
+        $response->assertOk();
+        $this->assertMatchesRegularExpression(
+            '/<input[^>]*name="ready_date"(?:(?!disabled).)*>/s',
+            $response->getContent()
+        );
+    }
+
     // ==================== Destroy ====================
 
     public function test_can_delete_order(): void
@@ -307,8 +417,39 @@ class OrderControllerTest extends TestCase
             ->getJson(route('order.search', ['s' => 'Менеджер Поиска']));
 
         $response->assertOk();
-        $response->assertJsonPath((string) $matchedOrder->id, $matchedOrder->common_name);
-        $response->assertJsonMissing([$otherOrder->id => $otherOrder->common_name]);
+        $response->assertJsonPath((string) $matchedOrder->id, $matchedOrder->move_maf_name);
+        $response->assertJsonMissing([$otherOrder->id => $otherOrder->move_maf_name]);
+    }
+
+    public function test_order_search_route_includes_site_name_and_excludes_current_order(): void
+    {
+        $district = District::factory()->create(['name' => 'Северный округ']);
+        $area = Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Левобережный',
+        ]);
+        $currentOrder = Order::factory()->create([
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+            'name' => 'Текущая площадка',
+            'object_address' => 'ул. Проверочная, д. 1',
+        ]);
+        $targetOrder = Order::factory()->create([
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+            'name' => 'Площадка назначения',
+            'object_address' => 'ул. Проверочная, д. 2',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('order.search', [
+                's' => 'Проверочная',
+                'current_order_id' => $currentOrder->id,
+            ]));
+
+        $response->assertOk();
+        $response->assertJsonPath((string) $targetOrder->id, $targetOrder->move_maf_name);
+        $response->assertJsonMissing([(string) $currentOrder->id => $currentOrder->move_maf_name]);
     }
 
     // ==================== MAF Operations ====================
@@ -673,42 +814,69 @@ class OrderControllerTest extends TestCase
         $this->assertCount(5, $order->fresh()->documents);
     }
 
+    public function test_can_upload_webp_photo(): void
+    {
+        Storage::fake('public');
+
+        $order = Order::factory()->create();
+        $photo = UploadedFile::fake()->create('photo.webp', 100, 'image/webp');
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('order.upload-photo', $order), [
+                'photo' => [$photo],
+            ]);
+
+        $response->assertRedirect();
+        $saved = $order->fresh()->photos->first();
+        $this->assertNotNull($saved);
+        $this->assertSame('photo.webp', $saved->original_name);
+    }
+
     // ==================== Generation ====================
 
-    public function test_generate_installation_pack_requires_correct_status(): void
+    public function test_generate_installation_pack_is_allowed_for_any_status_when_data_is_valid(): void
     {
+        Bus::fake();
+
+        $product = Product::factory()->create();
+        $mafOrder = MafOrder::factory()->create(['product_id' => $product->id]);
         $order = Order::factory()->create([
             'order_status_id' => Order::STATUS_NEW,
         ]);
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
 
         $response = $this->actingAs($this->managerUser)
             ->get(route('order.generate-installation-pack', $order));
 
         $response->assertRedirect(route('order.show', $order));
-        $response->assertSessionHas('danger');
+        $response->assertSessionHas('success');
+        Bus::assertDispatched(GenerateInstallationPack::class);
     }
 
-    public function test_generate_installation_pack_succeeds_with_correct_status(): void
+    public function test_generate_installation_pack_still_requires_connected_maf(): void
     {
-        $product = Product::factory()->create();
-        $mafOrder = MafOrder::factory()->create(['product_id' => $product->id]);
+        Bus::fake();
 
+        $product = Product::factory()->create();
         $order = Order::factory()->create([
-            'order_status_id' => Order::STATUS_READY_TO_MOUNT,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
         ]);
-
-        // Create SKU with MAF assigned
         ProductSKU::factory()->create([
             'order_id' => $order->id,
             'product_id' => $product->id,
-            'maf_order_id' => $mafOrder->id,
+            'maf_order_id' => null,
         ]);
 
         $response = $this->actingAs($this->managerUser)
             ->get(route('order.generate-installation-pack', $order));
 
         $response->assertRedirect(route('order.show', $order));
-        $response->assertSessionHas('success');
+        $response->assertSessionHas('danger');
+        Bus::assertNotDispatched(GenerateInstallationPack::class);
     }
 
     // ==================== Export ====================

+ 91 - 0
tests/Feature/ProductSKUControllerTest.php

@@ -96,6 +96,97 @@ class ProductSKUControllerTest extends TestCase
         $response->assertViewIs('products_sku.index');
     }
 
+    public function test_maf_filter_options_include_empty_marker_for_factory_number(): void
+    {
+        ProductSKU::factory()->create(['factory_number' => null]);
+        ProductSKU::factory()->create(['factory_number' => '   ']);
+        ProductSKU::factory()->create(['factory_number' => 'FN-123456']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('getFilters', [
+                'table' => 'product_sku',
+                'column' => 'factory_number',
+            ]));
+
+        $response->assertOk();
+        $response->assertJsonFragment(['-пусто-']);
+        $response->assertJsonFragment(['FN-123456']);
+    }
+
+    public function test_maf_empty_filter_matches_null_blank_and_null_date_values(): void
+    {
+        $orderWithNullFactory = Order::factory()->create(['object_address' => 'ул. Пустая фабрика']);
+        $orderWithBlankFactory = Order::factory()->create(['object_address' => 'ул. Пробелы фабрика']);
+        $orderWithNullDate = Order::factory()->create(['object_address' => 'ул. Пустая дата']);
+        $orderWithBlankStatement = Order::factory()->create(['object_address' => 'ул. Пустая ведомость']);
+        $orderWithFilledValue = Order::factory()->create(['object_address' => 'ул. Заполненная']);
+        $product = Product::factory()->create();
+
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithNullFactory->id,
+            'product_id' => $product->id,
+            'factory_number' => null,
+            'manufacture_date' => '2026-04-01',
+            'statement_number' => 'STAT-1',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithBlankFactory->id,
+            'product_id' => $product->id,
+            'factory_number' => '   ',
+            'manufacture_date' => '2026-04-02',
+            'statement_number' => 'STAT-2',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithNullDate->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-000001',
+            'manufacture_date' => null,
+            'statement_number' => 'STAT-3',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithBlankStatement->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-000002',
+            'manufacture_date' => '2026-04-03',
+            'statement_number' => '   ',
+        ]);
+        ProductSKU::factory()->create([
+            'order_id' => $orderWithFilledValue->id,
+            'product_id' => $product->id,
+            'factory_number' => 'FN-999999',
+            'manufacture_date' => '2026-04-04',
+            'statement_number' => 'STAT-9',
+        ]);
+
+        $factoryResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['factory_number' => '-пусто-'],
+            ]));
+        $factoryResponse->assertOk();
+        $factoryAddresses = $factoryResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая фабрика', $factoryAddresses);
+        $this->assertContains('ул. Пробелы фабрика', $factoryAddresses);
+        $this->assertNotContains('ул. Заполненная', $factoryAddresses);
+
+        $dateResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['manufacture_date' => '-пусто-'],
+            ]));
+        $dateResponse->assertOk();
+        $dateAddresses = $dateResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая дата', $dateAddresses);
+        $this->assertNotContains('ул. Заполненная', $dateAddresses);
+
+        $statementResponse = $this->actingAs($this->adminUser)
+            ->get(route('product_sku.index', [
+                'filters' => ['statement_number' => '-пусто-'],
+            ]));
+        $statementResponse->assertOk();
+        $statementAddresses = $statementResponse->viewData('products_sku')->pluck('object_address')->all();
+        $this->assertContains('ул. Пустая ведомость', $statementAddresses);
+        $this->assertNotContains('ул. Заполненная', $statementAddresses);
+    }
+
     // ==================== Show ====================
 
     public function test_admin_can_view_product_sku(): void

+ 60 - 0
tests/Feature/ReclamationControllerTest.php

@@ -156,6 +156,30 @@ class ReclamationControllerTest extends TestCase
         $response->assertViewIs('reclamations.edit');
     }
 
+    public function test_reclamation_details_show_spare_part_note_in_input_instead_of_used_in_maf(): void
+    {
+        $sparePart = \App\Models\SparePart::factory()->create([
+            'article' => 'SP-100',
+            'used_in_maf' => 'Старое значение',
+            'note' => 'Показать это примечание',
+        ]);
+        $reclamation = Reclamation::factory()->create();
+        $reclamation->spareParts()->attach($sparePart->id, [
+            'quantity' => 1,
+            'with_documents' => false,
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('reclamations.show', $reclamation));
+
+        $response->assertOk();
+        $response->assertSee('SP-100 (Показать это примечание)');
+        $response->assertDontSee('SP-100 (Старое значение)');
+    }
+
     public function test_brigadier_cannot_view_reclamation_details_with_hidden_status(): void
     {
         $reclamation = Reclamation::factory()->create([
@@ -230,6 +254,24 @@ class ReclamationControllerTest extends TestCase
         $this->assertCount(1, $reclamation->fresh()->photos_before);
     }
 
+    public function test_can_upload_photo_before_in_webp_format(): void
+    {
+        Storage::fake('public');
+
+        $reclamation = Reclamation::factory()->create();
+        $photo = UploadedFile::fake()->create('photo_before.webp', 100, 'image/webp');
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('reclamations.upload-photo-before', $reclamation), [
+                'photo' => [$photo],
+            ]);
+
+        $response->assertRedirect();
+        $saved = $reclamation->fresh()->photos_before->first();
+        $this->assertNotNull($saved);
+        $this->assertSame('photo_before.webp', $saved->original_name);
+    }
+
     public function test_upload_photo_before_preserves_unicode_and_quotes_filename(): void
     {
         Storage::fake('public');
@@ -286,6 +328,24 @@ class ReclamationControllerTest extends TestCase
         $this->assertCount(1, $reclamation->fresh()->photos_after);
     }
 
+    public function test_can_upload_photo_after_in_webp_format(): void
+    {
+        Storage::fake('public');
+
+        $reclamation = Reclamation::factory()->create();
+        $photo = UploadedFile::fake()->create('photo_after.webp', 100, 'image/webp');
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('reclamations.upload-photo-after', $reclamation), [
+                'photo' => [$photo],
+            ]);
+
+        $response->assertRedirect();
+        $saved = $reclamation->fresh()->photos_after->first();
+        $this->assertNotNull($saved);
+        $this->assertSame('photo_after.webp', $saved->original_name);
+    }
+
     public function test_can_delete_photo_after(): void
     {
         Storage::fake('public');

+ 86 - 0
tests/Feature/ResponsibleControllerTest.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Responsible;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ResponsibleControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+    }
+
+    public function test_responsibles_index_filters_by_area_name(): void
+    {
+        $district = District::factory()->create();
+        $matchingResponsible = Responsible::query()->create([
+            'name' => 'Иван Иванов',
+            'phone' => '+79990000001',
+            'post' => 'Куратор',
+        ]);
+        $otherResponsible = Responsible::query()->create([
+            'name' => 'Петр Петров',
+            'phone' => '+79990000002',
+            'post' => 'Куратор',
+        ]);
+
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Тверской',
+            'responsible_id' => $matchingResponsible->id,
+        ]);
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Арбат',
+            'responsible_id' => $otherResponsible->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('responsible.index', [
+                'filters' => ['area_name' => 'Тверской'],
+            ]));
+
+        $response->assertOk();
+        $response->assertSee('Иван Иванов');
+        $response->assertDontSee('Петр Петров');
+    }
+
+    public function test_responsible_area_filter_values_are_loaded_from_view(): void
+    {
+        $district = District::factory()->create();
+        $responsible = Responsible::query()->create([
+            'name' => 'Иван Иванов',
+            'phone' => '+79990000001',
+            'post' => 'Куратор',
+        ]);
+        Area::factory()->create([
+            'district_id' => $district->id,
+            'name' => 'Тверской',
+            'responsible_id' => $responsible->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('getFilters', [
+                'table' => 'responsibles',
+                'column' => 'area_name',
+            ]));
+
+        $response->assertOk();
+        $response->assertJsonFragment(['Тверской']);
+    }
+}

+ 43 - 2
tests/Feature/SparePartControllerTest.php

@@ -260,13 +260,54 @@ class SparePartControllerTest extends TestCase
 
     public function test_admin_can_search_spare_parts(): void
     {
-        SparePart::factory()->create(['article' => 'SP-SEARCH-001']);
+        SparePart::factory()->create([
+            'article' => 'SP-SEARCH-001',
+            'note' => 'Комментарий поиска',
+        ]);
 
         $response = $this->actingAs($this->adminUser)
             ->getJson(route('spare_parts.search', ['query' => 'SP-SEARCH']));
 
         $response->assertStatus(200);
-        $response->assertJsonStructure([['id', 'article', 'used_in_maf']]);
+        $response->assertJsonStructure([['id', 'article', 'note']]);
+        $response->assertJsonFragment([
+            'article' => 'SP-SEARCH-001',
+            'note' => 'Комментарий поиска',
+        ]);
+    }
+
+    public function test_spare_part_show_displays_ordered_row_separately_from_stock(): void
+    {
+        $sparePart = SparePart::factory()->create(['min_stock' => 5]);
+        \App\Models\SparePartOrder::factory()->forSparePart($sparePart)->ordered()->withDocuments(false)->withQuantity(4)->create();
+        \App\Models\SparePartOrder::factory()->forSparePart($sparePart)->ordered()->withDocuments(true)->withQuantity(6)->create();
+        \App\Models\SparePartOrder::factory()->forSparePart($sparePart)->inStock()->withDocuments(false)->withQuantity(10)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_parts.show', $sparePart));
+
+        $response->assertOk();
+        $response->assertSee('Заказано');
+        $response->assertSee('>4<', false);
+        $response->assertSee('>6<', false);
+        $response->assertSee('>10<', false);
+    }
+
+    public function test_spare_part_search_can_find_by_note(): void
+    {
+        SparePart::factory()->create([
+            'article' => 'SP-NOTE-001',
+            'note' => 'Особая отметка',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('spare_parts.search', ['query' => 'Особая отметка']));
+
+        $response->assertOk();
+        $response->assertJsonFragment([
+            'article' => 'SP-NOTE-001',
+            'note' => 'Особая отметка',
+        ]);
     }
 
     public function test_guest_cannot_search_spare_parts(): void

+ 57 - 0
tests/Feature/SparePartReservationControllerTest.php

@@ -313,6 +313,63 @@ class SparePartReservationControllerTest extends TestCase
         $response->assertSessionHas('success');
     }
 
+    public function test_admin_can_issue_reservation_from_selected_order(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 10,
+            'available_qty' => 10,
+            'with_documents' => false,
+        ]);
+        $selectedOrder = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 7,
+            'available_qty' => 7,
+            'with_documents' => false,
+        ]);
+        $reclamation = Reclamation::factory()->create();
+
+        \App\Models\ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 4,
+            'with_documents' => false,
+            'status' => 'reserved',
+            'reserved_qty' => 4,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $oldOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 4,
+            'with_documents' => false,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.issue', $reservation), [
+                'selected_order_id' => $selectedOrder->id,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $selectedOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 4,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $selectedOrder->id,
+            'available_qty' => 3,
+        ]);
+    }
+
     public function test_cannot_issue_inactive_reservation(): void
     {
         $reservation = Reservation::factory()->cancelled()->create();

+ 7 - 1
tests/TestCase.php

@@ -2,9 +2,15 @@
 
 namespace Tests;
 
+use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
 abstract class TestCase extends BaseTestCase
 {
-    //
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->withoutMiddleware(ValidateCsrfToken::class);
+    }
 }

+ 72 - 0
tests/Unit/Services/Export/ExportScheduleServiceTest.php

@@ -7,8 +7,10 @@ use App\Models\Dictionary\District;
 use App\Models\Schedule;
 use App\Models\User;
 use App\Services\ExportScheduleService;
+use Illuminate\Database\Eloquent\Factories\Sequence;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Collection;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use Tests\TestCase;
 
 class ExportScheduleServiceTest extends TestCase
@@ -144,4 +146,74 @@ class ExportScheduleServiceTest extends TestCase
             $this->assertTrue(true);
         }
     }
+
+    public function test_build_spreadsheet_sets_pdf_layout_for_multiple_pages(): void
+    {
+        if (!file_exists('./templates/Schedule.xlsx')) {
+            $this->markTestSkipped('Excel template Schedule.xlsx not found');
+        }
+
+        $brigadier = User::factory()->brigadier()->create([
+            'color' => '#123456',
+        ]);
+
+        $district = District::query()->inRandomOrder()->first()
+            ?? District::factory()->create();
+
+        $area = Area::query()->inRandomOrder()->first()
+            ?? Area::factory()->create(['district_id' => $district->id]);
+
+        $schedules = Schedule::factory()
+            ->count(30)
+            ->sequence(fn (Sequence $sequence) => [
+                'brigadier_id' => $brigadier->id,
+                'district_id' => $district->id,
+                'area_id' => $area->id,
+                'installation_date' => now()->addDays($sequence->index)->format('Y-m-d'),
+            ])
+            ->create()
+            ->load(['district', 'area', 'brigadier']);
+
+        $spreadsheet = $this->service->buildSpreadsheet(Collection::make($schedules->all()));
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $this->assertSame('A1:K32', $sheet->getPageSetup()->getPrintArea());
+        $this->assertSame(1, $sheet->getPageSetup()->getFitToWidth());
+        $this->assertSame(0, $sheet->getPageSetup()->getFitToHeight());
+        $this->assertSame(['A27' => Worksheet::BREAK_ROW], $sheet->getBreaks());
+    }
+
+    public function test_build_spreadsheet_keeps_small_schedule_on_single_page(): void
+    {
+        if (!file_exists('./templates/Schedule.xlsx')) {
+            $this->markTestSkipped('Excel template Schedule.xlsx not found');
+        }
+
+        $brigadier = User::factory()->brigadier()->create([
+            'color' => '#654321',
+        ]);
+
+        $district = District::query()->inRandomOrder()->first()
+            ?? District::factory()->create();
+
+        $area = Area::query()->inRandomOrder()->first()
+            ?? Area::factory()->create(['district_id' => $district->id]);
+
+        $schedules = Schedule::factory()
+            ->count(2)
+            ->sequence(fn (Sequence $sequence) => [
+                'brigadier_id' => $brigadier->id,
+                'district_id' => $district->id,
+                'area_id' => $area->id,
+                'installation_date' => now()->addDays($sequence->index)->format('Y-m-d'),
+            ])
+            ->create()
+            ->load(['district', 'area', 'brigadier']);
+
+        $spreadsheet = $this->service->buildSpreadsheet(Collection::make($schedules->all()));
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $this->assertSame('A1:K4', $sheet->getPageSetup()->getPrintArea());
+        $this->assertSame([], $sheet->getBreaks());
+    }
 }

+ 106 - 0
tests/Unit/Services/SparePartIssueServiceTest.php

@@ -367,6 +367,112 @@ class SparePartIssueServiceTest extends TestCase
         $this->assertEquals(3, $results[0]->issued);
     }
 
+    public function test_issue_reservation_from_selected_order_cancels_old_reservation_and_issues_from_new_order(): void
+    {
+        $user = User::factory()->create();
+        $this->actingAs($user);
+
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->withDocuments(false)->withQuantity(10)->forSparePart($sparePart)->create();
+        $newOrder = SparePartOrder::factory()->inStock()->withDocuments(false)->withQuantity(8)->forSparePart($sparePart)->create();
+
+        \App\Models\ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 5,
+            'with_documents' => false,
+            'status' => 'reserved',
+            'reserved_qty' => 5,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(false)
+            ->fromOrder($oldOrder)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $result = $this->service->issueReservationFromSelectedOrder($reservation, $newOrder->id);
+
+        $this->assertEquals(5, $result->issued);
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $newOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $newOrder->id,
+            'available_qty' => 3,
+        ]);
+    }
+
+    public function test_issue_reservation_from_selected_order_splits_row_when_selected_order_has_partial_quantity(): void
+    {
+        $user = User::factory()->create();
+        $this->actingAs($user);
+
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->withDocuments(true)->withQuantity(10)->forSparePart($sparePart)->create();
+        $newOrder = SparePartOrder::factory()->inStock()->withDocuments(true)->withQuantity(2)->forSparePart($sparePart)->create([
+            'available_qty' => 2,
+        ]);
+
+        \App\Models\ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 5,
+            'with_documents' => true,
+            'status' => 'reserved',
+            'reserved_qty' => 5,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(true)
+            ->fromOrder($oldOrder)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $result = $this->service->issueReservationFromSelectedOrder($reservation, $newOrder->id);
+
+        $this->assertEquals(2, $result->issued);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 2,
+            'with_documents' => true,
+            'status' => 'issued',
+            'issued_qty' => 2,
+        ]);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 3,
+            'with_documents' => true,
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+        $this->assertDatabaseHas('shortages', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'missing_qty' => 3,
+            'with_documents' => true,
+            'status' => \App\Models\Shortage::STATUS_OPEN,
+        ]);
+    }
+
     // ==================== directIssue ====================
 
     public function test_direct_issue_decreases_available_qty(): void