Forráskód Böngészése

implemented technical descriptions module

Alexander Musikhin 3 hete
szülő
commit
ca1bca221a
32 módosított fájl, 1532 hozzáadás és 46 törlés
  1. 10 2
      app/Console/Commands/CleanupGeneratedDocuments.php
  2. 90 0
      app/Console/Commands/ImportTechnicalDescriptions.php
  3. 13 0
      app/Http/Controllers/Admin/AdminSettingsController.php
  4. 73 0
      app/Http/Controllers/CommonCatalogController.php
  5. 5 0
      app/Http/Requests/StoreCommonCatalogItemRequest.php
  6. 61 0
      app/Jobs/Export/ExportTechnicalDescriptionsJob.php
  7. 24 0
      app/Models/CommonCatalogItem.php
  8. 15 0
      app/Models/Setting.php
  9. 180 0
      app/Services/Export/ExportTechnicalDescriptionsService.php
  10. 203 0
      app/Services/Import/ImportTechnicalDescriptionsService.php
  11. 65 0
      app/Services/TechnicalDescriptionService.php
  12. 3 2
      composer.json
  13. 163 2
      composer.lock
  14. 5 0
      config/access.php
  15. 2 0
      config/access_routes.php
  16. 5 0
      database/factories/CommonCatalogItemFactory.php
  17. 34 0
      database/migrations/2026_08_07_000001_add_technical_description_fields_to_common_catalog_items.php
  18. 22 0
      database/migrations/2026_08_07_000002_sync_technical_description_field_permissions.php
  19. 11 11
      docs/refactor/plan.md
  20. 24 13
      docs/refactor/tz-technical-description.md
  21. 11 0
      resources/views/admin/settings/index.blade.php
  22. 87 16
      resources/views/common_catalog/edit.blade.php
  23. 90 0
      resources/views/common_catalog/index.blade.php
  24. 14 0
      resources/views/partials/table.blade.php
  25. 4 0
      routes/web.php
  26. BIN
      templates/technical-descriptions/массовый.docx
  27. BIN
      templates/technical-descriptions/одиночный.docx
  28. 21 0
      tests/Feature/AdminSettingsControllerTest.php
  29. 25 0
      tests/Feature/CleanupGeneratedDocumentsCommandTest.php
  30. 71 0
      tests/Feature/CommonCatalogControllerTest.php
  31. 88 0
      tests/Unit/Services/Export/ExportTechnicalDescriptionsServiceTest.php
  32. 113 0
      tests/Unit/Services/Import/ImportTechnicalDescriptionsServiceTest.php

+ 10 - 2
app/Console/Commands/CleanupGeneratedDocuments.php

@@ -90,9 +90,10 @@ class CleanupGeneratedDocuments extends Command
                 DB::table('reclamation_document')->where('file_id', $file->id)->delete();
                 DB::table('chat_message_file')->where('file_id', $file->id)->delete();
 
+                $disk = $this->generatedFileDisk($file);
                 foreach ($paths as $path) {
-                    if (Storage::disk('public')->exists($path)) {
-                        Storage::disk('public')->delete($path);
+                    if (Storage::disk($disk)->exists($path)) {
+                        Storage::disk($disk)->delete($path);
                         $deletedFiles++;
                     }
                 }
@@ -162,6 +163,13 @@ class CleanupGeneratedDocuments extends Command
         return array_values(array_unique(array_filter($paths)));
     }
 
+    private function generatedFileDisk(File $file): string
+    {
+        return Str::startsWith((string) $file->path, 'generated/technical-descriptions/')
+            ? 'local'
+            : 'public';
+    }
+
     private function pathFromLink(string $link): ?string
     {
         $path = parse_url($link, PHP_URL_PATH);

+ 90 - 0
app/Console/Commands/ImportTechnicalDescriptions.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Console\Commands;
+
+use App\Models\User;
+use App\Services\Import\ImportTechnicalDescriptionsService;
+use Illuminate\Console\Command;
+use JsonException;
+
+class ImportTechnicalDescriptions extends Command
+{
+    protected $signature = 'technical-descriptions:import
+                            {manifest : JSON-файл с данными исходного модуля}
+                            {--images= : Каталог с исходными изображениями}
+                            {--user=1 : ID владельца импортированных файлов}
+                            {--overwrite : Перезаписывать уже заполненные поля}
+                            {--create-missing : Создавать отсутствующие позиции общего каталога}
+                            {--apply-to-variants : Применять данные ко всем позициям с повторяющимся артикулом}
+                            {--dry-run : Только показать результат без изменений}';
+
+    protected $description = 'Обогащает общий каталог техническими описаниями из старого модуля';
+
+    public function handle(ImportTechnicalDescriptionsService $service): int
+    {
+        $manifest = (string) $this->argument('manifest');
+        if (! is_file($manifest) || ! is_readable($manifest)) {
+            $this->error("JSON-файл {$manifest} не найден или недоступен для чтения.");
+            return self::FAILURE;
+        }
+
+        $userId = (int) $this->option('user');
+        if (! User::query()->whereKey($userId)->exists()) {
+            $this->error("Пользователь #{$userId} не найден.");
+            return self::FAILURE;
+        }
+
+        try {
+            $products = json_decode(
+                (string) file_get_contents($manifest),
+                true,
+                512,
+                JSON_THROW_ON_ERROR,
+            );
+        } catch (JsonException $exception) {
+            $this->error('Некорректный JSON: '.$exception->getMessage());
+            return self::FAILURE;
+        }
+
+        if (! is_array($products) || ! array_is_list($products)) {
+            $this->error('Корень JSON должен быть массивом позиций.');
+            return self::FAILURE;
+        }
+
+        $stats = $service->handle(
+            $products,
+            $this->option('images') ? (string) $this->option('images') : null,
+            $userId,
+            (bool) $this->option('overwrite'),
+            (bool) $this->option('create-missing'),
+            (bool) $this->option('apply-to-variants'),
+            (bool) $this->option('dry-run'),
+        );
+
+        $this->table(['Показатель', 'Количество'], [
+            ['Источник', $stats['source']],
+            ['Сопоставлено', $stats['matched']],
+            ['Обновлено', $stats['updated']],
+            ['Создано', $stats['created']],
+            ['Без изменений', $stats['unchanged']],
+            ['Нет в каталоге', $stats['missing']],
+            ['Неоднозначный артикул', $stats['ambiguous']],
+            ['Перенесено изображений', $stats['images_imported']],
+            ['Не найдено изображений', $stats['images_missing']],
+        ]);
+
+        if ($stats['missing_articles'] !== []) {
+            $this->warn('Нет в каталоге: '.implode(', ', $stats['missing_articles']));
+        }
+        if ($stats['ambiguous_articles'] !== []) {
+            $this->warn('Неоднозначные артикулы: '.implode(', ', $stats['ambiguous_articles']));
+        }
+        if ($this->option('dry-run')) {
+            $this->info('Dry-run завершён: данные не изменены.');
+        }
+
+        return self::SUCCESS;
+    }
+}

+ 13 - 0
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -3,10 +3,12 @@
 namespace App\Http\Controllers\Admin;
 
 use App\Http\Controllers\Controller;
+use App\Models\CommonCatalogItem;
 use App\Models\Setting;
 use App\Models\User;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
 use Illuminate\View\View;
 
 class AdminSettingsController extends Controller
@@ -33,6 +35,8 @@ class AdminSettingsController extends Controller
                 Setting::KEY_TTN_NEXT_NUMBER,
                 \App\Models\Ttn::getNextTtnNumber()
             ),
+            'technicalDescriptionPriceFields' => CommonCatalogItem::PRICE_FIELDS,
+            'technicalDescriptionPriceField' => Setting::technicalDescriptionPriceField(),
         ]);
     }
 
@@ -42,6 +46,11 @@ class AdminSettingsController extends Controller
             'default_maf_order_user_id' => ['nullable', 'integer', 'exists:users,id'],
             'reclamation_act_representative_user_id' => ['nullable', 'integer', 'exists:users,id'],
             'ttn_next_number' => ['required', 'integer', 'min:1'],
+            'technical_description_price_field' => [
+                'required',
+                'string',
+                Rule::in(array_keys(CommonCatalogItem::PRICE_FIELDS)),
+            ],
         ]);
 
         Setting::set(
@@ -56,6 +65,10 @@ class AdminSettingsController extends Controller
             Setting::KEY_TTN_NEXT_NUMBER,
             $data['ttn_next_number']
         );
+        Setting::set(
+            Setting::KEY_TECHNICAL_DESCRIPTION_PRICE_FIELD,
+            $data['technical_description_price_field'],
+        );
 
         return back()->with('success', 'Настройки сохранены.');
     }

+ 73 - 0
app/Http/Controllers/CommonCatalogController.php

@@ -6,17 +6,24 @@ namespace App\Http\Controllers;
 
 use App\Http\Requests\StoreCommonCatalogItemRequest;
 use App\Jobs\Export\ExportCommonCatalogJob;
+use App\Jobs\Export\ExportTechnicalDescriptionsJob;
 use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
 use App\Models\Import;
+use App\Models\Role;
+use App\Models\Setting;
+use App\Services\Access\AccessService;
 use App\Services\Access\FieldAccessService;
 use App\Services\FileService;
