Browse Source

implement production order XLS import

Alexander Musikhin 17 hours ago
parent
commit
40406bfce7

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

@@ -9,7 +9,9 @@ use App\Enums\ProductionOrderNotificationEvent;
 use App\Enums\ProductionOrderStatus;
 use App\Http\Requests\SaveProductionOrderRequest;
 use App\Jobs\Export\ExportProductionOrdersJob;
+use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
+use App\Models\Import;
 use App\Models\ProductionOrder;
 use App\Models\Role;
 use App\Models\User;
@@ -21,7 +23,10 @@ use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 
 class ProductionOrderController extends Controller
 {
@@ -206,6 +211,32 @@ class ProductionOrderController extends Controller
             ->with('success', 'Задача экспорта графика заказов создана.');
     }
 
+    public function import(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
+        ]);
+        /** @var UploadedFile $uploadedFile */
+        $uploadedFile = $validated['import_file'];
+        $path = 'production_orders/'.Str::uuid().'.xlsx';
+        Storage::disk('upload')->put($path, $uploadedFile->getContent());
+
+        $import = Import::query()->create([
+            'type' => 'production_orders',
+            'year' => null,
+            'filename' => $path,
+            'status' => 'new',
+            'user_id' => $request->user()->getKey(),
+            'file_path' => $path,
+            'original_filename' => $uploadedFile->getClientOriginalName(),
+        ]);
+        ImportJob::dispatch($import, (int) $request->user()->getKey());
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Задача импорта графика заказов создана.');
+    }
+
     public function show(
         Request $request,
         ProductionOrder $productionOrder,

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

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

+ 1 - 0
app/Models/Import.php

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

+ 69 - 2
app/Services/Export/ExportProductionOrdersService.php

@@ -17,6 +17,10 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class ExportProductionOrdersService
 {
+    public const ORDERS_SHEET = 'График заказов';
+
+    public const ITEMS_SHEET = 'МАФ';
+
     public const HEADERS = [
         'ID',
         'Номер заказа',
@@ -38,26 +42,47 @@ class ExportProductionOrdersService
         'Примечание',
     ];
 
+    public const ITEM_HEADERS = [
+        'Строка заказа',
+        'ID МАФ',
+        'ID позиции каталога',
+        'Артикул',
+        'Наименование',
+        'Номер заказа МАФ',
+        'Заводской номер',
+        'Дата производства',
+    ];
+
     /** @param list<int> $orderIds */
     public function handle(array $orderIds, int $userId): File
     {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
-        $sheet->setTitle('График заказов');
+        $sheet->setTitle(self::ORDERS_SHEET);
         $sheet->fromArray(self::HEADERS, null, 'A1');
         $sheet->getStyle('A1:R1')->getFont()->setBold(true);
         $sheet->getStyle('A1:R1')->getFill()
             ->setFillType(Fill::FILL_SOLID)
             ->getStartColor()->setARGB('FFD9EAF7');
 
+        $itemsSheet = $spreadsheet->createSheet();
+        $itemsSheet->setTitle(self::ITEMS_SHEET);
+        $itemsSheet->fromArray(self::ITEM_HEADERS, null, 'A1');
+        $itemsSheet->getStyle('A1:H1')->getFont()->setBold(true);
+        $itemsSheet->getStyle('A1:H1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('FFE2F0D9');
+
         $row = 2;
+        $itemRow = 2;
         ProductionOrder::query()
             ->with(['manager', 'items.catalogItem'])
             ->whereKey($orderIds)
             ->orderByDesc('created_at')
             ->orderByDesc('id')
-            ->chunk(200, function ($orders) use ($sheet, &$row): void {
+            ->chunk(200, function ($orders) use ($sheet, $itemsSheet, &$row, &$itemRow): void {
                 foreach ($orders as $order) {
+                    $orderRow = $row;
                     $sheet->setCellValue("A{$row}", $order->id);
                     $sheet->setCellValueExplicit("B{$row}", $order->order_number, DataType::TYPE_STRING);
                     $sheet->setCellValue("C{$row}", $order->order_year);
@@ -76,6 +101,37 @@ class ExportProductionOrdersService
                     $sheet->setCellValue("P{$row}", $order->manager?->name);
                     $sheet->setCellValue("Q{$row}", $this->equipmentSummary($order));
                     $sheet->setCellValue("R{$row}", $order->note);
+
+                    foreach ($order->items as $item) {
+                        $itemsSheet->setCellValue("A{$itemRow}", $orderRow);
+                        $itemsSheet->setCellValue("B{$itemRow}", $item->id);
+                        $itemsSheet->setCellValue("C{$itemRow}", $item->common_catalog_item_id);
+                        $itemsSheet->setCellValueExplicit(
+                            "D{$itemRow}",
+                            (string) $item->catalogItem?->article,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValue(
+                            "E{$itemRow}",
+                            $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
+                        );
+                        $itemsSheet->setCellValueExplicit(
+                            "F{$itemRow}",
+                            $item->order_item_number,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValueExplicit(
+                            "G{$itemRow}",
+                            (string) $item->factory_number,
+                            DataType::TYPE_STRING,
+                        );
+                        $itemsSheet->setCellValue(
+                            "H{$itemRow}",
+                            $item->manufacture_date?->format('d.m.Y'),
+                        );
+                        $itemRow++;
+                    }
+
                     $row++;
                 }
             });
@@ -89,6 +145,17 @@ class ExportProductionOrdersService
             $sheet->getColumnDimension($column)->setWidth(in_array($column, ['D', 'E', 'Q', 'R'], true) ? 36 : 18);
         }
 
+        $lastItemRow = max(1, $itemRow - 1);
+        $itemsSheet->getStyle("A1:H{$lastItemRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $itemsSheet->getStyle("A1:H{$lastItemRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        $itemsSheet->freezePane('A2');
+        $itemsSheet->setAutoFilter("A1:H{$lastItemRow}");
+        foreach (range('A', 'H') as $column) {
+            $itemsSheet->getColumnDimension($column)->setWidth(in_array($column, ['D', 'E', 'F'], true) ? 30 : 18);
+        }
+
+        $spreadsheet->setActiveSheetIndex(0);
+
         $filename = 'График_заказов_'.now()->format('Y-m-d_H-i-s').'.xlsx';
         $path = 'generated/production-orders/'.$userId.'/'.Str::uuid().'/'.$filename;
         Storage::disk('local')->makeDirectory(dirname($path));

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

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

+ 7 - 4
app/Services/ProductionOrderService.php

@@ -18,13 +18,16 @@ class ProductionOrderService
     ) {}
 
     /** @param array<string, mixed> $data */
-    public function create(array $data, User $actor): ProductionOrder
-    {
-        return DB::transaction(function () use ($data, $actor): ProductionOrder {
+    public function create(
+        array $data,
+        User $actor,
+        ProductionOrderSource $source = ProductionOrderSource::Manual,
+    ): ProductionOrder {
+        return DB::transaction(function () use ($data, $actor, $source): ProductionOrder {
             $items = Arr::pull($data, 'items', []);
             unset($data['nav']);
 
-            $data['source'] = ProductionOrderSource::Manual;
+            $data['source'] = $source;
             $data['contract_shipment_date'] = $this->contractShipmentDate($data);
             $data['created_by'] = $actor->id;
             $data['updated_by'] = $actor->id;

+ 1 - 0
config/access_routes.php

@@ -98,6 +98,7 @@ return [
             'store' => 'schedule-orders.create',
             'catalog-search' => 'schedule-orders.items.manage',
             'export' => 'schedule-orders.export',
+            'import' => 'schedule-orders.import',
             'show' => 'schedule-orders.view',
             'update' => 'schedule-orders.update',
             'destroy' => 'schedule-orders.delete',

+ 4 - 4
docs/refactor/plan.md

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

+ 1 - 1
docs/refactor/test-order-schedule.md

@@ -25,7 +25,7 @@
 - уведомления и журнал действий;
 - создание рекламации типа `Прочее` по выбранным МАФ.
 
-В эту проверку **не входят** импорт XLS, обмен с 1С и перенос истории из старого Manager. Формат XLS уже согласован, но сам импорт ещё не реализован; для 1С и переноса истории остаются отдельные интеграционные вопросы.
+В эту ручную проверку **не входят** импорт XLS, обмен с 1С и перенос истории из старого Manager. XLS-импорт уже реализован и покрыт отдельными автоматическими тестами; для 1С и переноса истории остаются интеграционные вопросы.
 
 ## 2. Что подготовить
 

+ 1 - 1
docs/refactor/tz-order-schedule-answers.md

@@ -182,4 +182,4 @@
 3. Полный payload и контракт API.
 4. Правила повторной синхронизации, конфликтов, аннулирования и частичного изменения состава заказа.
 
-Требования к ручному XLS-импорту согласованы. Открытые вопросы выше блокируют только интеграцию с 1С.
+Ручной XLS-импорт реализован по согласованным правилам. Открытые вопросы выше блокируют только интеграцию с 1С.

+ 1 - 1
docs/refactor/tz-order-schedule.md

@@ -273,6 +273,6 @@
 3. Реализовать справочники, права и базовую карточку с ручным созданием заказа.
 4. Добавить документы, фотографии, чат, паспорт МАФ и техническую документацию.
 5. Реализовать доставки, монтажи, уведомления, аудит и заявку на доставку.
-6. Реализовать экспорт с фильтрами и ручной XLS-импорт по согласованным правилам.
+6. Реализовать экспорт с фильтрами и ручной XLS-импорт по согласованным правилам — реализовано.
 7. Реализовать рекламации типа `Прочее` по выбранным экземплярам МАФ.
 8. После контакта со специалистом 1С спроектировать и реализовать идемпотентный API обмена с журналом загрузок.

+ 3 - 2
docs/refactor/tz-schedules.md

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

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

@@ -6,6 +6,15 @@
             <h3>График заказов</h3>
         </div>
         <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if(hasPermission('schedule-orders.import'))
+                <button type="button"
+                        class="btn btn-sm btn-outline-primary page-action-btn"
+                        data-bs-toggle="modal"
+                        data-bs-target="#productionOrderImportModal">
+                    <i class="bi bi-file-earmark-arrow-up page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Импорт</span>
+                </button>
+            @endif
             @if(hasPermission('schedule-orders.export'))
                 <form action="{{ route('schedule.orders.export') }}" method="POST" class="d-inline">
                     @csrf
@@ -41,4 +50,42 @@
     ])
 
     @include('partials.pagination', ['items' => $orders])
+
+    @if(hasPermission('schedule-orders.import'))
+        <div class="modal fade"
+             id="productionOrderImportModal"
+             tabindex="-1"
+             aria-labelledby="productionOrderImportModalLabel"
+             aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h2 class="modal-title fs-5" id="productionOrderImportModalLabel">Импорт графика заказов</h2>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <form action="{{ route('schedule.orders.import') }}" method="POST" enctype="multipart/form-data">
+                        @csrf
+                        <div class="modal-body">
+                            <p class="text-muted small">
+                                Используйте XLSX, полученный экспортом графика заказов. Заказы находятся на первом листе,
+                                отдельные экземпляры МАФ — на листе «МАФ». Состав МАФ в файле полностью заменяет
+                                текущий состав импортируемых заказов.
+                            </p>
+                            <label for="production-order-import-file" class="form-label">XLSX-файл</label>
+                            <input type="file"
+                                   id="production-order-import-file"
+                                   name="import_file"
+                                   class="form-control"
+                                   accept=".xlsx"
+                                   required>
+                        </div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                            <button type="submit" class="btn btn-primary">Импортировать</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    @endif
 @endsection

+ 2 - 1
routes/web.php

@@ -18,7 +18,6 @@ use App\Http\Controllers\MafOrderController;
 use App\Http\Controllers\OrderController;
 use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
-use App\Http\Controllers\ProductSKUController;
 use App\Http\Controllers\ProductionOrderChatMessageController;
 use App\Http\Controllers\ProductionOrderController;
 use App\Http\Controllers\ProductionOrderDeliveryController;
@@ -26,6 +25,7 @@ use App\Http\Controllers\ProductionOrderDocumentController;
 use App\Http\Controllers\ProductionOrderFileController;
 use App\Http\Controllers\ProductionOrderInstallationController;
 use App\Http\Controllers\ProductionOrderReclamationController;
+use App\Http\Controllers\ProductSKUController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
 use App\Http\Controllers\ResponsibleController;
@@ -188,6 +188,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::get('orders/catalog-search', [ProductionOrderController::class, 'searchCatalogItems'])
             ->name('orders.catalog-search');
         Route::post('orders/export', [ProductionOrderController::class, 'export'])->name('orders.export');
+        Route::post('orders/import', [ProductionOrderController::class, 'import'])->name('orders.import');
         Route::post('orders', [ProductionOrderController::class, 'store'])->name('orders.store');
         Route::get('orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('orders.show');
         Route::put('orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('orders.update');

+ 47 - 1
tests/Feature/ProductionOrderControllerTest.php

@@ -7,16 +7,20 @@ namespace Tests\Feature;
 use App\Enums\ProductionOrderExecutionType;
 use App\Enums\ProductionOrderStatus;
 use App\Jobs\Export\ExportProductionOrdersJob;
+use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
+use App\Models\Import;
 use App\Models\ProductionCalendarDay;
 use App\Models\ProductionOrder;
 use App\Models\ProductionOrderDelivery;
-use App\Models\ProductionOrderItem;
 use App\Models\ProductionOrderInstallation;
+use App\Models\ProductionOrderItem;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
 use Tests\TestCase;
 
 class ProductionOrderControllerTest extends TestCase
@@ -451,6 +455,48 @@ class ProductionOrderControllerTest extends TestCase
         Bus::assertNotDispatched(ExportProductionOrdersJob::class);
     }
 
+    public function test_admin_can_queue_production_order_import(): void
+    {
+        Bus::fake([ImportJob::class]);
+        Storage::fake('upload');
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.import'), [
+                'import_file' => UploadedFile::fake()->create(
+                    'production-orders.xlsx',
+                    20,
+                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ),
+            ])
+            ->assertRedirect(route('schedule.orders'))
+            ->assertSessionHas('success');
+
+        $import = Import::query()->where('type', 'production_orders')->firstOrFail();
+        $this->assertSame($this->admin->id, $import->user_id);
+        $this->assertSame('production-orders.xlsx', $import->original_filename);
+        Storage::disk('upload')->assertExists($import->filename);
+        Bus::assertDispatched(ImportJob::class);
+    }
+
+    public function test_production_order_import_requires_import_permission(): void
+    {
+        Bus::fake([ImportJob::class]);
+        Storage::fake('upload');
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.import'), [
+                'import_file' => UploadedFile::fake()->create(
+                    'production-orders.xlsx',
+                    20,
+                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ),
+            ])
+            ->assertForbidden();
+
+        $this->assertDatabaseMissing('imports', ['type' => 'production_orders']);
+        Bus::assertNotDispatched(ImportJob::class);
+    }
+
     public function test_admin_can_search_common_catalog_when_adding_order_items(): void
     {
         $matchingByName = CommonCatalogItem::factory()->create([

+ 22 - 2
tests/Unit/Services/Export/ExportProductionOrdersServiceTest.php

@@ -70,7 +70,9 @@ class ExportProductionOrdersServiceTest extends TestCase
         $this->assertSame($user->id, $file->user_id);
         $this->assertStringContainsString('/schedules/orders/files/', $file->link);
 
-        $sheet = IOFactory::load(Storage::disk('local')->path($file->path))->getActiveSheet();
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
+        $this->assertNotNull($sheet);
         $this->assertSame(
             ExportProductionOrdersService::HEADERS,
             $sheet->rangeToArray('A1:R1')[0],
@@ -91,6 +93,19 @@ class ExportProductionOrdersServiceTest extends TestCase
         $this->assertSame('Примечание к заказу', $sheet->getCell('R2')->getValue());
         $this->assertNull($sheet->getCell('A3')->getValue());
         $this->assertNotSame($excluded->id, $sheet->getCell('A2')->getValue());
+
+        $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
+        $this->assertNotNull($itemsSheet);
+        $this->assertSame(
+            ExportProductionOrdersService::ITEM_HEADERS,
+            $itemsSheet->rangeToArray('A1:H1')[0],
+        );
+        $this->assertSame(2, $itemsSheet->getCell('A2')->getValue());
+        $this->assertSame($playground->id, $itemsSheet->getCell('C2')->getValue());
+        $this->assertSame('0007', $itemsSheet->getCell('D2')->getValue());
+        $this->assertSame('Игровой комплекс', $itemsSheet->getCell('E2')->getValue());
+        $this->assertSame(2, $itemsSheet->getCell('A3')->getValue());
+        $this->assertSame(2, $itemsSheet->getCell('A4')->getValue());
     }
 
     public function test_export_can_create_an_empty_workbook(): void
@@ -101,8 +116,13 @@ class ExportProductionOrdersServiceTest extends TestCase
         $file = app(ExportProductionOrdersService::class)->handle([], $user->id);
 
         Storage::disk('local')->assertExists($file->path);
-        $sheet = IOFactory::load(Storage::disk('local')->path($file->path))->getActiveSheet();
+        $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path));
+        $sheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
+        $this->assertNotNull($sheet);
         $this->assertSame(ExportProductionOrdersService::HEADERS, $sheet->rangeToArray('A1:R1')[0]);
         $this->assertNull($sheet->getCell('A2')->getValue());
+        $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
+        $this->assertNotNull($itemsSheet);
+        $this->assertSame(ExportProductionOrdersService::ITEM_HEADERS, $itemsSheet->rangeToArray('A1:H1')[0]);
     }
 }

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

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