Przeglądaj źródła

added technical descriptions catalog transfer

Alexander Musikhin 3 tygodni temu
rodzic
commit
bdfe703293

+ 73 - 1
app/Services/Export/ExportCommonCatalogService.php

@@ -19,6 +19,18 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class ExportCommonCatalogService
 {
+    public const TECHNICAL_DESCRIPTIONS_SHEET = 'Техописания';
+
+    public const TECHNICAL_DESCRIPTION_HEADERS = [
+        'Артикул',
+        'Наименование',
+        'Наименование для формы',
+        'Группа продукции',
+        'Характеристики',
+        'Техническое описание',
+        'Краткое техническое описание',
+    ];
+
     public const HEADERS = [
         'Артикул',
         'Наименование',
@@ -94,13 +106,18 @@ class ExportCommonCatalogService
         $sheet->fromArray(self::SUBHEADERS, null, 'A2');
         $this->mergeHeaders($sheet);
         $this->styleHeaders($sheet);
+        $technicalDescriptionsSheet = $spreadsheet->createSheet();
+        $technicalDescriptionsSheet->setTitle(self::TECHNICAL_DESCRIPTIONS_SHEET);
+        $technicalDescriptionsSheet->fromArray(self::TECHNICAL_DESCRIPTION_HEADERS, null, 'A1');
+        $this->styleTechnicalDescriptionsSheet($technicalDescriptionsSheet);
 
         $row = 3;
+        $technicalDescriptionRow = 2;
         CommonCatalogItem::query()
             ->with('imageFile')
             ->orderBy('article')
             ->orderBy('calculator_name')
-            ->chunk(200, function ($items) use ($sheet, &$row): void {
+            ->chunk(200, function ($items) use ($sheet, $technicalDescriptionsSheet, &$row, &$technicalDescriptionRow): void {
                 foreach ($items as $item) {
                     $sheet->setCellValueExplicit("A{$row}", $item->article, DataType::TYPE_STRING);
                     $sheet->setCellValue("B{$row}", $item->calculator_name);
@@ -137,7 +154,9 @@ class ExportCommonCatalogService
                     $sheet->setCellValue("AD{$row}", $item->recommended_plus_10_price);
 
                     $this->addImage($sheet, $item, $row);
+                    $this->addTechnicalDescription($technicalDescriptionsSheet, $item, $technicalDescriptionRow);
                     $row++;
+                    $technicalDescriptionRow++;
                 }
             });
 
@@ -156,6 +175,18 @@ class ExportCommonCatalogService
         }
 
         $this->setColumnWidths($sheet);
+        if ($technicalDescriptionRow > 2) {
+            $lastTechnicalDescriptionRow = $technicalDescriptionRow - 1;
+            $technicalDescriptionsSheet->getStyle("A1:G{$lastTechnicalDescriptionRow}")
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN);
+            $technicalDescriptionsSheet->getStyle("A2:G{$lastTechnicalDescriptionRow}")
+                ->getAlignment()
+                ->setVertical(Alignment::VERTICAL_TOP)
+                ->setWrapText(true);
+        }
+        $spreadsheet->setActiveSheetIndex(0);
 
         $directory = 'export/common_catalog';
         $filename = 'common_catalog_'.now()->format('Y-m-d_H-i-s').'.xlsx';
@@ -256,4 +287,45 @@ class ExportCommonCatalogService
         $drawing->setWorksheet($sheet);
         $sheet->getRowDimension($row)->setRowHeight(56);
     }
+
+    private function addTechnicalDescription(Worksheet $sheet, CommonCatalogItem $item, int $row): void
+    {
+        $sheet->setCellValueExplicit("A{$row}", $item->article, DataType::TYPE_STRING);
+        $sheet->setCellValue("B{$row}", $item->calculator_name);
+        $sheet->setCellValue("C{$row}", $item->print_name);
+        $sheet->setCellValue("D{$row}", $item->product_group);
+        $sheet->setCellValue("E{$row}", $item->characteristics);
+        $sheet->setCellValue("F{$row}", $item->technical_description);
+        $sheet->setCellValue("G{$row}", $item->technical_description_short);
+    }
+
+    private function styleTechnicalDescriptionsSheet(Worksheet $sheet): void
+    {
+        $sheet->getStyle('A1:G1')->applyFromArray([
+            'font' => ['bold' => true],
+            'fill' => [
+                'fillType' => Fill::FILL_SOLID,
+                'startColor' => ['rgb' => 'D9EAF7'],
+            ],
+            'alignment' => [
+                'horizontal' => Alignment::HORIZONTAL_CENTER,
+                'vertical' => Alignment::VERTICAL_CENTER,
+                'wrapText' => true,
+            ],
+        ]);
+        $sheet->getRowDimension(1)->setRowHeight(36);
+        $sheet->freezePane('A2');
+
+        foreach ([
+            'A' => 14,
+            'B' => 38,
+            'C' => 38,
+            'D' => 28,
+            'E' => 55,
+            'F' => 70,
+            'G' => 55,
+        ] as $column => $width) {
+            $sheet->getColumnDimension($column)->setWidth($width);
+        }
+    }
 }