+use App\Services\TechnicalDescriptionService;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
+use Symfony\Component\HttpFoundation\StreamedResponse;
 use Throwable;
 
 class CommonCatalogController extends Controller
@@ -129,6 +136,12 @@ class CommonCatalogController extends Controller
 
         $this->data['items'] = $query->paginate($this->data['per_page'])->withQueryString();
         $this->data['nav'] = $nav;
+        $priceField = Setting::technicalDescriptionPriceField();
+        $this->data['technicalDescriptionModes'] = TechnicalDescriptionService::DESCRIPTION_MODES;
+        $this->data['canExportTechnicalDescriptions'] = $request->user()->canViewField(
+            'common-catalog',
+            $priceField,
+        );
 
         return view('common_catalog.index', $this->data);
     }
@@ -336,6 +349,58 @@ class CommonCatalogController extends Controller
             ->with('success', 'Задача импорта общего каталога создана.');
     }
 
+    public function exportTechnicalDescriptions(
+        Request $request,
+        AccessService $accessService,
+    ): RedirectResponse {
+        $data = $request->validate([
+            'item_ids' => ['required', 'array', 'min:1', 'max:1000'],
+            'item_ids.*' => ['required', 'integer', 'distinct', 'exists:common_catalog_items,id'],
+            'description_mode' => [
+                'required',
+                'string',
+                Rule::in(array_keys(TechnicalDescriptionService::DESCRIPTION_MODES)),
+            ],
+            'separate_documents' => ['nullable', 'boolean'],
+        ]);
+
+        $priceField = Setting::technicalDescriptionPriceField();
+        abort_unless(
+            $accessService->canViewField($request->user(), 'common-catalog', $priceField),
+            403,
+            'Нет доступа к выбранному для технических описаний виду цены.',
+        );
+
+        ExportTechnicalDescriptionsJob::dispatch(
+            array_values(array_map('intval', $data['item_ids'])),
+            $data['description_mode'],
+            (bool) ($data['separate_documents'] ?? false),
+            (int) $request->user()->getKey(),
+        );
+
+        return back()->with('success', 'Задача экспорта технических описаний создана.');
+    }
+
+    public function downloadTechnicalDescription(Request $request, File $file): StreamedResponse
+    {
+        abort_unless($file->is_generated, 404);
+        abort_unless(
+            Str::startsWith((string) $file->path, 'generated/technical-descriptions/'),
+            404,
+        );
+        abort_unless(
+            (int) $file->user_id === (int) $request->user()->getKey()
+                || $request->user()->hasRole(Role::ADMIN),
+            403,
+        );
+        abort_unless(Storage::disk('local')->exists((string) $file->path), 404);
+
+        return Storage::disk('local')->download(
+            (string) $file->path,
+            (string) $file->original_name,
+        );
+    }
+
     private function itemView(
         Request $request,
         FieldAccessService $fieldAccess,
@@ -361,6 +426,14 @@ class CommonCatalogController extends Controller
             $this->data['commonCatalogWritableFields'][$field] = $request->user()
                 ->canUpdateField('common-catalog', $field);
         }
+        $priceField = Setting::technicalDescriptionPriceField();
+        $this->data['technicalDescriptionPriceField'] = $priceField;
+        $this->data['technicalDescriptionPriceLabel'] = CommonCatalogItem::PRICE_FIELDS[$priceField];
+        $this->data['canViewTechnicalDescriptionPrice'] = $request->user()->canViewField(
+            'common-catalog',
+            $priceField,
+        );
+        $this->data['technicalDescriptionModes'] = TechnicalDescriptionService::DESCRIPTION_MODES;
 
         return view('common_catalog.edit', $this->data);
     }

+ 5 - 0
app/Http/Requests/StoreCommonCatalogItemRequest.php

@@ -44,7 +44,9 @@ class StoreCommonCatalogItemRequest extends FormRequest
                     ->ignore($item instanceof CommonCatalogItem ? $item->getKey() : null),
             ],
             'calculator_name' => ['required', 'string', 'max:255'],
+            'print_name' => ['nullable', 'string', 'max:255'],
             'kind' => ['nullable', 'string', 'max:255'],
+            'product_group' => ['nullable', 'string', 'max:255'],
             'dimension_length' => ['nullable', 'string', 'max:255'],
             'dimension_width' => ['nullable', 'string', 'max:255'],
             'dimension_height' => ['nullable', 'string', 'max:255'],
@@ -57,6 +59,9 @@ class StoreCommonCatalogItemRequest extends FormRequest
             'volume' => ['nullable', 'numeric', 'min:0'],
             'places' => ['nullable', 'integer', 'min:0'],
             'composition' => ['nullable', 'string'],
+            'characteristics' => ['nullable', 'string'],
+            'technical_description' => ['nullable', 'string'],
+            'technical_description_short' => ['nullable', 'string'],
             'age_group' => ['nullable', 'string', 'max:100'],
             'max_users' => ['nullable', 'integer', 'min:0'],
             'unit' => ['nullable', 'string', 'max:50'],

+ 61 - 0
app/Jobs/Export/ExportTechnicalDescriptionsJob.php

@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportTechnicalDescriptionsService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class ExportTechnicalDescriptionsJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $timeout = 600;
+
+    public int $tries = 2;
+
+    /** @param list<int> $itemIds */
+    public function __construct(
+        private readonly array $itemIds,
+        private readonly string $descriptionMode,
+        private readonly bool $separateDocuments,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(ExportTechnicalDescriptionsService $service): void
+    {
+        try {
+            $file = $service->handle(
+                $this->itemIds,
+                $this->descriptionMode,
+                $this->separateDocuments,
+                $this->userId,
+            );
+
+            event(new SendWebSocketMessageEvent(
+                'Экспорт технических описаний завершён!',
+                $this->userId,
+                ['link' => $file->link],
+            ));
+        } catch (Throwable $exception) {
+            Log::error('Technical descriptions export failed.', [
+                'user_id' => $this->userId,
+                'item_ids' => $this->itemIds,
+                'error' => $exception->getMessage(),
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                'Ошибка экспорта технических описаний: '.$exception->getMessage(),
+                $this->userId,
+                ['error' => $exception->getMessage()],
+            ));
+
+            throw $exception;
+        }
+    }
+}

+ 24 - 0
app/Models/CommonCatalogItem.php

@@ -24,11 +24,32 @@ class CommonCatalogItem extends Model
 
     public const DEFAULT_SORT_BY = 'article';
 
+    public const PRICE_FIELDS = [
+        'builders_price' => 'строители',
+        'wholesale_price' => 'опт',
+        'recommended_price' => 'рек',
+        'retail_price' => 'розница',
+        'project_price' => 'проект',
+        'project_with_installation_price' => 'проект+м',
+        'pik_price' => 'пик',
+        'recommended_plus_10_price' => 'рек+10',
+    ];
+
+    public const TECHNICAL_DESCRIPTION_FIELDS = [
+        'print_name',
+        'product_group',
+        'characteristics',
+        'technical_description',
+        'technical_description_short',
+    ];
+
     protected $fillable = [
         'image_file_id',
         'article',
         'calculator_name',
+        'print_name',
         'kind',
+        'product_group',
         'dimension_length',
         'dimension_width',
         'dimension_height',
@@ -42,6 +63,9 @@ class CommonCatalogItem extends Model
         'volume',
         'places',
         'composition',
+        'characteristics',
+        'technical_description',
+        'technical_description_short',
         'age_group',
         'max_users',
         'unit',

+ 15 - 0
app/Models/Setting.php

@@ -9,6 +9,9 @@ class Setting extends Model
     public const KEY_DEFAULT_MAF_ORDER_USER_ID = 'default_maf_order_user_id';
     public const KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID = 'reclamation_act_representative_user_id';
     public const KEY_TTN_NEXT_NUMBER = 'ttn_next_number';
+    public const KEY_TECHNICAL_DESCRIPTION_PRICE_FIELD = 'technical_description_price_field';
+
+    public const DEFAULT_TECHNICAL_DESCRIPTION_PRICE_FIELD = 'project_price';
 
     protected $fillable = [
         'key',
@@ -40,4 +43,16 @@ class Setting extends Model
             ['value' => $value === null ? null : (string) $value]
         );
     }
+
+    public static function technicalDescriptionPriceField(): string
+    {
+        $field = (string) static::get(
+            self::KEY_TECHNICAL_DESCRIPTION_PRICE_FIELD,
+            self::DEFAULT_TECHNICAL_DESCRIPTION_PRICE_FIELD,
+        );
+
+        return array_key_exists($field, CommonCatalogItem::PRICE_FIELDS)
+            ? $field
+            : self::DEFAULT_TECHNICAL_DESCRIPTION_PRICE_FIELD;
+    }
 }

+ 180 - 0
app/Services/Export/ExportTechnicalDescriptionsService.php

@@ -0,0 +1,180 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Export;
+
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Services\TechnicalDescriptionService;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpWord\Settings as PhpWordSettings;
+use PhpOffice\PhpWord\TemplateProcessor;
+use RuntimeException;
+use ZipArchive;
+
+class ExportTechnicalDescriptionsService
+{
+    private const COMBINED_TEMPLATE = 'массовый.docx';
+    private const SINGLE_TEMPLATE = 'одиночный.docx';
+
+    public function __construct(
+        private readonly TechnicalDescriptionService $technicalDescriptions,
+    ) {}
+
+    /** @param list<int> $itemIds */
+    public function handle(
+        array $itemIds,
+        string $descriptionMode,
+        bool $separateDocuments,
+        int $userId,
+    ): File {
+        $items = CommonCatalogItem::query()
+            ->with('imageFile')
+            ->whereIn('id', $itemIds)
+            ->orderBy('series')
+            ->orderBy('article')
+            ->get();
+
+        if ($items->isEmpty()) {
+            throw new RuntimeException('Не выбраны позиции для экспорта технических описаний.');
+        }
+
+        if (! array_key_exists($descriptionMode, TechnicalDescriptionService::DESCRIPTION_MODES)) {
+            throw new RuntimeException('Выбран неизвестный вариант технического описания.');
+        }
+
+        PhpWordSettings::setOutputEscapingEnabled(true);
+
+        return $separateDocuments
+            ? $this->separateDocuments($items, $descriptionMode, $userId)
+            : $this->combinedDocument($items, $descriptionMode, $userId);
+    }
+
+    /** @param Collection<int, CommonCatalogItem> $items */
+    private function combinedDocument(Collection $items, string $descriptionMode, int $userId): File
+    {
+        $filename = 'Технические_описания_'.now()->format('Ymd_His').'.docx';
+        $path = $this->generatedPath($userId, $filename);
+        Storage::disk('local')->makeDirectory(dirname($path));
+
+        $processor = new TemplateProcessor($this->templatePath(self::COMBINED_TEMPLATE));
+        $processor->cloneBlock('product_block', $items->count(), true, true);
+
+        foreach ($items->values() as $index => $item) {
+            $this->fillProduct($processor, $item, $descriptionMode, $index + 1, $index + 1);
+        }
+
+        $processor->saveAs(Storage::disk('local')->path($path));
+
+        return $this->registerFile($path, $filename, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', $userId);
+    }
+
+    /** @param Collection<int, CommonCatalogItem> $items */
+    private function separateDocuments(Collection $items, string $descriptionMode, int $userId): File
+    {
+        $filename = 'Технические_описания_'.now()->format('Ymd_His').'.zip';
+        $path = $this->generatedPath($userId, $filename);
+        $temporaryDirectory = 'generated/technical-descriptions/tmp/'.Str::uuid();
+        Storage::disk('local')->makeDirectory(dirname($path));
+        Storage::disk('local')->makeDirectory($temporaryDirectory);
+
+        $zip = new ZipArchive;
+        if ($zip->open(Storage::disk('local')->path($path), ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+            throw new RuntimeException('Не удалось создать ZIP-архив технических описаний.');
+        }
+
+        try {
+            foreach ($items->values() as $index => $item) {
+                $documentName = sprintf(
+                    '%s_%04d.docx',
+                    $this->safeFilename((string) $item->article),
+                    $index + 1,
+                );
+                $documentPath = $temporaryDirectory.'/'.$documentName;
+                $processor = new TemplateProcessor($this->templatePath(self::SINGLE_TEMPLATE));
+                $processor->cloneBlock('product_block', 1, true, true);
+                $this->fillProduct($processor, $item, $descriptionMode, 1, 1);
+                $processor->setValue('page_br#1', '');
+                $processor->saveAs(Storage::disk('local')->path($documentPath));
+                $zip->addFile(Storage::disk('local')->path($documentPath), $documentName);
+            }
+        } finally {
+            $zip->close();
+            Storage::disk('local')->deleteDirectory($temporaryDirectory);
+        }
+
+        return $this->registerFile($path, $filename, 'application/zip', $userId);
+    }
+
+    private function fillProduct(
+        TemplateProcessor $processor,
+        CommonCatalogItem $item,
+        string $descriptionMode,
+        int $variableIndex,
+        int $number,
+    ): void {
+        $suffix = '#'.$variableIndex;
+        $processor->setValue('product_group'.$suffix, $item->product_group ?: $item->kind ?: '');
+        $processor->setValue('num'.$suffix, (string) $number);
+        $processor->setValue('name_for_form'.$suffix, $item->print_name ?: $item->calculator_name ?: '');
+        $processor->setValue('price'.$suffix, $this->technicalDescriptions->formattedPrice($item));
+        $processor->setValue(
+            'description'.$suffix,
+            $this->technicalDescriptions->description($item, $descriptionMode),
+        );
+
+        $imagePath = $item->imageFile?->path;
+        if ($imagePath && Storage::disk('public')->exists($imagePath)) {
+            $processor->setImageValue('image'.$suffix, [
+                'path' => Storage::disk('public')->path($imagePath),
+                'width' => 270,
+                'height' => 180,
+                'ratio' => true,
+            ]);
+        } else {
+            $processor->setValue('image'.$suffix, '');
+        }
+    }
+
+    private function registerFile(string $path, string $filename, string $mimeType, int $userId): File
+    {
+        $file = File::query()->create([
+            'link' => '',
+            'path' => $path,
+            'user_id' => $userId,
+            'original_name' => $filename,
+            'mime_type' => $mimeType,
+            'is_generated' => true,
+        ]);
+        $file->update([
+            'link' => route('common-catalog.technical-description.download', $file),
+        ]);
+
+        return $file;
+    }
+
+    private function generatedPath(int $userId, string $filename): string
+    {
+        return "generated/technical-descriptions/{$userId}/".Str::uuid()."/{$filename}";
+    }
+
+    private function templatePath(string $filename): string
+    {
+        $path = base_path('templates/technical-descriptions/'.$filename);
+        if (! is_file($path)) {
+            throw new RuntimeException("Шаблон технического описания {$filename} не найден.");
+        }
+
+        return $path;
+    }
+
+    private function safeFilename(string $value): string
+    {
+        $value = preg_replace('/[^\pL\pN._-]+/u', '_', trim($value)) ?? '';
+
+        return $value === '' ? 'позиция' : mb_substr($value, 0, 100);
+    }
+}

+ 203 - 0
app/Services/Import/ImportTechnicalDescriptionsService.php

@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Services\FileService;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use RuntimeException;
+
+class ImportTechnicalDescriptionsService
+{
+    /**
+     * @param list<array<string, mixed>> $sourceProducts
+     * @return array<string, int|list<string>>
+     */
+    public function handle(
+        array $sourceProducts,
+        ?string $imagesDirectory,
+        int $userId,
+        bool $overwrite = false,
+        bool $createMissing = false,
+        bool $applyToVariants = false,
+        bool $dryRun = false,
+    ): array {
+        $stats = [
+            'source' => count($sourceProducts),
+            'matched' => 0,
+            'updated' => 0,
+            'created' => 0,
+            'unchanged' => 0,
+            'missing' => 0,
+            'ambiguous' => 0,
+            'images_imported' => 0,
+            'images_missing' => 0,
+            'missing_articles' => [],
+            'ambiguous_articles' => [],
+        ];
+
+        foreach ($sourceProducts as $source) {
+            $article = trim((string) ($source['article'] ?? ''));
+            if ($article === '') {
+                continue;
+            }
+
+            $items = CommonCatalogItem::query()->where('article', $article)->get();
+            if ($items->isEmpty()) {
+                if (! $createMissing) {
+                    $stats['missing']++;
+                    $stats['missing_articles'][] = $article;
+                    continue;
+                }
+
+                $items = collect([new CommonCatalogItem([
+                    'article' => $article,
+                    'calculator_name' => $this->nullableString($source['name'] ?? null),
+                ])]);
+            } elseif ($items->count() > 1 && ! $applyToVariants) {
+                $stats['ambiguous']++;
+                $stats['ambiguous_articles'][] = $article;
+                continue;
+            }
+
+            foreach ($items as $item) {
+                $stats['matched']++;
+                $payload = $this->payload($source);
+                $changes = $this->changes($item, $payload, $overwrite);
+                $isNew = ! $item->exists;
+                $imageSource = $this->imageSource($source, $imagesDirectory);
+                $shouldImportImage = $item->image_file_id === null && $imageSource !== null;
+
+                if ($dryRun) {
+                    if ($isNew) {
+                        $stats['created']++;
+                    } elseif ($changes !== [] || $shouldImportImage) {
+                        $stats['updated']++;
+                    } else {
+                        $stats['unchanged']++;
+                    }
+                    if ($shouldImportImage) {
+                        $stats['images_imported']++;
+                    } elseif ($item->image_file_id === null && ($source['image_path'] ?? '') !== '') {
+                        $stats['images_missing']++;
+                    }
+                    continue;
+                }
+
+                if ($changes !== []) {
+                    $item->fill($changes);
+                }
+                if ($isNew) {
+                    $item->save();
+                    $stats['created']++;
+                } elseif ($item->isDirty()) {
+                    $item->save();
+                    $stats['updated']++;
+                } else {
+                    $stats['unchanged']++;
+                }
+
+                if ($item->image_file_id === null && ($source['image_path'] ?? '') !== '') {
+                    if ($imageSource === null) {
+                        $stats['images_missing']++;
+                    } else {
+                        $this->importImage($item, $imageSource, $userId);
+                        $stats['images_imported']++;
+                        if (! $isNew && $changes === []) {
+                            $stats['unchanged']--;
+                            $stats['updated']++;
+                        }
+                    }
+                }
+            }
+        }
+
+        $stats['missing_articles'] = array_values(array_unique($stats['missing_articles']));
+        $stats['ambiguous_articles'] = array_values(array_unique($stats['ambiguous_articles']));
+
+        return $stats;
+    }
+
+    /** @param array<string, mixed> $source */
+    private function payload(array $source): array
+    {
+        return [
+            'series' => $this->nullableString($source['series'] ?? null),
+            'calculator_name' => $this->nullableString($source['name'] ?? null),
+            'print_name' => $this->nullableString($source['name_for_form'] ?? null),
+            'product_group' => $this->nullableString($source['product_group'] ?? null),
+            'characteristics' => $this->nullableString($source['characteristics'] ?? null),
+            'technical_description' => $this->nullableString($source['tech_description'] ?? null),
+            'technical_description_short' => $this->nullableString($source['tech_description_short'] ?? null),
+        ];
+    }
+
+    private function changes(CommonCatalogItem $item, array $payload, bool $overwrite): array
+    {
+        return array_filter(
+            $payload,
+            function (mixed $value, string $field) use ($item, $overwrite): bool {
+                if ($value === null) {
+                    return false;
+                }
+
+                return $overwrite || trim((string) $item->getAttribute($field)) === '';
+            },
+            ARRAY_FILTER_USE_BOTH,
+        );
+    }
+
+    /** @param array<string, mixed> $source */
+    private function imageSource(array $source, ?string $imagesDirectory): ?string
+    {
+        $imageName = basename(str_replace('\\', '/', (string) ($source['image_path'] ?? '')));
+        if ($imageName === '' || $imagesDirectory === null || $imagesDirectory === '') {
+            return null;
+        }
+
+        $path = rtrim($imagesDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$imageName;
+
+        return is_file($path) && is_readable($path) ? $path : null;
+    }
+
+    private function importImage(CommonCatalogItem $item, string $sourcePath, int $userId): void
+    {
+        $contents = file_get_contents($sourcePath);
+        if (! is_string($contents) || $contents === '') {
+            throw new RuntimeException("Не удалось прочитать изображение {$sourcePath}.");
+        }
+
+        $mimeType = (string) (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
+        $extension = match ($mimeType) {
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/webp' => 'webp',
+            default => throw new RuntimeException("Неподдерживаемый MIME-тип изображения {$mimeType}."),
+        };
+        $path = "common_catalog/items/{$item->id}/image/".Str::uuid().".{$extension}";
+        if (! Storage::disk('public')->put($path, $contents)) {
+            throw new RuntimeException("Не удалось сохранить изображение для артикула {$item->article}.");
+        }
+
+        $file = File::query()->create([
+            'link' => url('/storage/'.$path),
+            'path' => $path,
+            'user_id' => $userId,
+            'original_name' => basename($sourcePath),
+            'mime_type' => $mimeType,
+        ]);
+        app(FileService::class)->ensureThumbnail($file);
+        $item->update(['image_file_id' => $file->id]);
+    }
+
+    private function nullableString(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? null : $value;
+    }
+}

+ 65 - 0
app/Services/TechnicalDescriptionService.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\CommonCatalogItem;
+use App\Models\Setting;
+
+class TechnicalDescriptionService
+{
+    public const MODE_CHARACTERISTICS_SHORT = 'characteristics_short';
+    public const MODE_CHARACTERISTICS_FULL = 'characteristics_full';
+    public const MODE_SHORT = 'short';
+    public const MODE_FULL = 'full';
+    public const MODE_CHARACTERISTICS = 'characteristics';
+
+    public const DESCRIPTION_MODES = [
+        self::MODE_CHARACTERISTICS_SHORT => 'Характеристики + краткое описание',
+        self::MODE_CHARACTERISTICS_FULL => 'Характеристики + полное описание',
+        self::MODE_SHORT => 'Только краткое описание',
+        self::MODE_FULL => 'Только полное описание',
+        self::MODE_CHARACTERISTICS => 'Только характеристики',
+    ];
+
+    public function priceField(): string
+    {
+        return Setting::technicalDescriptionPriceField();
+    }
+
+    public function priceLabel(): string
+    {
+        return CommonCatalogItem::PRICE_FIELDS[$this->priceField()];
+    }
+
+    public function price(CommonCatalogItem $item): ?float
+    {
+        $value = $item->{$this->priceField()};
+
+        return $value === null ? null : (float) $value;
+    }
+
+    public function formattedPrice(CommonCatalogItem $item): string
+    {
+        $price = $this->price($item);
+
+        return $price === null ? '' : number_format($price, 2, '.', ' ');
+    }
+
+    public function description(CommonCatalogItem $item, string $mode): string
+    {
+        $parts = match ($mode) {
+            self::MODE_CHARACTERISTICS_SHORT => [$item->characteristics, $item->technical_description_short],
+            self::MODE_CHARACTERISTICS_FULL => [$item->characteristics, $item->technical_description],
+            self::MODE_SHORT => [$item->technical_description_short],
+            self::MODE_FULL => [$item->technical_description],
+            default => [$item->characteristics],
+        };
+
+        return collect($parts)
+            ->map(fn (mixed $value): string => trim((string) $value))
+            ->filter()
+            ->implode("\n");
+    }
+}

+ 3 - 2
composer.json

@@ -6,15 +6,16 @@
     "license": "MIT",
     "require": {
         "php": "^8.2",
+        "ext-fileinfo": "*",
         "ext-pdo": "*",
         "ext-zip": "*",
         "laravel/framework": "^11.31",
         "laravel/tinker": "^2.9",
         "laravel/ui": "^4.6",
         "phpoffice/phpspreadsheet": "^5.1",
+        "phpoffice/phpword": "^1.4",
         "predis/predis": "^2.3",
-        "syntech/syntechfcm": "^1.3",
-        "ext-fileinfo": "*"
+        "syntech/syntechfcm": "^1.3"
     },
     "require-dev": {
         "fakerphp/faker": "^1.23",

+ 163 - 2
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "ce5f7c41c26e4e0c9d6f46cec46cab30",
+    "content-hash": "8077bf45c9ab3de58adccbe30ee430a4",
     "packages": [
         {
             "name": "brick/math",
@@ -2835,6 +2835,58 @@
             ],
             "time": "2024-11-21T10:39:51+00:00"
         },
+        {
+            "name": "phpoffice/math",
+            "version": "0.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPOffice/Math.git",
+                "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
+                "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-xml": "*",
+                "php": "^7.1|^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^0.12.88 || ^1.0.0",
+                "phpunit/phpunit": "^7.0 || ^9.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PhpOffice\\Math\\": "src/Math/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Progi1984",
+                    "homepage": "https://lefevre.dev"
+                }
+            ],
+            "description": "Math - Manipulate Math Formula",
+            "homepage": "https://phpoffice.github.io/Math/",
+            "keywords": [
+                "MathML",
+                "officemathml",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPOffice/Math/issues",
+                "source": "https://github.com/PHPOffice/Math/tree/0.3.0"
+            },
+            "time": "2025-05-29T08:31:49+00:00"
+        },
         {
             "name": "phpoffice/phpspreadsheet",
             "version": "5.1.0",
@@ -2941,6 +2993,114 @@
             },
             "time": "2025-09-04T05:34:49+00:00"
         },
+        {
+            "name": "phpoffice/phpword",
+            "version": "1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPOffice/PHPWord.git",
+                "reference": "6d75328229bc93790b37e93741adf70646cea958"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958",
+                "reference": "6d75328229bc93790b37e93741adf70646cea958",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-gd": "*",
+                "ext-json": "*",
+                "ext-xml": "*",
+                "ext-zip": "*",
+                "php": "^7.1|^8.0",
+                "phpoffice/math": "^0.3"
+            },
+            "require-dev": {
+                "dompdf/dompdf": "^2.0 || ^3.0",
+                "ext-libxml": "*",
+                "friendsofphp/php-cs-fixer": "^3.3",
+                "mpdf/mpdf": "^7.0 || ^8.0",
+                "phpmd/phpmd": "^2.13",
+                "phpstan/phpstan": "^0.12.88 || ^1.0.0",
+                "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+                "phpunit/phpunit": ">=7.0",
+                "symfony/process": "^4.4 || ^5.0",
+                "tecnickcom/tcpdf": "^6.5"
+            },
+            "suggest": {
+                "dompdf/dompdf": "Allows writing PDF",
+                "ext-xmlwriter": "Allows writing OOXML and ODF",
+                "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PhpOffice\\PhpWord\\": "src/PhpWord"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "LGPL-3.0-only"
+            ],
+            "authors": [
+                {
+                    "name": "Mark Baker"
+                },
+                {
+                    "name": "Gabriel Bull",
+                    "email": "me@gabrielbull.com",
+                    "homepage": "http://gabrielbull.com/"
+                },
+                {
+                    "name": "Franck Lefevre",
+                    "homepage": "https://rootslabs.net/blog/"
+                },
+                {
+                    "name": "Ivan Lanin",
+                    "homepage": "http://ivan.lanin.org"
+                },
+                {
+                    "name": "Roman Syroeshko",
+                    "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
+                },
+                {
+                    "name": "Antoine de Troostembergh"
+                }
+            ],
+            "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)",
+            "homepage": "https://phpoffice.github.io/PHPWord/",
+            "keywords": [
+                "ISO IEC 29500",
+                "OOXML",
+                "Office Open XML",
+                "OpenDocument",
+                "OpenXML",
+                "PhpOffice",
+                "PhpWord",
+                "Rich Text Format",
+                "WordprocessingML",
+                "doc",
+                "docx",
+                "html",
+                "odf",
+                "odt",
+                "office",
+                "pdf",
+                "php",
+                "reader",
+                "rtf",
+                "template",
+                "template processor",
+                "word",
+                "writer"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPOffice/PHPWord/issues",
+                "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0"
+            },
+            "time": "2025-06-05T10:32:36+00:00"
+        },
         {
             "name": "phpoption/phpoption",
             "version": "1.9.3",
@@ -8617,9 +8777,10 @@
     "prefer-lowest": false,
     "platform": {
         "php": "^8.2",
+        "ext-fileinfo": "*",
         "ext-pdo": "*",
         "ext-zip": "*"
     },
     "platform-dev": {},
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.9.0"
 }

+ 5 - 0
config/access.php

@@ -131,7 +131,9 @@ return [
             'image' => 'Внешний вид',
             'article' => 'Артикул',
             'calculator_name' => 'Наименование',
+            'print_name' => 'Техописание: наименование для формы',
             'kind' => 'Вид',
+            'product_group' => 'Техописание: группа',
             'dimension_length' => 'Габариты: длина',
             'dimension_width' => 'Габариты: ширина',
             'dimension_height' => 'Габариты: высота',
@@ -144,6 +146,9 @@ return [
             'volume' => 'Объем, м3',
             'places' => 'Места',
             'composition' => 'Состав',
+            'characteristics' => 'Техописание: характеристики',
+            'technical_description' => 'Техописание: полное описание',
+            'technical_description_short' => 'Техописание: краткое описание',
             'age_group' => 'Возрастная группа',
             'max_users' => 'Макс. кол-во пользователей',
             'unit' => 'Ед.',

+ 2 - 0
config/access_routes.php

@@ -69,6 +69,8 @@ return [
             'destroy' => 'common-catalog.delete',
             'import' => 'common-catalog.import',
             'export' => 'common-catalog.export',
+            'technical-description.export' => 'common-catalog.view',
+            'technical-description.download' => 'common-catalog.view',
             'image.upload' => 'common-catalog.image.upload',
             'image.delete' => 'common-catalog.image.delete',
             'documents.upload' => 'common-catalog.documents.upload',

+ 5 - 0
database/factories/CommonCatalogItemFactory.php

@@ -17,7 +17,9 @@ class CommonCatalogItemFactory extends Factory
         return [
             'article' => fake()->unique()->numerify('####'),
             'calculator_name' => fake()->sentence(),
+            'print_name' => fake()->optional()->sentence(),
             'kind' => fake()->randomElement(['Игровое оборудование', 'Теневой навес', 'Мебель']),
+            'product_group' => fake()->optional()->randomElement(['Игровое оборудование', 'Спортивное оборудование']),
             'dimension_length' => (string) fake()->randomFloat(2, 1, 10),
             'dimension_width' => (string) fake()->randomFloat(2, 1, 10),
             'dimension_height' => (string) fake()->randomFloat(2, 1, 5),
@@ -30,6 +32,9 @@ class CommonCatalogItemFactory extends Factory
             'volume' => fake()->randomFloat(3, 1, 20),
             'places' => fake()->numberBetween(1, 50),
             'composition' => fake()->optional()->sentence(),
+            'characteristics' => fake()->optional()->paragraph(),
+            'technical_description' => fake()->optional()->paragraphs(2, true),
+            'technical_description_short' => fake()->optional()->paragraph(),
             'age_group' => fake()->randomElement(['0+', '3+', '6+']),
             'max_users' => fake()->numberBetween(1, 20),
             'unit' => 'шт.',

+ 34 - 0
database/migrations/2026_08_07_000001_add_technical_description_fields_to_common_catalog_items.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->string('print_name')->nullable()->after('calculator_name');
+            $table->string('product_group')->nullable()->after('kind');
+            $table->text('characteristics')->nullable()->after('composition');
+            $table->text('technical_description')->nullable()->after('characteristics');
+            $table->text('technical_description_short')->nullable()->after('technical_description');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->dropColumn([
+                'print_name',
+                'product_group',
+                'characteristics',
+                'technical_description',
+                'technical_description_short',
+            ]);
+        });
+    }
+};

+ 22 - 0
database/migrations/2026_08_07_000002_sync_technical_description_field_permissions.php

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

+ 11 - 11
docs/refactor/plan.md

@@ -83,7 +83,7 @@
 | 4. Общий каталог | Расширение реализовано, ожидает пользовательской проверки | Импорт, экспорт и карточка переведены на фактический 30-колоночный формат `Каталог общий.xlsx`; добавлены восемь цен и ограничения доступа. |
 | 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
-| 7. Техническое описание | Анализ завершён, готово к реализации | Изучены данные, импорт, изображения, DOCX-шаблоны и массовый/раздельный экспорт `to.stroyprofit.com`; требуется перенос в общий каталог. |
+| 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
 | 9. Графики заказов и доставок | Исходная система изучена, ожидается отдельное ТЗ | Найдены старый график и фактический формат обмена с 1С. Нормализация принята как архитектурный принцип, но финальные поля, статусы и правила уточнит заказчик. |
 | 10. Рекламации из графика заказов | Готово только после графика заказов | Сценарий согласован, но точка входа зависит от карточки заказа в графике. |
@@ -205,7 +205,7 @@
 
 ## Этап 7. Техническое описание в общем каталоге
 
-Статус: **анализ источника завершён, готово к реализации**.
+Статус: **реализован и проверен пользователем**.
 
 - [x] Получить и изучить готовый Laravel-модуль техописаний `to.stroyprofit.com`.
 - [x] Составить карту данных модуля техописаний.
@@ -213,15 +213,15 @@
 - [x] Сопоставить данные техописаний с полями `Каталог общий`.
 - [x] Решить, что данные техописаний расширяют `common_catalog_items`, а не образуют отдельный модуль каталога.
 - [x] Зафиксировать, что техописание не хранит отдельную копию цены, а использует нужный вид цены из `Каталог общий`.
-- [ ] Добавить остальные недостающие поля техописаний в `Каталог общий` с безопасной миграцией.
-- [ ] Подготовить перенос или обогащение 938 позиций по артикулу в согласованном режиме без потери уже заполненных полей общего каталога.
-- [ ] Сопоставить старое одиночное поле цены техописаний с нужным видом цены общего каталога, если оно потребуется при переносе.
-- [ ] Перенести изображения через текущий файловый механизм CRM.
-- [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
-- [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
-- [ ] Подставлять в DOCX согласованный вид цены из `Каталог общий`, не сохраняя отдельную копию в данных техописания.
-- [ ] Реализовать общий DOCX и отдельные DOCX в ZIP через очередь и приватное хранение файлов.
-- [ ] Покрыть миграцию данных и варианты экспорта автоматическими тестами.
+- [x] Добавить остальные недостающие поля техописаний в `Каталог общий` с безопасной миграцией.
+- [x] Реализовать безопасное обогащение по артикулу без перезаписи уже заполненных полей; из 938 исходных артикулов сопоставлено 907, данные применены к 1033 позициям с учётом вариантов.
+- [x] Не переносить старую одиночную цену; вид актуальной цены выбирает администратор в системных настройках, значение по умолчанию — `проект`.
+- [x] Использовать изображения общего каталога; при переносе добавлять исходное изображение только позиции без изображения.
+- [x] Встроить просмотр/редактирование техописания в карточку общего каталога.
+- [x] Перенести и адаптировать оба DOCX-шаблона готового Laravel-модуля.
+- [x] Подставлять в DOCX выбранный администратором вид цены из `Каталог общий`, не сохраняя отдельную копию в данных техописания.
+- [x] Реализовать общий DOCX и отдельные DOCX в ZIP через очередь и приватное хранение файлов.
+- [x] Покрыть перенос данных, настройку цены, права и варианты экспорта автоматическими тестами.
 
 ## Этап 8. Калькуляции
 

+ 24 - 13
docs/refactor/tz-technical-description.md

@@ -15,7 +15,7 @@
 
 ## 2. Статус
 
-Статус: **источник изучен, встроить в модуль `Каталог общий`**.
+Статус: **реализовано в модуле `Каталог общий` и проверено пользователем**.
 
 Laravel-модуль `to.stroyprofit.com` изучен и используется как источник:
 
@@ -64,6 +64,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - использование готового Laravel-модуля как основы для экспорта;
 - хранение данных в структуре общего каталога или связанной таблице, если данные нельзя уложить в основную карточку позиции.
 - получение согласованного вида цены для DOCX из `Каталог общий` без собственной копии цены в техописании.
+- выбор администратором вида цены в системных настройках; значение по умолчанию — `проект`.
 
 ## 6. Что не переносим из готового модуля
 
@@ -98,7 +99,9 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 В текущем `common_catalog_items` отсутствуют готовые поля для названия печатной формы, текстовых характеристик, полного и краткого технического описания. Эти описательные поля должны дополнять общий каталог.
 
-Техописание не хранит собственную копию цены. При формировании DOCX оно читает согласованный вид цены непосредственно из позиции `Каталог общий`. Старое одиночное поле `price` переносится только после сопоставления с одним из восьми новых видов цены.
+Техописание не хранит собственную копию цены. При формировании DOCX оно читает выбранный администратором вид цены непосредственно из позиции `Каталог общий`. По умолчанию используется поле `project_price` (`проект`). Старое одиночное поле `price` не переносится.
+
+Экспорт доступен только пользователю, имеющему право просмотра выбранного ценового поля. Поэтому DOCX не позволяет обойти действующее ограничение видимости цен.
 
 ## 8. Этапы реализации
 
@@ -108,14 +111,14 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - [x] Сопоставить данные техописаний с полями `Каталог общий`.
 - [x] Решить, что данные хранятся в карточке общего каталога, а не в отдельном модуле.
 - [x] Исключить отдельную копию цены из модели техописания; владельцем цен является общий каталог.
-- [ ] Добавить остальные недостающие поля общего каталога.
-- [ ] Перенести данные по артикулу с защитой уже заполненных полей.
-- [ ] При необходимости сопоставить старое поле `price` с согласованным видом цены общего каталога.
-- [ ] Перенести изображения в текущий файловый механизм CRM.
-- [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
-- [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
-- [ ] Подставлять в экспорт согласованный вид цены из `Каталог общий`.
-- [ ] Выполнять массовые экспорты через очередь и выдавать результат из приватного хранилища.
+- [x] Добавить остальные недостающие поля общего каталога.
+- [x] Перенести данные по артикулу с защитой уже заполненных полей.
+- [x] Заменить старое поле `price` выбором актуального ценового поля в системных настройках.
+- [x] Переносить исходное изображение через файловый механизм CRM только при отсутствии изображения общего каталога.
+- [x] Встроить просмотр/редактирование техописания в карточку общего каталога.
+- [x] Перенести и адаптировать экспорт из готового Laravel-модуля.
+- [x] Подставлять в экспорт выбранный вид цены из `Каталог общий`.
+- [x] Выполнять массовые экспорты через очередь и выдавать результат из приватного хранилища.
 - [x] Не добавлять отдельный пункт меню `Технич. описание` в финальную навигацию.
 
 ## 9. Критерии приемки
@@ -129,7 +132,15 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 ## 10. Открытые вопросы
 
-- Переносить все 938 позиций как источник данных или только обогащать артикулы, уже существующие в общем каталоге?
-- Сохраняются ли оба текущих DOCX-шаблона без визуальных изменений?
+- [x] Использовать безопасное обогащение существующего общего каталога; отсутствующие позиции автоматически не создавать.
+- [x] Сохранить оба текущих DOCX-шаблона без визуальных изменений.
 - Нужна ли история изменения текстов технического описания?
-- Какому из восьми видов цены общего каталога соответствует старое поле `price`?
+- [x] Вид цены выбирается администратором; значение по умолчанию — `проект`.
+
+## 11. Результат фактического переноса
+
+- источник: 938 позиций, 24 серии, 938 изображений;
+- найдено в общем каталоге: 907 исходных артикулов;
+- обогащено: 1033 позиции общего каталога с учётом вариантов одного артикула;
+- уже заполненные поля и изображения не перезаписывались;
+- 31 отсутствующий артикул оставлен для отдельного решения: `K5320`, `E2002`, `Е4015`, `Е4016`, `Е4017`, `Е4019`, `Е4022`, `Е6306`, `5002`, `5008`, `5011`, `6502`, `6503`, `6504`, `0001-1`, `VR0001`, `VR0002`, `VR0007`, `VR0008`, `VR0009`, `VR0010`, `VR0011`, `VR0012`, `VR0013`, `VR0014`, `VR0015`, `10020`, `У001`, `У002`, `МК001`, `ЕК-001`.

+ 11 - 0
resources/views/admin/settings/index.blade.php

@@ -32,6 +32,17 @@
             'required' => true,
             'value' => old('ttn_next_number', $ttnNextNumber),
         ])
+        @include('partials.select', [
+            'name' => 'technical_description_price_field',
+            'title' => 'Цена для технических описаний',
+            'options' => $technicalDescriptionPriceFields,
+            'value' => old('technical_description_price_field', $technicalDescriptionPriceField),
+        ])
+        <div class="row mb-3">
+            <div class="offset-md-4 col-md-8">
+                <div class="form-text">По умолчанию используется цена «проект». Значение подставляется в DOCX непосредственно из общего каталога.</div>
+            </div>
+        </div>
         <div class="row mb-3">
             <div class="offset-md-4 col-md-8">
                 <div class="form-text">Следующая созданная ТН получит именно этот номер.</div>

+ 87 - 16
resources/views/common_catalog/edit.blade.php

@@ -10,15 +10,11 @@
             'calculator_enabled',
             $item === null || $item->calculator_enabled === null ? '' : (string) (int) $item->calculator_enabled,
         );
-        $priceFields = [
-            'builders_price' => 'строители',
-            'wholesale_price' => 'опт',
-            'recommended_price' => 'рек',
-            'retail_price' => 'розница',
-            'project_price' => 'проект',
-            'project_with_installation_price' => 'проект+м',
-            'pik_price' => 'пик',
-            'recommended_plus_10_price' => 'рек+10',
+        $priceFields = \App\Models\CommonCatalogItem::PRICE_FIELDS;
+        $technicalTextFields = [
+            'characteristics' => ['Характеристики', 4],
+            'technical_description' => ['Техническое описание', 6],
+            'technical_description_short' => ['Краткое техническое описание', 5],
         ];
     @endphp
     <div class="px-3">
@@ -187,6 +183,63 @@
                 </div>
             @endif
 
+            @if(collect(\App\Models\CommonCatalogItem::TECHNICAL_DESCRIPTION_FIELDS)->contains(fn (string $field): bool => $canView($field)))
+                <div class="card mt-3">
+                    <div class="card-header"><strong>Техническое описание</strong></div>
+                    <div class="card-body">
+                        <div class="row">
+                            @if($canView('print_name'))
+                                <div class="col-xl-6">
+                                    @include('partials.input', [
+                                        'name' => 'print_name',
+                                        'title' => 'Наименование для формы',
+                                        'value' => $item?->print_name,
+                                        'disabled' => !$canUpdate('print_name'),
+                                    ])
+                                </div>
+                            @endif
+                            @if($canView('product_group'))
+                                <div class="col-xl-6">
+                                    @include('partials.input', [
+                                        'name' => 'product_group',
+                                        'title' => 'Группа',
+                                        'value' => $item?->product_group,
+                                        'disabled' => !$canUpdate('product_group'),
+                                    ])
+                                </div>
+                            @endif
+                        </div>
+
+                        @foreach($technicalTextFields as $field => [$label, $rows])
+                            @if($canView($field))
+                                <div class="row mb-2">
+                                    <label for="{{ $field }}" class="col-form-label small col-md-4 text-md-end">{{ $label }}</label>
+                                    <div class="col-md-8">
+                                        <textarea name="{{ $field }}" id="{{ $field }}" rows="{{ $rows }}"
+                                                  @disabled(!$canUpdate($field))
+                                                  class="form-control form-control-sm @error($field) is-invalid @enderror">{{ old($field, $item?->{$field}) }}</textarea>
+                                        @error($field)
+                                            <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                        @enderror
+                                    </div>
+                                </div>
+                            @endif
+                        @endforeach
+
+                        @if($item)
+                            <div class="alert alert-light border mb-0 mt-3 py-2">
+                                @if($canViewTechnicalDescriptionPrice)
+                                    Цена для DOCX: <strong>{{ $technicalDescriptionPriceLabel }}</strong> —
+                                    <strong>{{ $item->{$technicalDescriptionPriceField.'_txt'} }}</strong>
+                                @else
+                                    Цена для DOCX скрыта вашими правами доступа.
+                                @endif
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            @endif
+
             <div class="d-flex flex-wrap gap-2 mt-3">
                 @if(($item && hasPermission('common-catalog.update')) || (!$item && hasPermission('common-catalog.create')))
                     <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>
@@ -196,6 +249,31 @@
         </form>
 
         @if($item)
+            @if($canViewTechnicalDescriptionPrice)
+                <div class="card mt-4">
+                    <div class="card-header"><strong>Экспорт технического описания</strong></div>
+                    <div class="card-body">
+                        <form action="{{ route('common-catalog.technical-description.export') }}" method="POST">
+                            @csrf
+                            <input type="hidden" name="item_ids[]" value="{{ $item->id }}">
+                            @include('partials.select', [
+                                'name' => 'description_mode',
+                                'title' => 'Содержимое описания',
+                                'options' => $technicalDescriptionModes,
+                                'value' => \App\Services\TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT,
+                            ])
+                            <div class="row">
+                                <div class="offset-md-4 col-md-8">
+                                    <button type="submit" class="btn btn-sm btn-outline-primary">
+                                        <i class="bi bi-file-earmark-word"></i> Сформировать DOCX
+                                    </button>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            @endif
+
             <div class="card mt-4">
                 <div class="card-header d-flex justify-content-between align-items-center">
                     <strong>Документы</strong>
@@ -227,13 +305,6 @@
                 </div>
             </div>
 
-            <div class="card mt-4">
-                <div class="card-header"><strong>Техническое описание</strong></div>
-                <div class="card-body text-muted">
-                    Блок подготовлен к подключению данных и выгрузок из готового Laravel-модуля технических описаний.
-                </div>
-            </div>
-
             @if(hasPermission('common-catalog.delete'))
                 <form action="{{ route('common-catalog.destroy', $item) }}" method="POST" class="mt-4"
                       onsubmit="return confirm('Удалить позицию общего каталога?')">

+ 90 - 0
resources/views/common_catalog/index.blade.php

@@ -6,6 +6,14 @@
             <h3>Каталог общий</h3>
         </div>
         <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if($canExportTechnicalDescriptions)
+                <button type="button" id="technical-description-export-button"
+                        class="btn btn-sm mb-1 btn-outline-primary page-action-btn" disabled
+                        data-bs-toggle="modal" data-bs-target="#technicalDescriptionExportModal">
+                    <i class="bi bi-file-earmark-word page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Техописания</span>
+                </button>
+            @endif
             @if(hasPermission('common-catalog.import'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn"
                         data-bs-toggle="modal" data-bs-target="#commonCatalogImportModal">
@@ -39,6 +47,7 @@
         'routeName' => 'common-catalog.show',
         'routeParam' => 'commonCatalogItem',
         'nav' => $nav,
+        'selectable' => $canExportTechnicalDescriptions,
     ])
 
     @include('partials.pagination', ['items' => $items])
@@ -71,4 +80,85 @@
             </div>
         </div>
     @endif
+
+    @if($canExportTechnicalDescriptions)
+        <div class="modal fade" id="technicalDescriptionExportModal" tabindex="-1"
+             aria-labelledby="technicalDescriptionExportModalLabel" aria-hidden="true">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="technicalDescriptionExportModalLabel">Экспорт технических описаний</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <form id="technical-description-export-form"
+                          action="{{ route('common-catalog.technical-description.export') }}" method="POST">
+                        @csrf
+                        <div class="modal-body">
+                            <p>Выбрано позиций: <strong id="technical-description-selected-count">0</strong></p>
+                            <div id="technical-description-selected-inputs"></div>
+                            @include('partials.select', [
+                                'name' => 'description_mode',
+                                'title' => 'Содержимое описания',
+                                'options' => $technicalDescriptionModes,
+                                'value' => \App\Services\TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT,
+                            ])
+                            <div class="row mb-2">
+                                <div class="offset-md-4 col-md-8">
+                                    <label class="form-check-label">
+                                        <input type="checkbox" class="form-check-input" name="separate_documents" value="1">
+                                        Отдельный DOCX для каждой позиции в ZIP
+                                    </label>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
+                            <button type="submit" class="btn btn-primary">Сформировать</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        @push('scripts')
+            <script>
+                document.addEventListener('DOMContentLoaded', () => {
+                    const rows = Array.from(document.querySelectorAll('.js-table-row-select'));
+                    const selectAll = document.querySelector('.js-table-select-all');
+                    const button = document.getElementById('technical-description-export-button');
+                    const count = document.getElementById('technical-description-selected-count');
+                    const inputs = document.getElementById('technical-description-selected-inputs');
+
+                    const selected = () => rows.filter((checkbox) => checkbox.checked);
+                    const refresh = () => {
+                        const selectedRows = selected();
+                        button.disabled = selectedRows.length === 0;
+                        count.textContent = String(selectedRows.length);
+                        if (selectAll) {
+                            selectAll.checked = rows.length > 0 && selectedRows.length === rows.length;
+                            selectAll.indeterminate = selectedRows.length > 0 && selectedRows.length < rows.length;
+                        }
+                    };
+
+                    selectAll?.addEventListener('change', () => {
+                        rows.forEach((checkbox) => checkbox.checked = selectAll.checked);
+                        refresh();
+                    });
+                    rows.forEach((checkbox) => checkbox.addEventListener('change', refresh));
+
+                    document.getElementById('technical-description-export-form')?.addEventListener('submit', () => {
+                        inputs.replaceChildren(...selected().map((checkbox) => {
+                            const input = document.createElement('input');
+                            input.type = 'hidden';
+                            input.name = 'item_ids[]';
+                            input.value = checkbox.value;
+                            return input;
+                        }));
+                    });
+
+                    refresh();
+                });
+            </script>
+        @endpush
+    @endif
 @endsection

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

@@ -20,6 +20,12 @@
     <table class="table table-interactive table-initial-hidden" id="tbl" data-table-name="{{ $id }}">
         <thead class="table-head-shadow">
         <tr>
+            @if($selectable ?? false)
+                <th scope="col" class="bg-primary-subtle text-center" data-no-row-select>
+                    <input type="checkbox" class="form-check-input js-table-select-all"
+                           aria-label="Выбрать все строки на странице">
+                </th>
+            @endif
             @foreach($header as $headerName => $headerTitle)
                 @php
                     $normalizedHeaderName = str_replace('_txt', '', $headerName);
@@ -104,6 +110,14 @@
                     class="{{ $string->isRead() ? match($string->type) { 'reclamation' => 'notification-read-reclamation', 'platform' => 'notification-read-platform', 'schedule' => 'notification-read-schedule', default => 'notification-read-platform' } : 'notification-unread' }}"
                 @endif
             >
+                @if($selectable ?? false)
+                    <td class="align-middle text-center" data-no-row-select>
+                        <input type="checkbox"
+                               class="form-check-input js-table-row-select"
+                               value="{{ $rowId }}"
+                               aria-label="Выбрать строку {{ $rowId }}">
+                    </td>
+                @endif
                 @foreach($header as $headerName => $headerTitle)
                     <td class="column_{{$headerName}} align-middle"
                     >

+ 4 - 0
routes/web.php

@@ -162,6 +162,10 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::post('', [CommonCatalogController::class, 'store'])->name('store');
         Route::post('import', [CommonCatalogController::class, 'import'])->name('import');
         Route::post('export', [CommonCatalogController::class, 'export'])->name('export');
+        Route::post('technical-descriptions/export', [CommonCatalogController::class, 'exportTechnicalDescriptions'])
+            ->name('technical-description.export');
+        Route::get('technical-descriptions/files/{file}', [CommonCatalogController::class, 'downloadTechnicalDescription'])
+            ->name('technical-description.download');
         Route::get('{commonCatalogItem}', [CommonCatalogController::class, 'show'])->name('show');
         Route::put('{commonCatalogItem}', [CommonCatalogController::class, 'update'])->name('update');
         Route::delete('{commonCatalogItem}', [CommonCatalogController::class, 'destroy'])->name('destroy');

BIN
templates/technical-descriptions/массовый.docx


BIN
templates/technical-descriptions/одиночный.docx


+ 21 - 0
tests/Feature/AdminSettingsControllerTest.php

@@ -32,11 +32,32 @@ class AdminSettingsControllerTest extends TestCase
                 'default_maf_order_user_id' => null,
                 'reclamation_act_representative_user_id' => null,
                 'ttn_next_number' => 25,
+                'technical_description_price_field' => 'project_price',
             ]);
 
         $response->assertRedirect();
         $response->assertSessionHas('success');
         $this->assertSame(25, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+        $this->assertSame('project_price', Setting::technicalDescriptionPriceField());
+    }
+
+    public function test_admin_can_select_technical_description_price_field(): void
+    {
+        $this->actingAs($this->adminUser)
+            ->post(route('admin.settings.store'), [
+                'default_maf_order_user_id' => null,
+                'reclamation_act_representative_user_id' => null,
+                'ttn_next_number' => 25,
+                'technical_description_price_field' => 'wholesale_price',
+            ])
+            ->assertRedirect();
+
+        $this->assertSame('wholesale_price', Setting::technicalDescriptionPriceField());
+    }
+
+    public function test_technical_description_price_defaults_to_project(): void
+    {
+        $this->assertSame('project_price', Setting::technicalDescriptionPriceField());
     }
 
     public function test_manager_cannot_store_admin_settings(): void

+ 25 - 0
tests/Feature/CleanupGeneratedDocumentsCommandTest.php

@@ -93,6 +93,31 @@ class CleanupGeneratedDocumentsCommandTest extends TestCase
         Storage::disk('public')->assertMissing('reclamations/20/Рекламация тест.zip');
     }
 