+ 104 - 1
app/Services/Import/ImportCommonCatalogService.php

@@ -125,7 +125,16 @@ class ImportCommonCatalogService
                 }
             }
 
-            $this->import->log("Создано: {$created}; обновлено: {$updated}; ошибок: {$errors}.");
+            [$technicalDescriptionsUpdated, $technicalDescriptionsErrors] = $this->importTechnicalDescriptions(
+                $spreadsheet,
+                $user,
+                $access,
+            );
+            $errors += $technicalDescriptionsErrors;
+
+            $this->import->log(
+                "Создано: {$created}; обновлено: {$updated}; техописаний обновлено: {$technicalDescriptionsUpdated}; ошибок: {$errors}.",
+            );
             $this->import->status = 'DONE';
             $this->import->save();
             $spreadsheet->disconnectWorksheets();
@@ -152,6 +161,89 @@ class ImportCommonCatalogService
         }
     }
 
+    private function importTechnicalDescriptions(
+        \PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet,
+        User $user,
+        AccessService $access,
+    ): array {
+        $sheet = $spreadsheet->getSheetByName(ExportCommonCatalogService::TECHNICAL_DESCRIPTIONS_SHEET);
+        if ($sheet === null) {
+            return [0, 0];
+        }
+
+        $headers = $sheet->rangeToArray('A1:G1', null, true, true, false)[0];
+        $actualHeaders = array_map($this->normalizeHeader(...), $headers);
+        $expectedHeaders = array_map(
+            $this->normalizeHeader(...),
+            ExportCommonCatalogService::TECHNICAL_DESCRIPTION_HEADERS,
+        );
+        if ($actualHeaders !== $expectedHeaders) {
+            throw new RuntimeException('Некорректные заголовки листа «Техописания». Используйте экспорт общего каталога.');
+        }
+
+        $updated = 0;
+        $errors = 0;
+        for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
+            $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
+            if ($article === null) {
+                continue;
+            }
+
+            $calculatorName = $this->stringValue($sheet->getCell("B{$row}")->getValue());
+            $item = $this->findTechnicalDescriptionItem($article, $calculatorName);
+            if ($item === null) {
+                $errors++;
+                $this->import->log(
+                    "Лист «Техописания», строка {$row}: позиция {$article} не найдена или неоднозначна.",
+                    'WARNING',
+                );
+
+                continue;
+            }
+
+            $data = $access->filterWritableData($user, 'common-catalog', [
+                'print_name' => $this->stringValue($sheet->getCell("C{$row}")->getValue()),
+                'product_group' => $this->stringValue($sheet->getCell("D{$row}")->getValue()),
+                'characteristics' => $this->stringValue($sheet->getCell("E{$row}")->getValue()),
+                'technical_description' => $this->stringValue($sheet->getCell("F{$row}")->getValue()),
+                'technical_description_short' => $this->stringValue($sheet->getCell("G{$row}")->getValue()),
+            ]);
+            $validator = Validator::make($data, $this->technicalDescriptionRules());
+            if ($validator->fails()) {
+                $errors++;
+                $this->import->log(
+                    "Лист «Техописания», строка {$row}: ".implode(' ', $validator->errors()->all()),
+                    'WARNING',
+                );
+
+                continue;
+            }
+
+            $item->update($validator->validated());
+            $updated++;
+        }
+
+        return [$updated, $errors];
+    }
+
+    private function findTechnicalDescriptionItem(string $article, ?string $calculatorName): ?CommonCatalogItem
+    {
+        $exact = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->where('calculator_name', $calculatorName)
+            ->first();
+        if ($exact) {
+            return $exact;
+        }
+
+        $items = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->limit(2)
+            ->get();
+
+        return $items->count() === 1 ? $items->first() : null;
+    }
+
     private function articleCounts(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): array
     {
         $counts = [];
@@ -351,4 +443,15 @@ class ImportCommonCatalogService
             'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
         ];
     }