+    public function test_cleanup_removes_private_technical_description_export(): void
+    {
+        Storage::fake('public');
+        Storage::fake('local');
+        $user = User::factory()->create();
+        $path = 'generated/technical-descriptions/'.$user->id.'/test/document.docx';
+        $file = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'document.docx',
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            'path' => $path,
+            'link' => route('common-catalog.technical-description.download', 1),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+        Storage::disk('local')->put($path, 'generated');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $file->id]);
+        Storage::disk('local')->assertMissing($path);
+    }
+
     public function test_cleanup_generated_documents_removes_old_orphan_generated_archive(): void
     {
         Storage::fake('public');

+ 71 - 0
tests/Feature/CommonCatalogControllerTest.php

@@ -5,9 +5,11 @@ declare(strict_types=1);
 namespace Tests\Feature;
 
 use App\Jobs\Export\ExportCommonCatalogJob;
+use App\Jobs\Export\ExportTechnicalDescriptionsJob;
 use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\Role;
+use App\Models\File;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
@@ -203,6 +205,75 @@ class CommonCatalogControllerTest extends TestCase
             ->assertSeeText('Калькуляция');
     }
 
+    public function test_admin_can_edit_technical_description_fields(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $payload = $this->validData();
+        $payload['article'] = $item->article;
+        $payload['print_name'] = 'Наименование для формы';
+        $payload['product_group'] = 'Игровое оборудование';
+        $payload['characteristics'] = 'Характеристики';
+        $payload['technical_description'] = 'Полное описание';
+        $payload['technical_description_short'] = 'Краткое описание';
+
+        $this->actingAs($this->admin)
+            ->put(route('common-catalog.update', $item), $payload)
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('common_catalog_items', [
+            'id' => $item->id,
+            'print_name' => 'Наименование для формы',
+            'technical_description' => 'Полное описание',
+        ]);
+    }
+
+    public function test_technical_description_export_requires_access_to_selected_price(): void
+    {
+        Bus::fake([ExportTechnicalDescriptionsJob::class]);
+        $item = CommonCatalogItem::factory()->create();
+        $payload = [
+            'item_ids' => [$item->id],
+            'description_mode' => \App\Services\TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT,
+        ];
+
+        $this->actingAs($this->manager)
+            ->post(route('common-catalog.technical-description.export'), $payload)
+            ->assertForbidden();
+        Bus::assertNotDispatched(ExportTechnicalDescriptionsJob::class);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.technical-description.export'), $payload)
+            ->assertRedirect();
+        Bus::assertDispatched(ExportTechnicalDescriptionsJob::class);
+    }
+
+    public function test_generated_technical_description_is_private_to_owner_or_admin(): void
+    {
+        Storage::fake('local');
+        $owner = User::factory()->create(['role' => Role::MANAGER]);
+        $otherManager = User::factory()->create(['role' => Role::MANAGER]);
+        $path = 'generated/technical-descriptions/'.$owner->id.'/test/document.docx';
+        Storage::disk('local')->put($path, 'docx');
+        $file = File::query()->create([
+            'user_id' => $owner->id,
+            'path' => $path,
+            'link' => '',
+            'original_name' => 'document.docx',
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            'is_generated' => true,
+        ]);
+
+        $this->actingAs($otherManager)
+            ->get(route('common-catalog.technical-description.download', $file))
+            ->assertForbidden();
+        $this->actingAs($owner)
+            ->get(route('common-catalog.technical-description.download', $file))
+            ->assertDownload('document.docx');
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.technical-description.download', $file))
+            ->assertDownload('document.docx');
+    }
+
     public function test_admin_can_upload_and_delete_image(): void
     {
         Storage::fake('public');

+ 88 - 0
tests/Unit/Services/Export/ExportTechnicalDescriptionsServiceTest.php

@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Export;
+
+use App\Models\CommonCatalogItem;
+use App\Models\Role;
+use App\Models\Setting;
+use App\Models\User;
+use App\Services\Export\ExportTechnicalDescriptionsService;
+use App\Services\TechnicalDescriptionService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+use ZipArchive;
+
+class ExportTechnicalDescriptionsServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_combined_docx_uses_selected_catalog_price_and_description(): void
+    {
+        Storage::fake('local');
+        Storage::fake('public');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        Setting::set(Setting::KEY_TECHNICAL_DESCRIPTION_PRICE_FIELD, 'project_price');
+        $item = CommonCatalogItem::factory()->create([
+            'article' => 'S1102',
+            'print_name' => 'Игровой комплекс Сибирь',
+            'product_group' => 'Игровое оборудование',
+            'characteristics' => 'Высота 2 метра',
+            'technical_description_short' => 'Краткое описание',
+            'project_price' => 1234.50,
+            'retail_price' => 9999,
+        ]);
+
+        $file = app(ExportTechnicalDescriptionsService::class)->handle(
+            [$item->id],
+            TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT,
+            false,
+            $user->id,
+        );
+
+        Storage::disk('local')->assertExists($file->path);
+        $xml = $this->documentXml(Storage::disk('local')->path($file->path));
+        $this->assertStringContainsString('1 234.50', $xml);
+        $this->assertStringNotContainsString('9 999.00', $xml);
+        $this->assertStringContainsString('Краткое описание', $xml);
+        $this->assertTrue($file->is_generated);
+    }
+
+    public function test_separate_export_creates_zip_with_docx_per_item(): void
+    {
+        Storage::fake('local');
+        Storage::fake('public');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $items = CommonCatalogItem::factory()->count(2)->create();
+
+        $file = app(ExportTechnicalDescriptionsService::class)->handle(
+            $items->modelKeys(),
+            TechnicalDescriptionService::MODE_FULL,
+            true,
+            $user->id,
+        );
+
+        Storage::disk('local')->assertExists($file->path);
+        $zip = new ZipArchive;
+        $this->assertTrue($zip->open(Storage::disk('local')->path($file->path)) === true);
+        $this->assertSame(2, $zip->numFiles);
+        $this->assertStringEndsWith('.docx', (string) $zip->getNameIndex(0));
+        $zip->close();
+    }
+
+    private function documentXml(string $path): string
+    {
+        $zip = new ZipArchive;
+        $this->assertTrue($zip->open($path) === true);
+        $xml = $zip->getFromName('word/document.xml');
+        $zip->close();
+
+        $this->assertIsString($xml);
+
+        return $xml;
+    }
+}

+ 113 - 0
tests/Unit/Services/Import/ImportTechnicalDescriptionsServiceTest.php

@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\CommonCatalogItem;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Import\ImportTechnicalDescriptionsService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class ImportTechnicalDescriptionsServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_import_fills_only_empty_fields_and_imports_image(): void
+    {
+        Storage::fake('public');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $item = CommonCatalogItem::factory()->create([
+            'article' => 'S1102',
+            'calculator_name' => 'Актуальное название',
+            'print_name' => null,
+            'technical_description' => null,
+            'project_price' => 1000,
+        ]);
+        $directory = sys_get_temp_dir().'/technical-description-test-'.uniqid();
+        mkdir($directory);
+        file_put_contents(
+            $directory.'/S1102.jpg',
+            UploadedFile::fake()->image('S1102.jpg', 80, 80)->getContent(),
+        );
+
+        try {
+            $stats = app(ImportTechnicalDescriptionsService::class)->handle(
+                [$this->sourceProduct()],
+                $directory,
+                $user->id,
+            );
+        } finally {
+            unlink($directory.'/S1102.jpg');
+            rmdir($directory);
+        }
+
+        $item->refresh();
+        $this->assertSame('Актуальное название', $item->calculator_name);
+        $this->assertSame('Игровой комплекс Сибирь', $item->print_name);
+        $this->assertSame('Полное описание', $item->technical_description);
+        $this->assertSame(1000.0, $item->project_price);
+        $this->assertNotNull($item->imageFile);
+        Storage::disk('public')->assertExists($item->imageFile->path);
+        $this->assertSame(1, $stats['updated']);
+        $this->assertSame(1, $stats['images_imported']);
+    }
+
+    public function test_import_skips_ambiguous_article_by_default(): void
+    {
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        CommonCatalogItem::factory()->create(['article' => 'S1102', 'calculator_name' => 'Вариант 1']);
+        CommonCatalogItem::factory()->create(['article' => 'S1102', 'calculator_name' => 'Вариант 2']);
+
+        $stats = app(ImportTechnicalDescriptionsService::class)->handle(
+            [$this->sourceProduct()],
+            null,
+            $user->id,
+        );
+
+        $this->assertSame(1, $stats['ambiguous']);
+        $this->assertSame(['S1102'], $stats['ambiguous_articles']);
+        $this->assertDatabaseMissing('common_catalog_items', ['print_name' => 'Игровой комплекс Сибирь']);
+    }
+
+    public function test_dry_run_does_not_change_data(): void
+    {
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $item = CommonCatalogItem::factory()->create([
+            'article' => 'S1102',
+            'print_name' => null,
+        ]);
+
+        $stats = app(ImportTechnicalDescriptionsService::class)->handle(
+            [$this->sourceProduct()],
+            null,
+            $user->id,
+            dryRun: true,
+        );
+
+        $this->assertNull($item->refresh()->print_name);
+        $this->assertSame(1, $stats['updated']);
+    }
+
+    /** @return array<string, string> */
+    private function sourceProduct(): array
+    {
+        return [
+            'article' => 'S1102',
+            'series' => 'Сибирь',
+            'name' => 'Старое название',
+            'name_for_form' => 'Игровой комплекс Сибирь',
+            'product_group' => 'Детское игровое оборудование',
+            'characteristics' => 'Характеристики',
+            'tech_description' => 'Полное описание',
+            'tech_description_short' => 'Краткое описание',
+            'image_path' => 'S1102.jpg',
+        ];
+    }
+}