+
+    private function technicalDescriptionRules(): array
+    {
+        return [
+            'print_name' => ['nullable', 'string', 'max:255'],
+            'product_group' => ['nullable', 'string', 'max:255'],
+            'characteristics' => ['nullable', 'string'],
+            'technical_description' => ['nullable', 'string'],
+            'technical_description_short' => ['nullable', 'string'],
+        ];
+    }
 }

+ 3 - 2
docs/refactor/plan.md

@@ -80,7 +80,7 @@
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
-| 4. Общий каталог | Расширение реализовано, ожидает пользовательской проверки | Импорт, экспорт и карточка переведены на фактический 30-колоночный формат `Каталог общий.xlsx`; добавлены восемь цен и ограничения доступа. |
+| 4. Общий каталог | Реализован и проверен | Создание, редактирование, импорт и экспорт фактического формата `Каталог общий.xlsx` приняты по результатам пользовательской проверки; добавлены восемь цен и ограничения доступа. |
 | 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
 | 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
@@ -90,7 +90,7 @@
 
 ## Этап 1. Реорганизация меню и заглушки
 
-Статус: **ядро проверено пользователем; расширение под актуальный каталог реализовано и ожидает пользовательской проверки**.
+Статус: **реализован и проверен пользователем**.
 
 - [x] Проверить текущие permissions для всех существующих пунктов меню.
 - [x] Для новых пунктов-заглушек открыть доступ всем авторизованным пользователям.
@@ -221,6 +221,7 @@
 - [x] Перенести и адаптировать оба DOCX-шаблона готового Laravel-модуля.
 - [x] Подставлять в DOCX выбранный администратором вид цены из `Каталог общий`, не сохраняя отдельную копию в данных техописания.
 - [x] Реализовать общий DOCX и отдельные DOCX в ZIP через очередь и приватное хранение файлов.
+- [x] Добавить перенос текстов техописаний отдельным листом общего XLSX; изображения переносятся существующим столбцом общего каталога.
 - [x] Покрыть перенос данных, настройку цены, права и варианты экспорта автоматическими тестами.
 
 ## Этап 8. Калькуляции

+ 2 - 0
docs/refactor/tz-technical-description.md

@@ -26,6 +26,8 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 В источнике находятся 938 позиций из 24 серий. Для всех позиций заполнены цена, характеристики, полное и краткое техническое описание, группа, название для формы и изображение. Наличие одиночной цены в старом модуле зафиксировано как факт анализа; в новой CRM цены хранятся у позиции общего каталога по восьми отдельным видам.
 
+Для переноса между окружениями экспорт общего каталога содержит необязательный лист `Техописания` с текстовыми полями. Импорт понимает как системный экспорт с этим листом, так и исходный 30-колоночный файл без него. Изображение техописания является изображением позиции общего каталога и переносится существующим столбцом `Внешний вид`.
+
 ## 3. Место в меню
 
 Отдельный пункт меню `Технич. описание` не нужен. Целевая модель:

+ 17 - 0
tests/Unit/Services/Export/ExportCommonCatalogServiceTest.php

@@ -42,6 +42,11 @@ class ExportCommonCatalogServiceTest extends TestCase
             'calculator_enabled' => true,
             'builders_price' => 1418500,
             'recommended_plus_10_price' => 1888023.50,
+            'print_name' => 'Теневой навес для детского сада',
+            'product_group' => 'Теневые навесы',
+            'characteristics' => 'Размер 9 × 3 м',
+            'technical_description' => 'Полное техническое описание',
+            'technical_description_short' => 'Краткое техническое описание',
         ]);
 
         (new ExportCommonCatalogService)->handle($user->id);
@@ -59,5 +64,17 @@ class ExportCommonCatalogServiceTest extends TestCase
         $this->assertSame(1418500.0, $sheet->getCell('W3')->getValue());
         $this->assertSame(1888023.5, $sheet->getCell('AD3')->getValue());
         $this->assertCount(1, $sheet->getDrawingCollection());
+
+        $technicalDescriptionsSheet = $sheet->getParent()->getSheetByName(
+            ExportCommonCatalogService::TECHNICAL_DESCRIPTIONS_SHEET,
+        );
+        $this->assertNotNull($technicalDescriptionsSheet);
+        $this->assertSame(
+            ExportCommonCatalogService::TECHNICAL_DESCRIPTION_HEADERS,
+            $technicalDescriptionsSheet->rangeToArray('A1:G1')[0],
+        );
+        $this->assertSame('0254', $technicalDescriptionsSheet->getCell('A2')->getValue());
+        $this->assertSame('Теневой навес для детского сада', $technicalDescriptionsSheet->getCell('C2')->getValue());
+        $this->assertSame('Полное техническое описание', $technicalDescriptionsSheet->getCell('F2')->getValue());
     }
 }

+ 36 - 1
tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php

@@ -12,8 +12,8 @@ use App\Services\Import\ImportCommonCatalogService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\Storage;
-use PhpOffice\PhpSpreadsheet\Cell\DataType;
 use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@@ -123,6 +123,26 @@ class ImportCommonCatalogServiceTest extends TestCase
         ]);
     }
 
+    public function test_import_restores_technical_descriptions_from_optional_sheet(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $import = $this->createImport($user, 'Теневой навес', withTechnicalDescriptions: true);
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Теневой навес',
+            'print_name' => 'Теневой навес для детского сада',
+            'product_group' => 'Теневые навесы',
+            'characteristics' => 'Размер 9 × 3 м',
+            'technical_description' => 'Полное техническое описание',
+            'technical_description_short' => 'Краткое техническое описание',
+        ]);
+        $this->assertStringContainsString('техописаний обновлено: 1', (string) $import->refresh()->result);
+    }
+
     public function test_import_rejects_wrong_headers_with_clear_error(): void
     {
         Storage::fake('upload');
@@ -145,6 +165,7 @@ class ImportCommonCatalogServiceTest extends TestCase
         string $dimensionHeader = "Единица измерения\nгабаритов",
         bool $withImage = false,
         bool $duplicateVariant = false,
+        bool $withTechnicalDescriptions = false,
     ): Import {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
@@ -198,6 +219,20 @@ class ImportCommonCatalogServiceTest extends TestCase
             $drawing->setWorksheet($sheet);
         }
 
+        if ($withTechnicalDescriptions) {
+            $technicalDescriptionsSheet = $spreadsheet->createSheet();
+            $technicalDescriptionsSheet->setTitle(ExportCommonCatalogService::TECHNICAL_DESCRIPTIONS_SHEET);
+            $technicalDescriptionsSheet->fromArray(ExportCommonCatalogService::TECHNICAL_DESCRIPTION_HEADERS, null, 'A1');
+            $technicalDescriptionsSheet->setCellValueExplicit('A2', '0254', DataType::TYPE_STRING);
+            $technicalDescriptionsSheet->setCellValue('B2', $calculatorName);
+            $technicalDescriptionsSheet->setCellValue('C2', 'Теневой навес для детского сада');
+            $technicalDescriptionsSheet->setCellValue('D2', 'Теневые навесы');
+            $technicalDescriptionsSheet->setCellValue('E2', 'Размер 9 × 3 м');
+            $technicalDescriptionsSheet->setCellValue('F2', 'Полное техническое описание');
+            $technicalDescriptionsSheet->setCellValue('G2', 'Краткое техническое описание');
+            $spreadsheet->setActiveSheetIndex(0);
+        }
+
         $filename = 'common_catalog_'.uniqid().'.xlsx';
         $path = Storage::disk('upload')->path($filename);
         (new Xlsx($spreadsheet))->save($path);