6 Commits cc37675725 ... 9f11d28f2d

Author SHA1 Message Date
  Alexander Musikhin 9f11d28f2d refactored docs 3 weeks ago
  Alexander Musikhin 9fadc7f33b documented order schedule answers and delivery template 1 month ago
  Alexander Musikhin 018001f4ca improved catalog and stock column filters 1 month ago
  Alexander Musikhin ce3183adae documented order schedule requirements 1 month ago
  Alexander Musikhin bdfe703293 added technical descriptions catalog transfer 1 month ago
  Alexander Musikhin ca1bca221a implemented technical descriptions module 1 month ago
51 changed files with 3068 additions and 163 deletions
  1. 1 0
      AGENTS.md
  2. 10 2
      app/Console/Commands/CleanupGeneratedDocuments.php
  3. 90 0
      app/Console/Commands/ImportTechnicalDescriptions.php
  4. 13 0
      app/Http/Controllers/Admin/AdminSettingsController.php
  5. 73 0
      app/Http/Controllers/CommonCatalogController.php
  6. 154 33
      app/Http/Controllers/FilterController.php
  7. 179 13
      app/Http/Controllers/StockController.php
  8. 159 4
      app/Http/Controllers/StockOrderController.php
  9. 5 0
      app/Http/Requests/StoreCommonCatalogItemRequest.php
  10. 61 0
      app/Jobs/Export/ExportTechnicalDescriptionsJob.php
  11. 24 0
      app/Models/CommonCatalogItem.php
  12. 15 0
      app/Models/Setting.php
  13. 73 1
      app/Services/Export/ExportCommonCatalogService.php
  14. 180 0
      app/Services/Export/ExportTechnicalDescriptionsService.php
  15. 104 1
      app/Services/Import/ImportCommonCatalogService.php
  16. 203 0
      app/Services/Import/ImportTechnicalDescriptionsService.php
  17. 65 0
      app/Services/TechnicalDescriptionService.php
  18. 3 2
      composer.json
  19. 163 2
      composer.lock
  20. 5 0
      config/access.php
  21. 2 0
      config/access_routes.php
  22. 5 0
      database/factories/CommonCatalogItemFactory.php
  23. 34 0
      database/migrations/2026_08_07_000001_add_technical_description_fields_to_common_catalog_items.php
  24. 22 0
      database/migrations/2026_08_07_000002_sync_technical_description_field_permissions.php
  25. 1 1
      docs/refactor/menu.md
  26. 34 22
      docs/refactor/plan.md
  27. 187 0
      docs/refactor/tz-order-schedule-answers.md
  28. BIN
      docs/refactor/tz-order-schedule-card.png
  29. 283 0
      docs/refactor/tz-order-schedule.md
  30. 37 30
      docs/refactor/tz-schedules.md
  31. 26 13
      docs/refactor/tz-technical-description.md
  32. 11 0
      resources/views/admin/settings/index.blade.php
  33. 87 16
      resources/views/common_catalog/edit.blade.php
  34. 90 0
      resources/views/common_catalog/index.blade.php
  35. 31 9
      resources/views/partials/newFilterElement.blade.php
  36. 14 0
      resources/views/partials/table.blade.php
  37. 10 10
      resources/views/stock/index.blade.php
  38. 9 3
      resources/views/stock/orders.blade.php
  39. 33 0
      resources/views/stock/partials/column-header.blade.php
  40. 4 0
      routes/web.php
  41. BIN
      templates/DeliveryRequest.xls
  42. BIN
      templates/technical-descriptions/массовый.docx
  43. BIN
      templates/technical-descriptions/одиночный.docx
  44. 21 0
      tests/Feature/AdminSettingsControllerTest.php
  45. 25 0
      tests/Feature/CleanupGeneratedDocumentsCommandTest.php
  46. 140 0
      tests/Feature/CommonCatalogControllerTest.php
  47. 128 0
      tests/Feature/StockControllerTest.php
  48. 17 0
      tests/Unit/Services/Export/ExportCommonCatalogServiceTest.php
  49. 88 0
      tests/Unit/Services/Export/ExportTechnicalDescriptionsServiceTest.php
  50. 36 1
      tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php
  51. 113 0
      tests/Unit/Services/Import/ImportTechnicalDescriptionsServiceTest.php

+ 1 - 0
AGENTS.md

@@ -20,6 +20,7 @@ Stroyprofit CRM is a Laravel 11 application for managing orders of building stru
 ## Development Notes
 - Tests, Artisan commands, and related project checks should be run inside containers.
 - Preferred commands: `make test` for tests and `docker compose exec app ...` for ad-hoc commands inside the application container.
+- Production is hosted on the `stroyprofit` SSH server in `/var/www/stroyprofit/data/dkr` (connect with `ssh stroyprofit`).
 
 ## Project Structure
 ```

+ 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);
     }

+ 154 - 33
app/Http/Controllers/FilterController.php

@@ -2,27 +2,33 @@
 
 namespace App\Http\Controllers;
 
-use App\Models\SparePartsView;
 use App\Http\Requests\FilterRequest;
+use App\Models\CommonCatalogItem;
+use App\Models\SparePartsView;
+use App\Models\StockOrder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 
 class FilterController extends Controller
 {
     const DB_TABLES = [
-        'orders'        => 'orders_view',
-        'product_sku'   => 'mafs_view',
-        'products'      => 'products',
-        'reclamations'  => 'reclamations_view',
-        'maf_order'     => 'maf_orders_view',
-        'import'        => 'imports',
-        'responsibles'  => 'responsibles_view',
-        'users'         => 'users',
-        'contracts'     => 'contracts',
-        'spare_parts'   => 'spare_parts_view',
+        'orders' => 'orders_view',
+        'product_sku' => 'mafs_view',
+        'products' => 'products',
+        'reclamations' => 'reclamations_view',
+        'maf_order' => 'maf_orders_view',
+        'import' => 'imports',
+        'responsibles' => 'responsibles_view',
+        'users' => 'users',
+        'contracts' => 'contracts',
+        'spare_parts' => 'spare_parts_view',
         'spare_part_orders' => 'spare_part_orders_view',
         'notifications' => 'user_notifications',
         'notification_logs' => 'notification_delivery_logs',
+        'common_catalog_items' => 'common_catalog_items',
+        'stock_availability' => 'common_catalog_items',
+        'stock_orders' => 'stock_orders',
     ];
 
     const SKIP_YEAR_FILTER = [
@@ -37,7 +43,7 @@ class FilterController extends Controller
      */
     const COLUMN_MAP = [
         'spare_part_orders' => [
-            'status_name'         => 'status',
+            'status_name' => 'status',
             'with_documents_text' => 'with_documents',
         ],
         'spare_parts' => [
@@ -53,6 +59,20 @@ class FilterController extends Controller
         'responsibles' => [
             'area-name' => 'area_name',
         ],
+        'common_catalog_items' => [
+            'calculator_enabled_txt' => 'calculator_enabled',
+            'builders_price_txt' => 'builders_price',
+            'wholesale_price_txt' => 'wholesale_price',
+            'recommended_price_txt' => 'recommended_price',
+            'retail_price_txt' => 'retail_price',
+            'project_price_txt' => 'project_price',
+            'project_with_installation_price_txt' => 'project_with_installation_price',
+            'pik_price_txt' => 'pik_price',
+            'recommended_plus_10_price_txt' => 'recommended_plus_10_price',
+        ],
+        'stock_orders' => [
+            'status_name' => 'status',
+        ],
     ];
 
     /**
@@ -81,35 +101,62 @@ class FilterController extends Controller
                 1 => 'Да',
             ],
             'status' => [
-                'ordered'  => 'Заказано',
+                'ordered' => 'Заказано',
                 'in_stock' => 'На складе',
-                'shipped'  => 'Отгружено',
+                'shipped' => 'Отгружено',
             ],
         ],
         'notification_logs' => [
             'channel' => [
-                'in_app'  => 'Браузер',
+                'in_app' => 'Браузер',
                 'browser' => 'Браузер',
-                'push'    => 'Android/iOS',
-                'email'   => 'Email',
+                'push' => 'Android/iOS',
+                'email' => 'Email',
             ],
             'status' => [
-                'sent'        => 'Отправлено',
-                'failed'      => 'Ошибка',
-                'skipped'     => 'Пропущено',
+                'sent' => 'Отправлено',
+                'failed' => 'Ошибка',
+                'skipped' => 'Пропущено',
                 'dead_letter' => 'Dead letter',
             ],
         ],
+        'common_catalog_items' => [
+            'calculator_enabled' => [
+                0 => 'нет',
+                1 => 'да',
+            ],
+        ],
+        'stock_orders' => [
+            'status' => StockOrder::STATUS_NAMES,
+        ],
+    ];
+
+    private const FIELD_ACCESS_MODULES = [
+        'common_catalog_items' => 'common-catalog',
+    ];
+
+    private const SESSION_KEYS = [
+        'common_catalog_items' => 'gp_common_catalog',
     ];
 
-    public function getFilters(FilterRequest $request)
+    public function getFilters(FilterRequest $request): JsonResponse
     {
         $table = $request->validated('table');
         $column = $request->validated('column');
-        if(!array_key_exists($table, self::DB_TABLES)) {
+        if (! array_key_exists($table, self::DB_TABLES)) {
             abort(400, 'Table not found');
         }
-        $gp = session('gp_' . $table);
+
+        $this->assertCanViewColumn($request, $table, $column);
+
+        if ($table === 'stock_availability') {
+            return $this->stockAvailabilityFilters($column);
+        }
+        if ($table === 'stock_orders') {
+            return $this->stockOrderFilters($column);
+        }
+
+        $gp = session(self::SESSION_KEYS[$table] ?? 'gp_'.$table);
 
         if ($table === 'spare_parts' && $column === 'pricing_codes_list') {
             $result = DB::table('pricing_codes as pc')
@@ -128,7 +175,7 @@ class FilterController extends Controller
                 array_unshift($result, '-пусто-');
             }
 
-            return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
+            return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
         }
 
         $dbTable = self::DB_TABLES[$table];
@@ -138,22 +185,26 @@ class FilterController extends Controller
 
         if ($dbColumn && Schema::hasColumn($dbTable, $dbColumn)) {
             $normalizedColumn = self::normalizedSelectExpression($dbColumn);
-            $q = DB::table($dbTable)->selectRaw($normalizedColumn . ' as filter_value')->distinct();
-            if (!in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
-                $q->where('year' , year());
+            $q = DB::table($dbTable)->selectRaw($normalizedColumn.' as filter_value')->distinct();
+            if (! in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
+                $q->where('year', year());
             }
             if (Schema::hasColumn($dbTable, 'deleted_at')) {
                 $q->whereNull('deleted_at');
             }
 
-            if(isset($gp['filters']) && is_array($gp['filters']) && count($gp['filters'])) {
+            if (isset($gp['filters']) && is_array($gp['filters']) && count($gp['filters'])) {
                 foreach ($gp['filters'] as $colName => $vals) {
-                    if ($colName === $column) continue;
+                    if ($colName === $column) {
+                        continue;
+                    }
                     $filterDbColumn = self::resolveDbColumn($table, $dbTable, $colName);
-                    if (!$filterDbColumn || !Schema::hasColumn($dbTable, $filterDbColumn)) continue;
+                    if (! $filterDbColumn || ! Schema::hasColumn($dbTable, $filterDbColumn)) {
+                        continue;
+                    }
                     $q->where(function ($query) use ($filterDbColumn, $vals) {
                         foreach (explode('||', $vals) as $val) {
-                            if($val == '-пусто-') {
+                            if ($val == '-пусто-') {
                                 self::applyEmptyFilterConditionForFilterQuery($query, $filterDbColumn);
                             } else {
                                 $query->orWhere($filterDbColumn, '=', $val);
@@ -171,6 +222,7 @@ class FilterController extends Controller
                     if ($val === null || $val === '-пусто-') {
                         return $val;
                     }
+
                     return $val / 100;
                 }, $result);
             }
@@ -178,13 +230,13 @@ class FilterController extends Controller
             // Применяем маппинг значений, если есть
             if (isset(self::VALUE_MAP[$table][$dbColumn])) {
                 $map = self::VALUE_MAP[$table][$dbColumn];
-                $result = array_map(fn($val) => $map[$val] ?? $val, $result);
+                $result = array_map(fn ($val) => $map[$val] ?? $val, $result);
             }
         } else {
             $result = [];
         }
 
-        return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
+        return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
     }
 
     private static function normalizedSelectExpression(string $column): string
@@ -198,6 +250,75 @@ class FilterController extends Controller
             ->orWhereRaw("TRIM(CAST({$column} AS CHAR)) = ''");
     }
 
+    private function assertCanViewColumn(FilterRequest $request, string $table, string $column): void
+    {
+        if ($table === 'stock_availability') {
+            abort_unless($request->user()->hasPermission('stock.view'), 403);
+
+            return;
+        }
+        if ($table === 'stock_orders') {
+            abort_unless($request->user()->hasPermission('stock.orders.view'), 403);
+
+            return;
+        }
+
+        $module = self::FIELD_ACCESS_MODULES[$table] ?? null;
+        if ($module === null) {
+            return;
+        }
+
+        $field = self::COLUMN_MAP[$table][$column] ?? $column;
+        abort_unless($request->user()->canViewField($module, $field), 403);
+    }
+
+    private function stockAvailabilityFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'article', 'calculator_name', 'kind', 'unit' => CommonCatalogItem::query()
+                ->whereHas('stockOrders')
+                ->pluck($column),
+            'latest_order_note' => CommonCatalogItem::query()
+                ->whereHas('stockOrders')
+                ->with('latestStockOrder:id,common_catalog_item_id,note')
+                ->get()
+                ->pluck('latestStockOrder.note'),
+            default => [],
+        };
+
+        return $this->filterValuesResponse($values);
+    }
+
+    private function stockOrderFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'order_number', 'note' => StockOrder::query()->pluck($column),
+            'item_article' => StockOrder::query()
+                ->join('common_catalog_items', 'common_catalog_items.id', '=', 'stock_orders.common_catalog_item_id')
+                ->pluck('common_catalog_items.article'),
+            'status_name' => StockOrder::query()
+                ->pluck('status')
+                ->map(fn (string $status): string => StockOrder::STATUS_NAMES[$status] ?? $status),
+            default => [],
+        };
+
+        return $this->filterValuesResponse($values);
+    }
+
+    private function filterValuesResponse(iterable $values): JsonResponse
+    {
+        $normalized = collect($values)
+            ->map(static fn (mixed $value): string => $value === null || trim((string) $value) === ''
+                ? '-пусто-'
+                : (string) $value)
+            ->unique()
+            ->sort(static fn (string $left, string $right): int => strnatcasecmp($left, $right))
+            ->values()
+            ->all();
+
+        return response()->json($normalized, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+    }
+
     /**
      * Определяет реальный столбец БД по имени столбца из заголовка.
      * Приоритет: прямое совпадение в БД → COLUMN_MAP для конкретной таблицы.

+ 179 - 13
app/Http/Controllers/StockController.php

@@ -13,11 +13,25 @@ use App\Services\StockInventoryService;
 use DomainException;
 use Illuminate\Contracts\View\View;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 
 class StockController extends Controller
 {
+    private const SORT_COLUMNS = [
+        'article',
+        'calculator_name',
+        'kind',
+        'unit',
+        'physical_quantity',
+        'reserved_quantity',
+        'available_stock_quantity',
+        'ordered_stock_quantity',
+        'latest_order_note',
+    ];
+
     public function __construct(private readonly StockInventoryService $inventoryService) {}
 
     public function index(Request $request): View
@@ -26,18 +40,34 @@ class StockController extends Controller
         $kind = trim((string) $request->query('kind', ''));
         $status = trim((string) $request->query('status', ''));
         $perPage = $this->perPage($request);
-        $items = CommonCatalogItem::query()
+        $sortBy = in_array($request->string('sortBy')->toString(), self::SORT_COLUMNS, true)
+            ? $request->string('sortBy')->toString()
+            : 'article';
+        $orderBy = $request->query('order') === 'desc' ? 'desc' : 'asc';
+        session(['gp_stock_availability' => $request->query()]);
+
+        $physicalQuantity = $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_IN_STOCK,
+            'available_quantity',
+        );
+        $reservedQuantity = $this->reservedQuantitySubquery();
+        $orderedQuantity = $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_ORDERED,
+            'ordered_quantity',
+        );
+        $availableQuantity = $this->availableQuantitySubquery();
+
+        $query = CommonCatalogItem::query()
+            ->select('common_catalog_items.*')
             ->whereHas('stockOrders')
-            ->with(['imageFile', 'latestStockOrder'])
-            ->withSum([
-                'stockOrders as physical_quantity' => fn (Builder $query) => $query->where('status', StockOrder::STATUS_IN_STOCK),
-            ], 'available_quantity')
-            ->withSum([
-                'stockReservations as reserved_quantity' => fn (Builder $query) => $query->active(),
-            ], 'quantity')
-            ->withSum([
-                'stockOrders as ordered_stock_quantity' => fn (Builder $query) => $query->where('status', StockOrder::STATUS_ORDERED),
-            ], 'ordered_quantity')
+            ->with('imageFile')
+            ->addSelect([
+                'physical_quantity' => $physicalQuantity,
+                'reserved_quantity' => $reservedQuantity,
+                'available_stock_quantity' => $availableQuantity,
+                'ordered_stock_quantity' => $orderedQuantity,
+                'latest_order_note' => $this->latestOrderNoteSubquery(),
+            ])
             ->when($search !== '', function (Builder $query) use ($search): void {
                 $query->where(function (Builder $query) use ($search): void {
                     $query->where('article', 'like', "%{$search}%")
@@ -47,8 +77,12 @@ class StockController extends Controller
             })
             ->when($kind !== '', fn (Builder $query) => $query->where('kind', $kind))
             ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query
-                ->whereHas('stockOrders', fn (Builder $query) => $query->where('status', $status)))
-            ->orderBy('article')
+                ->whereHas('stockOrders', fn (Builder $query) => $query->where('status', $status)));
+
+        $this->applyColumnFilters($query, $request);
+        $query->orderBy($sortBy, $orderBy)->orderBy('id');
+
+        $items = $query
             ->paginate($perPage)
             ->withQueryString();
 
@@ -67,6 +101,20 @@ class StockController extends Controller
                 ->pluck('kind'),
             'statuses' => StockOrder::STATUS_NAMES,
             'per_page' => $perPage,
+            'sortBy' => $sortBy,
+            'orderBy' => $orderBy,
+            'tableId' => 'stock_availability',
+            'columnFilters' => [
+                'article' => [],
+                'calculator_name' => [],
+                'kind' => [],
+                'unit' => [],
+                'physical_quantity' => ['type' => 'ranges'],
+                'reserved_quantity' => ['type' => 'ranges'],
+                'available_stock_quantity' => ['type' => 'ranges'],
+                'ordered_stock_quantity' => ['type' => 'ranges'],
+                'latest_order_note' => [],
+            ],
         ]);
     }
 
@@ -151,4 +199,122 @@ class StockController extends Controller
 
         return $perPage;
     }
+
+    private function applyColumnFilters(Builder $query, Request $request): void
+    {
+        foreach (['article', 'calculator_name', 'kind', 'unit'] as $column) {
+            $values = $this->filterValues($request, $column);
+            if ($values !== []) {
+                $query->where(function (Builder $query) use ($column, $values): void {
+                    $this->applyValuesCondition($query, $column, $values);
+                });
+            }
+        }
+
+        $latestNotes = $this->filterValues($request, 'latest_order_note');
+        if ($latestNotes !== []) {
+            $nonEmpty = array_values(array_diff($latestNotes, ['-пусто-']));
+            $includeEmpty = in_array('-пусто-', $latestNotes, true);
+            $query->where(function (Builder $query) use ($nonEmpty, $includeEmpty): void {
+                if ($nonEmpty !== []) {
+                    $query->whereHas('latestStockOrder', fn (Builder $query) => $query->whereIn('note', $nonEmpty));
+                }
+                if ($includeEmpty) {
+                    $method = $nonEmpty === [] ? 'where' : 'orWhere';
+                    $query->{$method}(function (Builder $query): void {
+                        $query->whereDoesntHave('latestStockOrder')
+                            ->orWhereHas('latestStockOrder', function (Builder $query): void {
+                                $query->whereNull('note')->orWhereRaw("TRIM(note) = ''");
+                            });
+                    });
+                }
+            });
+        }
+
+        $this->applySubqueryRange($query, $request, 'physical_quantity', $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_IN_STOCK,
+            'available_quantity',
+        ));
+        $this->applySubqueryRange($query, $request, 'reserved_quantity', $this->reservedQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'available_stock_quantity', $this->availableQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'ordered_stock_quantity', $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_ORDERED,
+            'ordered_quantity',
+        ));
+    }
+
+    /** @return array<int, string> */
+    private function filterValues(Request $request, string $column): array
+    {
+        $value = $request->input("filters.{$column}");
+
+        return is_string($value) && $value !== '' ? explode('||', $value) : [];
+    }
+
+    /** @param array<int, string> $values */
+    private function applyValuesCondition(Builder $query, string $column, array $values): void
+    {
+        $nonEmpty = array_values(array_diff($values, ['-пусто-']));
+        if ($nonEmpty !== []) {
+            $query->orWhereIn($column, $nonEmpty);
+        }
+        if (in_array('-пусто-', $values, true)) {
+            $this->applyEmptyFilterCondition($query, $column);
+        }
+    }
+
+    private function applySubqueryRange(
+        Builder $query,
+        Request $request,
+        string $column,
+        QueryBuilder $subquery,
+    ): void {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+
+        if (is_numeric($from)) {
+            $query->where(clone $subquery, '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where(clone $subquery, '<=', (float) $to);
+        }
+    }
+
+    private function stockOrderQuantitySubquery(string $status, string $column): QueryBuilder
+    {
+        return DB::table('stock_orders')
+            ->selectRaw("COALESCE(SUM({$column}), 0)")
+            ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
+            ->where('stock_orders.status', $status)
+            ->whereNull('stock_orders.deleted_at');
+    }
+
+    private function reservedQuantitySubquery(): QueryBuilder
+    {
+        return DB::table('stock_reservations')
+            ->selectRaw('COALESCE(SUM(quantity), 0)')
+            ->whereColumn('stock_reservations.common_catalog_item_id', 'common_catalog_items.id')
+            ->where('stock_reservations.status', StockReservation::STATUS_ACTIVE);
+    }
+
+    private function availableQuantitySubquery(): QueryBuilder
+    {
+        $physical = $this->stockOrderQuantitySubquery(StockOrder::STATUS_IN_STOCK, 'available_quantity');
+        $reserved = $this->reservedQuantitySubquery();
+
+        return DB::query()->selectRaw(
+            'GREATEST(0, ('.$physical->toSql().') - ('.$reserved->toSql().'))',
+            array_merge($physical->getBindings(), $reserved->getBindings()),
+        );
+    }
+
+    private function latestOrderNoteSubquery(): QueryBuilder
+    {
+        return DB::table('stock_orders')
+            ->select('note')
+            ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
+            ->whereNull('stock_orders.deleted_at')
+            ->orderByDesc('stock_orders.id')
+            ->limit(1);
+    }
 }

+ 159 - 4
app/Http/Controllers/StockOrderController.php

@@ -9,18 +9,32 @@ use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\Import;
 use App\Models\StockOrder;
+use App\Models\StockReservation;
 use App\Models\User;
 use App\Services\StockInventoryService;
 use DomainException;
 use Illuminate\Contracts\View\View;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 
 class StockOrderController extends Controller
 {
+    private const SORT_COLUMNS = [
+        'order_number',
+        'item_article',
+        'status_name',
+        'ordered_quantity',
+        'reserved_quantity',
+        'free_quantity',
+        'note',
+        'created_at',
+    ];
+
     public function __construct(private readonly StockInventoryService $inventoryService) {}
 
     public function index(Request $request): View
@@ -28,9 +42,24 @@ class StockOrderController extends Controller
         $search = trim((string) $request->query('search', ''));
         $status = trim((string) $request->query('status', ''));
         $perPage = $this->perPage($request);
-        $orders = StockOrder::query()
+        $requestedSort = $request->string('sortBy')->toString();
+        $sortBy = in_array($requestedSort, self::SORT_COLUMNS, true) ? $requestedSort : 'created_at';
+        $orderBy = $requestedSort !== ''
+            ? ($request->query('order') === 'desc' ? 'desc' : 'asc')
+            : 'desc';
+        session(['gp_stock_orders' => $request->query()]);
+
+        $query = StockOrder::query()
+            ->select('stock_orders.*')
             ->with(['item.imageFile', 'user'])
-            ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
+            ->addSelect([
+                'active_reservations_sum_quantity' => $this->reservedQuantitySubquery(),
+                'free_quantity_value' => $this->freeQuantitySubquery(),
+                'item_article_value' => CommonCatalogItem::query()
+                    ->select('article')
+                    ->whereColumn('common_catalog_items.id', 'stock_orders.common_catalog_item_id')
+                    ->limit(1),
+            ])
             ->when($search !== '', function (Builder $query) use ($search): void {
                 $query->where(function (Builder $query) use ($search): void {
                     $query->where('order_number', 'like', "%{$search}%")
@@ -40,8 +69,19 @@ class StockOrderController extends Controller
                             ->orWhere('calculator_name', 'like', "%{$search}%"));
                 });
             })
-            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query->where('status', $status))
-            ->orderByDesc('created_at')
+            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query->where('status', $status));
+
+        $this->applyColumnFilters($query, $request);
+        $sortColumn = match ($sortBy) {
+            'item_article' => 'item_article_value',
+            'status_name' => 'status',
+            'reserved_quantity' => 'active_reservations_sum_quantity',
+            'free_quantity' => 'free_quantity_value',
+            default => $sortBy,
+        };
+        $query->orderBy($sortColumn, $orderBy)->orderBy('stock_orders.id');
+
+        $orders = $query
             ->paginate($perPage)
             ->withQueryString();
 
@@ -63,6 +103,19 @@ class StockOrderController extends Controller
             'search' => $search,
             'status' => $status,
             'per_page' => $perPage,
+            'sortBy' => $sortBy,
+            'orderBy' => $orderBy,
+            'tableId' => 'stock_orders',
+            'columnFilters' => [
+                'order_number' => [],
+                'item_article' => [],
+                'status_name' => [],
+                'ordered_quantity' => ['type' => 'ranges'],
+                'reserved_quantity' => ['type' => 'ranges'],
+                'free_quantity' => ['type' => 'ranges'],
+                'note' => [],
+                'created_at' => ['type' => 'dates'],
+            ],
         ]);
     }
 
@@ -153,4 +206,106 @@ class StockOrderController extends Controller
 
         return $perPage;
     }
+
+    private function applyColumnFilters(Builder $query, Request $request): void
+    {
+        foreach (['order_number', 'note'] as $column) {
+            $values = $this->filterValues($request, $column);
+            if ($values !== []) {
+                $query->where(function (Builder $query) use ($column, $values): void {
+                    $this->applyValuesCondition($query, $column, $values);
+                });
+            }
+        }
+
+        $articles = $this->filterValues($request, 'item_article');
+        if ($articles !== []) {
+            $query->whereHas('item', fn (Builder $query) => $query->whereIn('article', $articles));
+        }
+
+        $statuses = $this->filterValues($request, 'status_name');
+        if ($statuses !== []) {
+            $statusCodes = array_intersect_key(array_flip(StockOrder::STATUS_NAMES), array_flip($statuses));
+            $query->whereIn('status', array_values($statusCodes));
+        }
+
+        $this->applyColumnRange($query, $request, 'ordered_quantity');
+        $this->applySubqueryRange($query, $request, 'reserved_quantity', $this->reservedQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'free_quantity', $this->freeQuantitySubquery());
+
+        $from = $request->input('filters.created_at_from');
+        $to = $request->input('filters.created_at_to');
+        if (is_string($from) && $from !== '') {
+            $query->whereDate('stock_orders.created_at', '>=', $from);
+        }
+        if (is_string($to) && $to !== '') {
+            $query->whereDate('stock_orders.created_at', '<=', $to);
+        }
+    }
+
+    /** @return array<int, string> */
+    private function filterValues(Request $request, string $column): array
+    {
+        $value = $request->input("filters.{$column}");
+
+        return is_string($value) && $value !== '' ? explode('||', $value) : [];
+    }
+
+    /** @param array<int, string> $values */
+    private function applyValuesCondition(Builder $query, string $column, array $values): void
+    {
+        $nonEmpty = array_values(array_diff($values, ['-пусто-']));
+        if ($nonEmpty !== []) {
+            $query->orWhereIn($column, $nonEmpty);
+        }
+        if (in_array('-пусто-', $values, true)) {
+            $this->applyEmptyFilterCondition($query, $column);
+        }
+    }
+
+    private function applyColumnRange(Builder $query, Request $request, string $column): void
+    {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+        if (is_numeric($from)) {
+            $query->where("stock_orders.{$column}", '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where("stock_orders.{$column}", '<=', (float) $to);
+        }
+    }
+
+    private function applySubqueryRange(
+        Builder $query,
+        Request $request,
+        string $column,
+        QueryBuilder $subquery,
+    ): void {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+        if (is_numeric($from)) {
+            $query->where(clone $subquery, '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where(clone $subquery, '<=', (float) $to);
+        }
+    }
+
+    private function reservedQuantitySubquery(): QueryBuilder
+    {
+        return DB::table('stock_reservations')
+            ->selectRaw('COALESCE(SUM(quantity), 0)')
+            ->whereColumn('stock_reservations.stock_order_id', 'stock_orders.id')
+            ->where('stock_reservations.status', StockReservation::STATUS_ACTIVE);
+    }
+
+    private function freeQuantitySubquery(): QueryBuilder
+    {
+        $reserved = $this->reservedQuantitySubquery();
+
+        return DB::query()->selectRaw(
+            'GREATEST(0, stock_orders.available_quantity - ('.$reserved->toSql().'))',
+            $reserved->getBindings(),
+        );
+    }
 }

+ 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;
+    }
 }

+ 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);
+        }
+    }
 }

+ 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);
+    }
+}

+ 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'],
+        ];
+    }
 }

+ 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; устаревшие системные права безопасно игнорируются.
+    }
+};

+ 1 - 1
docs/refactor/menu.md

@@ -201,6 +201,6 @@ Manager
 
 ## Открытые вопросы
 
-1. Когда будет передано отдельное ТЗ на `График заказов`?
+1. Какие GUID, артикулы, идентификаторы экземпляров и правила повторной синхронизации передаёт 1С в `График заказов`?
 2. Будет ли отдельное ТЗ на `Калькуляции` и какую архитектуру оно определит?
 3. Где находятся актуальные исходные данные старого калькулятора?

+ 34 - 22
docs/refactor/plan.md

@@ -1,4 +1,4 @@
-# План реорганизации Manager 2.0
+а# План реорганизации Manager 2.0
 
 Рабочая папка рефакторинга: `docs/refactor/`.
 
@@ -80,17 +80,17 @@
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
-| 4. Общий каталог | Расширение реализовано, ожидает пользовательской проверки | Импорт, экспорт и карточка переведены на фактический 30-колоночный формат `Каталог общий.xlsx`; добавлены восемь цен и ограничения доступа. |
+| 4. Общий каталог | Реализован и проверен | Создание, редактирование, импорт и экспорт фактического формата `Каталог общий.xlsx` приняты по результатам пользовательской проверки; добавлены восемь цен и ограничения доступа. |
 | 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
-| 7. Техническое описание | Анализ завершён, готово к реализации | Изучены данные, импорт, изображения, DOCX-шаблоны и массовый/раздельный экспорт `to.stroyprofit.com`; требуется перенос в общий каталог. |
+| 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
-| 9. Графики заказов и доставок | Исходная система изучена, ожидается отдельное ТЗ | Найдены старый график и фактический формат обмена с 1С. Нормализация принята как архитектурный принцип, но финальные поля, статусы и правила уточнит заказчик. |
-| 10. Рекламации из графика заказов | Готово только после графика заказов | Сценарий согласован, но точка входа зависит от карточки заказа в графике. |
+| 9. Графики заказов и доставок | Готов к реализации без интеграции с 1С | Ответы заказчика и шаблон заявки получены; ручное ядро, доставки, монтажи, документы, уведомления и аудит разблокированы. Контракт обмена требует специалиста 1С. |
+| 10. Рекламации из графика заказов | ТЗ получено, реализация после основы графика | Подтверждены выбор отдельных МАФ и тип `Прочее`, но новая связь рекламации зависит от моделей заказа и его экземпляров. |
 
 ## Этап 1. Реорганизация меню и заглушки
 
-Статус: **ядро проверено пользователем; расширение под актуальный каталог реализовано и ожидает пользовательской проверки**.
+Статус: **реализован и проверен пользователем**.
 
 - [x] Проверить текущие permissions для всех существующих пунктов меню.
 - [x] Для новых пунктов-заглушек открыть доступ всем авторизованным пользователям.
@@ -205,7 +205,7 @@
 
 ## Этап 7. Техническое описание в общем каталоге
 
-Статус: **анализ источника завершён, готово к реализации**.
+Статус: **реализован и проверен пользователем**.
 
 - [x] Получить и изучить готовый Laravel-модуль техописаний `to.stroyprofit.com`.
 - [x] Составить карту данных модуля техописаний.
@@ -213,15 +213,16 @@
 - [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] Добавить перенос текстов техописаний отдельным листом общего XLSX; изображения переносятся существующим столбцом общего каталога.
+- [x] Покрыть перенос данных, настройку цены, права и варианты экспорта автоматическими тестами.
 
 ## Этап 8. Калькуляции
 
@@ -244,7 +245,7 @@
 
 ## Этап 9. Графики заказов и доставок
 
-Статус: **исходная система и обмен с 1С изучены, ожидается отдельное ТЗ**.
+Статус: **ТЗ, ответы заказчика и шаблон заявки получены; можно реализовывать весь ручной контур, кроме обмена с 1С и неоднозначной части XLS-импорта**.
 
 Старый `График отгрузок` является источником существующих данных и сценариев, но не переносится одной таблицей. Архитектура нового модуля должна оставаться нормализованной и расширяемой под дополнительные требования заказчика.
 
@@ -252,17 +253,28 @@
 - [x] Описать найденный HTTP-обмен с 1С и состав текущего payload.
 - [x] Проанализировать существующие статусы, ручные поля, вложения и несколько доставок по заказу.
 - [x] Зафиксировать обязательную нормализацию заказов, позиций, доставок и загрузок 1С.
-- [ ] Получить и согласовать отдельное ТЗ на `График заказов`.
+- [x] Получить отдельное ТЗ на `График заказов`.
+- [x] Конвертировать ТЗ в `tz-order-schedule.md`, сохранить значимый референс карточки и сопоставить требования с планом и текущим кодом.
+- [x] Получить и конвертировать ответы заказчика в `tz-order-schedule-answers.md`.
+- [x] Получить и разобрать шаблон заявки на доставку `templates/DeliveryRequest.xls`.
+- [x] Согласовать даты отгрузки, производственный календарь, несколько доставок/монтажей, роль водителя, права, уведомления и аудит.
+- [ ] Согласовать со специалистом 1С оставшиеся вопросы интеграции.
 - [ ] Уточнить стабильный внешний идентификатор заказа и передачу артикула каждой позиции из 1С.
 - [ ] Согласовать принадлежность полей источнику или ручному редактированию, чтобы повторная загрузка не уничтожала плановые данные.
+- [x] Согласовать календарь рабочих дней и значения полей дат отгрузки.
+- [x] Зафиксировать `Водитель` как отдельную роль в ролевой модели.
 - [ ] Спроектировать нормализованные таблицы и связи после получения ТЗ.
 - [ ] Спроектировать загрузку данных через 1С.
 - [ ] Реализовать безопасный идемпотентный импорт с журналом запусков и ошибок.
 - [ ] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
 - [ ] Подготовить отдельную миграцию исторических данных старого Manager с отчётом о конфликтах и дублях.
 - [ ] Добавить карточку заказа с выбором одного или нескольких МАФ.
+- [ ] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал действий.
+- [ ] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
+- [ ] Реализовать импорт/экспорт списка заказов и экспорт МАФ одного заказа.
+- [ ] Реализовать пакеты документов для монтажа, доставки и технической документации.
 - [ ] Добавить создание рекламации из заказа через текущую форму.
-- [ ] Получить и согласовать требования к `Графику доставок`, если они не войдут в ТЗ графика заказов.
+- [x] Получить базовые требования и шаблон заявки для `Графика доставок`.
 - [ ] Реализовать `График доставок` в виде недельного списка.
 - [ ] Добавить ручное планирование занятости водителей.
 - [ ] Связать доставки с адресами из графика заказов.
@@ -270,7 +282,7 @@
 
 ## Этап 10. Рекламации из графика заказов
 
-Статус: **готово после этапа 9**.
+Статус: **ТЗ получено; реализация после базовых моделей этапа 9**.
 
 - [ ] Реализовать создание рекламации из графика заказов по выбранным МАФ с типом `other` (`Прочее`).
 - [ ] В карточке заказа дать выбор одного или нескольких МАФ.
@@ -292,8 +304,8 @@
 
 ## Открытые вопросы
 
-- [ ] Когда будет передано отдельное ТЗ на `График заказов` и какие новые требования добавит заказчик?
-- [ ] Передаёт ли 1С стабильный GUID заказа и артикулы позиций, либо формат обмена нужно расширить?
+- [ ] Передаёт ли 1С стабильный GUID заказа, артикулы и идентификаторы экземпляров МАФ, либо формат обмена нужно расширить?
+- [ ] Как 1С передаёт повторную синхронизацию, аннулирование и частичное изменение состава заказа?
 - [ ] Будет ли отдельное ТЗ на `Калькуляции` и выберет ли оно внутренний расчёт в Laravel или интеграцию?
 - [ ] Где находится актуальная резервная копия или исходные Excel-данные `calc.stroyprofit.com`?
 - [ ] Какие формулы и нормативы калькулятора подтверждены заказчиком?

+ 187 - 0
docs/refactor/tz-order-schedule-answers.md

@@ -0,0 +1,187 @@
+# Ответы на вопросы по ТЗ «График заказов»
+
+Источник: `ТЗ_20_08_График_заказов_ОТВЕТЫ_НА_ВОПРОСЫ.docx`.
+
+Дата получения: **27.08.2026**.
+
+Связанные документы:
+
+- [ТЗ: График заказов от 20.08.2026](tz-order-schedule.md)
+- [ТЗ: Модуль Графики](tz-schedules.md)
+- [План реорганизации](plan.md)
+- шаблон `templates/DeliveryRequest.xls`
+
+## 1. Ответы заказчика
+
+### 1.1. Интеграция с 1С
+
+1. **Стабильный GUID заказа и уникальность номера**
+
+   Состав данных 1С пока неизвестен, требуется подключить специалиста по 1С. Номер заказа без года не уникален. На фабрике используются номера вида `1015.26`, `945.25`; идентификатор в CRM желательно согласовать с этим форматом.
+
+2. **Артикул общего каталога и идентификатор экземпляра МАФ**
+
+   Неизвестно, передаёт ли их 1С. Требуется уточнение у специалиста по 1С.
+
+3. **Первичные поля 1С и сохранение локальных изменений**
+
+   Однозначное правило не сформулировано. При аннулировании заказа все позиции перемещаются на склад; при частичном изменении часть позиций перемещается на склад, остальные остаются в заказе. Изменения юридического лица допускается вносить вручную.
+
+4. **Повторное действие `Перенести в график`**
+
+   Ожидается поведение по аналогии с ДКР: предложить заменить старую строку либо добавить новую запись в нужный день без удаления старой. Это описывает пользовательский сценарий графика, но не полностью определяет идемпотентность повторной загрузки заказа из 1С.
+
+### 1.2. Даты и производственный календарь
+
+5. **Расчёт рабочих дней**
+
+   Используется производственный календарь с праздниками и переносами, а не только понедельник–пятница.
+
+6. **Наименования дат отгрузки**
+
+   Во всех представлениях должны остаться два одинаково названных поля:
+
+   - `Дата отгрузки по договору` — прежняя плановая дата отгрузки, рассчитываемая по дате оплаты и сроку поставки;
+   - `Дата отгрузки по заявке` — прежняя дата отгрузки с фабрики.
+
+### 1.3. Доставки, монтажи и пользователи
+
+7. **Несколько доставок и этапов монтажа**
+
+   Допускается несколько доставок и несколько этапов монтажа по одному заказу. Пользователь добавляет дополнительные строки кнопкой `+`, по аналогии с добавлением запчастей в рекламации. Для каждой доставки указываются дата и водитель; для каждого этапа монтажа — собственные параметры.
+
+   Добавление в график фиксируется в журнале карточки, например: `Назначена доставка на 01.01.1990, водитель Сидоров А. А., изменил Тукалин М. В.`
+
+8. **Роль водителя**
+
+   Нужна отдельная роль `Водитель`. Пользователи этой роли выводятся в списке выбора водителя по аналогии с выбором бригадира в ДКР. Исходное ТЗ также допускает выбор пользователей роли `Бригадир`. Роль `Водитель` должна участвовать в текущей ролевой модели, чтобы администратор управлял её правами.
+
+9. **Редактирование ручных полей**
+
+   Помимо администратора адрес, примечание, доставку, монтаж, заводской номер и дату производства может редактировать `Помощник руководителя`. Доступ к этим полям должен управляться текущим механизмом ролевой модели.
+
+### 1.4. Уведомления и аудит
+
+10. **События уведомлений**
+
+    Настраиваемые события:
+
+    - создан заказ;
+    - изменён статус заказа;
+    - добавлена доставка;
+    - добавлена дата монтажа;
+    - добавлена рекламация по адресу.
+
+    Уведомления получают привязанные к заказу менеджер, водитель и бригадир, если событие и канал включены в настройках пользователя.
+
+11. **Состав аудита**
+
+    В журнал включаются только:
+
+    - изменение статуса;
+    - добавление в график доставок;
+    - добавление в график монтажей.
+
+    Изменение обычных полей, а также старые и новые значения в журнал не выводятся.
+
+### 1.5. Импорт, экспорт и файлы
+
+12. **Экспорт списка заказов**
+
+    Используется подход экспорта площадок ДКР: одна строка на заказ, оборудование суммируется и выводится в одной колонке, выгружаются все поля заказа. Экспорт учитывает активные фильтры списка.
+
+13. **Обновление ручным импортом**
+
+    Нужно предусмотреть обновление заказа из файла по внутреннему `ID` заказа.
+
+14. **Паспорт МАФ**
+
+    В строку экземпляра МАФ загружается готовый скан паспорта по аналогии с площадками ДКР. Отдельный шаблон паспорта не требуется.
+
+15. **Шаблон заявки на доставку**
+
+    Получен файл `Шаблон заявки на доставку 2026.xls`, сохранённый в проекте как `templates/DeliveryRequest.xls`.
+
+16. **Количество функциональных кнопок**
+
+    Число `5` в исходном ТЗ ошибочно. Нужно реализовать все перечисленные действия карточки.
+
+### 1.6. Рекламации и отчётность
+
+17. **Отчётность рекламаций**
+
+    Рекламации типов `Прочее` и `ДКР` должны иметь разные отчёты.
+
+## 2. Разбор шаблона заявки на доставку
+
+Файл содержит один лист `TDSheet`, печатную область `B1:AG94` и две встроенные иллюстрации в памятке водителю. Справа от печатной области находятся пояснения заказчика по заполнению полей.
+
+Данные из карточки заказа:
+
+- дата создания заявки;
+- номер и дата счёта;
+- номер и дата договора;
+- номер заказа;
+- менеджер и его телефон;
+- водитель;
+- заказчик;
+- доставка или самовывоз;
+- адрес доставки;
+- выбранная дата доставки;
+- агрегированный список оборудования;
+- примечание;
+- дата и подпись менеджера.
+
+Данные, вводимые в модальном окне при создании заявки:
+
+- перевозчик;
+- номер/дата заявки;
+- время;
+- примечание перевозчику;
+- должность, Ф. И. О. и телефон контактного лица;
+- желаемое время доставки;
+- пропускная система;
+- место выгрузки;
+- необходимость и срок предварительного предупреждения;
+- способ подписания документов;
+- наличие паспортов и сертификатов;
+- дополнительный комментарий.
+
+Технические требования к реализации:
+
+- исходный XLS сохраняется как шаблон, а заполненный результат формируется через очередь;
+- пояснения за пределами `B1:AG94` не должны попадать в печать/PDF;
+- тест должен проверять сохранение печатной области, памятки и встроенных изображений;
+- позиции заказа перед выводом агрегируются по артикулу и наименованию;
+- отдельная заявка формируется для конкретной доставки, поскольку один заказ может иметь несколько доставок.
+
+## 3. Что разблокировано
+
+- нормализованная модель заказа и отдельных экземпляров МАФ;
+- ручное создание и редактирование;
+- два однозначно названных поля отгрузки;
+- производственный календарь;
+- модель `один заказ — много доставок/монтажей`;
+- роль `Водитель` и исходные права помощника руководителя;
+- события уведомлений и состав аудита;
+- экспорт списка с учётом фильтров;
+- паспорт как готовый скан;
+- генерация заявки на доставку по полученному шаблону;
+- отдельная отчётность рекламаций типа `Прочее`.
+
+## 4. Что осталось уточнить
+
+### Блокирует только интеграцию с 1С
+
+1. Стабильный GUID/ключ заказа в payload.
+2. Артикул общего каталога и идентификатор строки или экземпляра МАФ.
+3. Полный payload и контракт API.
+4. Правила повторной синхронизации, конфликтов, аннулирования и частичного изменения состава заказа.
+
+### Нужно зафиксировать до импорта XLS
+
+5. Может ли импорт создавать новые заказы или только обновлять существующие по `ID`.
+6. Как обрабатывать пустой, неизвестный или принадлежащий другому окружению `ID`.
+7. Нужен ли отдельный лист с экземплярами МАФ при импорте, если экспорт содержит агрегированное оборудование в одной колонке.
+
+Эти вопросы не блокируют разработку ручного ядра графика заказов, доставок, монтажей, файлов, уведомлений, аудита и экспорта заявки на доставку.

BIN
docs/refactor/tz-order-schedule-card.png


+ 283 - 0
docs/refactor/tz-order-schedule.md

@@ -0,0 +1,283 @@
+# ТЗ: График заказов от 20.08.2026
+
+Источник: `ТЗ 20.08 График заказов.docx`.
+
+Статус: **получено, проанализировано и дополнено ответами заказчика от 27.08.2026; ручное ядро и внутренние сценарии готовы к реализации, интеграция с 1С требует отдельного согласования со специалистом 1С**.
+
+Связанные документы:
+
+- [План реорганизации](plan.md)
+- [ТЗ: Модуль Графики](tz-schedules.md)
+- [Ответы заказчика от 27.08.2026](tz-order-schedule-answers.md)
+- [ТЗ: Каталог общий](tz-catalog-common.md)
+- [ТЗ: Рекламации](tz-reclamations.md)
+- [ТЗ: Документация](tz-documents.md)
+
+## 1. Поля заказа
+
+| № | Поле | Источник и правила |
+|---:|---|---|
+| 1 | ID | Формируется базой данных. |
+| 2 | Номер заказа | Передаётся из 1С; администратор может редактировать. |
+| 3 | Заказчик | Передаётся из 1С; администратор может редактировать. |
+| 4 | Адрес объекта | Передаётся из 1С; поле может редактироваться. Роль для редактирования в исходном ТЗ не уточнена. |
+| 5 | № счёта | Передаётся из 1С; администратор может редактировать. |
+| 6 | № договора | Передаётся из 1С; администратор может редактировать. |
+| 7 | Дата оплаты | Передаётся из 1С; администратор может редактировать. |
+| 8 | Срок поставки | Передаётся из 1С; администратор может редактировать. |
+| 9 | Плановая дата отгрузки | Рассчитывается автоматически: дата оплаты + срок поставки в рабочих днях. |
+| 10 | Дата отгрузки с фабрики | Передаётся из 1С; видна и редактируется администратором. |
+| 11 | Статус заказа | Выбирается из фиксированного списка статусов. |
+| 12 | Тип | Выбирается вручную из фиксированного списка типов исполнения. |
+| 13 | Менеджер | Передаётся из 1С; администратор может выбрать пользователя с ролью `Менеджер`. |
+| 14 | Примечание | Свободный текст. |
+| 15 | Дата доставки | Плановая дата, после заполнения которой заказ может быть перенесён в график доставок. |
+| 16 | Водитель | Выбирается среди пользователей с ролями `Водитель` и `Бригадир`. |
+| 17 | Дата выхода на монтаж | Плановая дата, после заполнения которой заказ может быть перенесён в график монтажей. |
+| 18 | Дней на монтаж | Плановая продолжительность монтажа в днях. |
+| 19 | Бригадир | Выбирается среди пользователей с ролью `Бригадир`. |
+
+### 1.1. Статусы заказа
+
+- `Размещен`;
+- `Готов на фабрике`;
+- `В пути`;
+- `Частичное поступление`;
+- `На складе`;
+- `Закрыт`.
+
+### 1.2. Типы исполнения заказа
+
+- `Самовывоз`;
+- `Доставка`;
+- `Монтаж`;
+- `Доставка + Монтаж`;
+- `Доставка + Шеф-монтаж`;
+- `Шеф-монтаж`.
+
+## 2. Главная страница
+
+Главная страница имеет табличный вид. Для каждой колонки нужен фильтр. По умолчанию заказы сортируются по дате добавления от новых к старым, если пользователь не выбрал другую сортировку.
+
+Колонки списка:
+
+1. ID.
+2. Номер заказа.
+3. Заказчик.
+4. Адрес объекта.
+5. № счёта.
+6. Дата оплаты.
+7. Срок поставки.
+8. Дата отгрузки.
+9. Дата отгрузки по заявке — видна администратору.
+10. Статус заказа.
+11. Дата доставки.
+12. Дата монтажа.
+13. Менеджер.
+14. Примечание.
+
+## 3. Карточка заказа
+
+Карточка строится по образцу карточки площадки ДКР:
+
+- слева — поля заказа из раздела 1;
+- справа — список оборудования;
+- отдельные блоки `Документы`, `Фотографии` и `Чат площадки`.
+
+Каждая единица оборудования хранится и отображается отдельной строкой. Если заказано несколько одинаковых изделий, строка создаётся для каждого экземпляра, а не одна строка с количеством.
+
+![Референс карточки площадки ДКР](tz-order-schedule-card.png)
+
+### 3.1. Таблица оборудования
+
+| Колонка | Правило |
+|---|---|
+| Чекбокс | Используется для удаления, переноса, создания рекламации и других групповых действий. |
+| Картинка | Подгружается из общего каталога. |
+| Номер заказа МАФ | При ручном создании вводится пользователем, при загрузке передаётся из 1С; администратор может редактировать. |
+| Заводской номер | Изначально пустой, заполняется вручную. |
+| Дата производства | Изначально пустая, заполняется вручную. |
+| Паспорт | Галочка или крестик; к каждой позиции можно прикрепить скан паспорта. |
+
+### 3.2. Действия карточки
+
+- `Редактировать` — доступ определяется ролевой моделью; позволяет добавлять и удалять оборудование.
+- `Экспорт МАФ` — выгружает таблицу оборудования заказа; в шапке указываются заказчик, адрес, № счёта и № заказа.
+- `Документы для монтажа` — формирует архив по аналогии с площадкой ДКР: заполненная заявка на монтаж и документация по каждому МАФ.
+- `Документы для доставки` — формирует заявку на доставку оборудования. Шаблон будет предоставлен отдельным файлом.
+- `Перенести в график монтажей` — доступно при заполненных бригадире, дате монтажа и количестве дней монтажа.
+- `Перенести в график доставок` — доступно при заполненных водителе и дате доставки.
+- `Назад` — возвращает к общему списку.
+
+Действия под таблицей оборудования:
+
+- `Создать рекламацию` — создаёт рекламацию по выбранным МАФ;
+- `Скачать техдокументацию` — формирует архив из технической документации выбранных МАФ по аналогии с карточкой площадки ДКР.
+
+## 4. Создание заказа
+
+### 4.1. Ручное создание
+
+Обязательные исходные данные:
+
+1. Номер заказа.
+2. Заказчик.
+3. Адрес объекта.
+4. № счёта.
+5. Дата оплаты.
+6. Срок поставки.
+7. Статус заказа.
+8. Тип.
+9. Менеджер.
+10. Позиции из общего каталога с количеством и номером заказа для каждой единицы МАФ.
+
+### 4.2. Создание из 1С
+
+В 1С используется действие `Перенести в график`. CRM должна создать или обновить заказ на основании переданных данных. Точный протокол, состав payload и правила повторной передачи в исходном ТЗ не описаны.
+
+## 5. Импорт и экспорт
+
+Нужны импорт и экспорт полного списка заказов. Формат должен соответствовать табличному представлению графика заказов.
+
+## 6. Уведомления
+
+Нужны настраиваемые уведомления пользователя о событиях от создания заказа до изменения статусов по аналогии с площадками ДКР.
+
+## 7. Визуальное отображение статусов
+
+- строки заказов подсвечиваются разными цветами в зависимости от статуса;
+- закрытый заказ отображается полупрозрачным.
+
+## 8. История действий
+
+В карточке заказа отображается журнал действий только по этому заказу, в том числе:
+
+- кто и когда изменил статус;
+- кто и когда добавил заказ в график доставок;
+- кто и когда добавил заказ в график монтажей;
+- другие значимые действия с карточкой.
+
+## 9. Рекламации из графика заказов
+
+Рекламация создаётся по выбранным экземплярам МАФ и получает тип `Прочее`:
+
+- отображается в подразделе рекламаций `Все`;
+- не относится к ДКР и не участвует в отчётах ДКР;
+- сохраняет основной функционал рекламации ДКР: детали и запчасти, списание запчастей, акты, фотографии проблемы и результата, чат;
+- поле площадки отображает ссылку с наименованием заказчика и адресом поставки.
+
+Таблица оборудования рекламации:
+
+1. Картинка.
+2. МАФ (артикул).
+3. Номер заказа МАФ.
+4. Заводской номер.
+5. Дата производства.
+
+## 10. Сопоставление с текущим планом и кодовой базой
+
+### 10.1. Что подтверждено
+
+- Заказ, экземпляры оборудования, доставки, монтажи и загрузки 1С нельзя объединять в одну таблицу старого формата `graf1`.
+- Оборудование должно ссылаться на `common_catalog_items`, а не на каталог ДКР.
+- Повторяющийся артикул представлен отдельными экземплярами МАФ. Это подтверждает необходимость отдельной сущности позиции/экземпляра, а не поля `quantity` как единственного представления состава заказа.
+- Данные 1С и ручные плановые данные должны быть разделены, чтобы повторная синхронизация не уничтожала дату доставки, водителя, монтаж, комментарии и ручные идентификаторы МАФ.
+- Создание рекламации типа `Прочее` по выбранным МАФ соответствует выделенному этапу 10 плана.
+
+### 10.2. Что добавлено новым ТЗ
+
+- ручное создание и редактирование заказа наряду с загрузкой из 1С;
+- фиксированные справочники статусов и типов исполнения;
+- автоматический расчёт плановой даты отгрузки в рабочих днях;
+- карточка заказа по образцу площадки ДКР;
+- документы, фотографии и чат заказа;
+- паспорт для каждого экземпляра МАФ;
+- экспорт оборудования одного заказа и импорт/экспорт общего списка;
+- пакеты документов для монтажа и доставки;
+- скачивание технической документации выбранных МАФ;
+- настраиваемые уведомления;
+- цветовая индикация статусов;
+- журнал действий по заказу;
+- роли водителя, менеджера и бригадира в планировании;
+- явные действия переноса в графики доставок и монтажей.
+
+### 10.3. Что можно переиспользовать
+
+- общий каталог, изображения и документы `CommonCatalogItem`;
+- механизм файлов, превью и приватных сгенерированных архивов;
+- генерацию монтажного пакета ДКР после адаптации под новый тип заказа;
+- чат площадки и рекламации после добавления связи с новым заказом;
+- пользовательские уведомления по браузеру, push и email;
+- ролевую модель и управление доступом к отдельным полям;
+- текущую форму и бизнес-логику рекламаций;
+- очередь импорта, экспорта и формирования документов.
+
+### 10.4. Что нельзя переиспользовать без изменения модели
+
+- `orders` — это площадки ДКР с обязательными округом, районом и типом объекта, а не производственные заказы из нового ТЗ;
+- `products_sku` связан с каталогом ДКР `products`, тогда как новое ТЗ требует общий каталог;
+- `schedules` содержит сериализованное поле `mafs` и обязательные реквизиты ДКР, поэтому новую связь с производственным заказом нужно вводить явно;
+- `reclamations.order_id` сейчас обязательно ссылается на площадку ДКР, поэтому для рекламаций типа `Прочее` нужна отдельная нормализованная связь с новым заказом без создания фиктивной площадки ДКР;
+- в ролевой модели есть `Менеджер` и `Бригадир`, но отдельной роли `Водитель` сейчас нет.
+
+## 11. Предлагаемая модель
+
+Точные имена фиксируются на этапе проектирования. Минимально необходимы:
+
+- производственный заказ;
+- экземпляр МАФ заказа со ссылкой на `common_catalog_items`;
+- справочник или enum статусов заказа;
+- справочник или enum типов исполнения;
+- отдельные плановые записи доставки и монтажа;
+- журнал загрузок 1С с исходным payload и результатами сопоставления;
+- история значимых изменений заказа;
+- связи заказа и экземпляров МАФ с файлами, чатом и рекламациями.
+
+Даже если интерфейс первого релиза показывает одну дату доставки и одну дату монтажа, модель доставок и монтажей не должна фиксировать лимит в одну запись: в старом Manager уже существовало до трёх доставок по одному заказу.
+
+## 12. Результат согласования вопросов
+
+Ответы заказчика и разбор полученного XLS зафиксированы отдельно: [Ответы на вопросы по ТЗ](tz-order-schedule-answers.md).
+
+Согласовано:
+
+- номер заказа без года не уникален; внешний формат должен учитывать год;
+- расчёт выполняется по производственному календарю с праздниками и переносами;
+- используются названия `Дата отгрузки по договору` и `Дата отгрузки по заявке`;
+- заказ может иметь несколько доставок и несколько этапов монтажа;
+- вводится отдельная роль `Водитель`;
+- помощник руководителя получает редактирование указанных полей через ролевую модель;
+- определены события уведомлений и их получатели;
+- аудит содержит изменения статуса и добавление в графики без диффа обычных полей;
+- экспорт учитывает фильтры и агрегирует оборудование в одной колонке;
+- импорт должен уметь обновлять заказ по его внутреннему ID;
+- паспорт загружается готовым сканом;
+- реализуются все перечисленные кнопки карточки;
+- рекламации `Прочее` и `ДКР` имеют разную отчётность;
+- получен шаблон заявки на доставку.
+
+Остаются вопросы по 1С и точным правилам ручного импорта.
+
+### Блокирующие проектирование и обмен с 1С
+
+1. Какой стабильный GUID/ключ заказа передаёт 1С?
+2. Передаёт ли 1С артикул общего каталога и стабильный идентификатор каждого экземпляра/строки МАФ?
+3. Какие поля 1С являются первичными, а какие локальные изменения должны сохраняться при повторной синхронизации?
+4. Как обрабатывать повторную загрузку, аннулирование и частичное изменение состава заказа?
+
+### Требуют уточнения до реализации ручного импорта
+
+5. Может ли XLS создавать новые заказы или только обновлять существующие по `ID`?
+6. Как обрабатывать отсутствующий или неизвестный `ID`?
+7. Как импортировать отдельные экземпляры МАФ, если оборудование в общем экспорте агрегировано в одной колонке?
+
+## 13. Рекомендуемая последовательность реализации
+
+1. Спроектировать нормализованные таблицы заказов, экземпляров МАФ, доставок, монтажей и аудита.
+2. Реализовать ручное ядро независимо от будущего обмена с 1С.
+3. Реализовать справочники, права и базовую карточку с ручным созданием заказа.
+4. Добавить документы, фотографии, чат, паспорт МАФ и техническую документацию.
+5. Реализовать доставки, монтажи, уведомления, аудит и заявку на доставку.
+6. Реализовать экспорт с фильтрами; ручной импорт — после уточнения оставшихся правил.
+7. Реализовать рекламации типа `Прочее` по выбранным экземплярам МАФ.
+8. После контакта со специалистом 1С спроектировать и реализовать идемпотентный API обмена с журналом загрузок.

+ 37 - 30
docs/refactor/tz-schedules.md

@@ -4,6 +4,8 @@
 
 - [План реорганизации](plan.md)
 - [Карта нового меню](menu.md)
+- [ТЗ: График заказов от 20.08.2026](tz-order-schedule.md)
+- [Ответы заказчика от 27.08.2026](tz-order-schedule-answers.md)
 - [ТЗ: Каталог общий](tz-catalog-common.md)
 - [ТЗ: Модуль ДКР](tz-dkr.md)
 
@@ -19,7 +21,7 @@
 
 ## 2. Статус
 
-Статус модуля: **частично реализован; исходная система графика заказов изучена, финальное ТЗ ожидается**.
+Статус модуля: **частично реализован; ТЗ и ответы заказчика проанализированы, ручное ядро готово к проектированию и реализации, обмен с 1С ожидает отдельного согласования со специалистом 1С**.
 
 В текущей CRM реализован:
 
@@ -36,7 +38,6 @@
 
 - `График заказов`;
 - `График доставок`;
-- новую верхнюю группу меню `Графики`;
 - связи между графиками.
 
 После анализа `manager.stroyprofit.com` установлено:
@@ -46,7 +47,7 @@
 - payload содержит заказ, клиента, договор, оплату, доставку, менеджера и список позиций;
 - позиции сейчас не нормализуются, а сохраняются только в сформированный Excel-файл;
 - поддерживаются до трёх доставок через фиксированные колонки;
-- финальные требования нового `Графика заказов` будут переданы отдельным ТЗ и могут расширить существующие сценарии.
+- новое ТЗ от 20.08.2026 существенно расширяет сценарии: ручное создание, отдельные экземпляры МАФ, файлы и чат, документы, уведомления, аудит, импорт/экспорт и рекламации типа `Прочее`.
 
 ## 3. Место в меню
 
@@ -86,7 +87,9 @@
 
 ## 5. График заказов
 
-Статус: **исходная реализация изучена; проектирование и разработка начинаются после отдельного ТЗ**.
+Статус: **ТЗ и ответы заказчика получены; ручное ядро, доставки, монтажи, уведомления, аудит и документы готовы к реализации независимо от будущего обмена с 1С**.
+
+Полный состав требований и анализ: [ТЗ: График заказов от 20.08.2026](tz-order-schedule.md), [ответы заказчика от 27.08.2026](tz-order-schedule-answers.md).
 
 Назначение:
 
@@ -103,7 +106,7 @@
 - связь с `Каталог общий`;
 - связь с `График монтажей`.
 
-Архитектурные требования, принятые до получения финального ТЗ:
+Архитектурные требования с учётом нового ТЗ:
 
 - не переносить таблицу `graf1` как есть;
 - хранить заказ, позиции заказа, доставки и загрузки 1С отдельными нормализованными сущностями;
@@ -143,13 +146,11 @@
 
 Открытые вопросы:
 
-- какой финальный состав полей и сценариев будет утверждён отдельным ТЗ;
 - может ли 1С передавать стабильный GUID заказа вместо ключа, собранного из номера и года;
-- может ли 1С передавать артикул позиции для связи с `Каталог общий`;
-- нужен ли ручной ввод/редактирование или график полностью загружается;
-- какие статусы производства должны отображаться;
+- может ли 1С передавать артикул общего каталога и идентификатор каждого экземпляра МАФ;
 - какие поля принадлежат 1С, а какие должны сохраняться при повторной синхронизации;
-- какие части старых заявок на доставку и монтаж нужно сохранить в новом модуле.
+- как 1С сообщает аннулирование и частичное изменение состава заказа;
+- может ли ручной XLS-импорт создавать новые заказы и как передавать в нём отдельные экземпляры МАФ.
 
 ## 6. График доставок
 
@@ -184,12 +185,15 @@
 
 Открытые вопросы:
 
-- где хранится список водителей;
-- нужна ли отдельная сущность `Водитель`;
 - какие статусы есть у доставки;
 - нужна ли связь с отдельным разделом `Отгрузки`;
-- требуется ли печатная форма или экспорт недельного списка;
-- как обрабатывать несколько доставок по одному заказу.
+- нужен ли отдельный экспорт недельного списка помимо полученного шаблона заявки на одну доставку.
+
+Согласовано:
+
+- водитель выбирается среди пользователей роли `Водитель` и, согласно исходному ТЗ, `Бригадир`;
+- один заказ может иметь несколько отдельных записей доставки;
+- получен шаблон печатной заявки на конкретную доставку `templates/DeliveryRequest.xls`.
 
 ## 7. График монтажей
 
@@ -247,16 +251,18 @@
 
 Для `График монтажей` использовать текущие модели и таблицы.
 
-Для `График заказов` и `График доставок` модели проектируются отдельно после получения финального ТЗ. Независимо от точных имён таблиц модель должна быть нормализована.
+Для `График заказов` и `График доставок` проектируются отдельные модели. Полученные ответы позволяют начинать проектирование ручного контура; модели интеграционных загрузок уточняются после согласования контракта с 1С. Независимо от точных имён таблиц модель должна быть нормализована.
 
 Минимально потребуются сущности:
 
 - производственный заказ;
-- позиция производственного заказа со ссылкой на `Каталог общий`, когда сопоставление возможно;
+- отдельный экземпляр МАФ производственного заказа со ссылкой на `Каталог общий`, когда сопоставление возможно;
 - произвольное количество доставок по заказу;
+- произвольное количество монтажных этапов по заказу;
 - водитель или ответственный доставки;
-- журнал загрузок из 1С.
+- журнал загрузок из 1С;
 - журнал ошибок и результатов сопоставления;
+- журнал значимых изменений заказа;
 - безопасно сохранённый исходный payload или ссылка на него для диагностики.
 
 Запрещённые варианты модели:
@@ -269,9 +275,11 @@
 Открыто:
 
 - точные имена таблиц и моделей;
-- структура полей;
-- необходимость истории изменений;
-- связь с существующими `Order`, `Product`, `ProductSKU`;
+- стабильные идентификаторы заказа и экземпляров МАФ в 1С;
+- принадлежность синхронизируемых и локально редактируемых полей;
+- источник и обновление производственного календаря;
+- формат импорта отдельных экземпляров МАФ и обработка отсутствующего/неизвестного ID;
+- способ связи с существующими `Order`, `ProductSKU`, `Schedule` и `Reclamation` без смешивания с ДКР;
 - правила миграции дублей и некорректных дат старого `graf1`.
 
 ## 10. Первый этап реализации
@@ -310,8 +318,14 @@
 - [x] Проверить экспорт графика монтажей.
 - [x] Изучить старый график заказов/отгрузок и существующий обмен с 1С.
 - [x] Зафиксировать нормализованную модель как обязательный архитектурный принцип.
-- [ ] Получить отдельное ТЗ и составить по нему финальный список полей графика заказов.
-- [ ] Составить отдельный список полей для будущего графика доставок.
+- [x] Получить отдельное ТЗ на график заказов.
+- [x] Конвертировать ТЗ от 20.08.2026 в Markdown, сохранить референс интерфейса и сопоставить требования с планом и кодовой базой.
+- [x] Получить и проанализировать ответы заказчика от 27.08.2026.
+- [x] Согласовать календарь, даты отгрузки, несколько доставок/монтажей, роль водителя, права, уведомления и аудит.
+- [x] Получить и разобрать шаблон заявки на доставку `templates/DeliveryRequest.xls`.
+- [ ] Согласовать со специалистом 1С контракт API, идентификаторы и правила повторной синхронизации.
+- [ ] Уточнить создание новых заказов и передачу экземпляров МАФ через ручной XLS-импорт.
+- [x] Составить базовый список полей для графика доставок.
 - [ ] Реализовать карточку заказа с выбором одного или нескольких МАФ.
 - [ ] Добавить действие `Добавить рекламацию`.
 - [ ] Открывать текущую форму рекламации с переданными заказом и выбранными МАФ.
@@ -333,11 +347,4 @@
 
 ## 13. Открытые вопросы
 
-- Какие дополнительные требования войдут в отдельное ТЗ графика заказов?
-- Передаёт ли 1С стабильный GUID заказа и артикулы позиций?
-- Какие поля должны отображаться в графике заказов по финальному ТЗ?
-- Нужен ли ручной ввод в графике заказов?
-- Где хранить список водителей для графика доставок?
-- Нужен ли отдельный модуль `Отгрузки`, или график доставок закрывает эту задачу?
-- Какие статусы нужны для доставок?
-- Как должна выглядеть связь между графиком заказов и графиком монтажей?
+Актуальный перечень сформирован в [ответах заказчика](tz-order-schedule-answers.md#4-что-осталось-уточнить). Вопросы по 1С блокируют только интеграцию с 1С. Для ручного импорта отдельно нужно уточнить создание новых заказов, обработку неизвестного ID и передачу экземпляров МАФ.

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

@@ -15,7 +15,7 @@
 
 ## 2. Статус
 
-Статус: **источник изучен, встроить в модуль `Каталог общий`**.
+Статус: **реализовано в модуле `Каталог общий` и проверено пользователем**.
 
 Laravel-модуль `to.stroyprofit.com` изучен и используется как источник:
 
@@ -26,6 +26,8 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 В источнике находятся 938 позиций из 24 серий. Для всех позиций заполнены цена, характеристики, полное и краткое техническое описание, группа, название для формы и изображение. Наличие одиночной цены в старом модуле зафиксировано как факт анализа; в новой CRM цены хранятся у позиции общего каталога по восьми отдельным видам.
 
+Для переноса между окружениями экспорт общего каталога содержит необязательный лист `Техописания` с текстовыми полями. Импорт понимает как системный экспорт с этим листом, так и исходный 30-колоночный файл без него. Изображение техописания является изображением позиции общего каталога и переносится существующим столбцом `Внешний вид`.
+
 ## 3. Место в меню
 
 Отдельный пункт меню `Технич. описание` не нужен. Целевая модель:
@@ -64,6 +66,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - использование готового Laravel-модуля как основы для экспорта;
 - хранение данных в структуре общего каталога или связанной таблице, если данные нельзя уложить в основную карточку позиции.
 - получение согласованного вида цены для DOCX из `Каталог общий` без собственной копии цены в техописании.
+- выбор администратором вида цены в системных настройках; значение по умолчанию — `проект`.
 
 ## 6. Что не переносим из готового модуля
 
@@ -98,7 +101,9 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 В текущем `common_catalog_items` отсутствуют готовые поля для названия печатной формы, текстовых характеристик, полного и краткого технического описания. Эти описательные поля должны дополнять общий каталог.
 
-Техописание не хранит собственную копию цены. При формировании DOCX оно читает согласованный вид цены непосредственно из позиции `Каталог общий`. Старое одиночное поле `price` переносится только после сопоставления с одним из восьми новых видов цены.
+Техописание не хранит собственную копию цены. При формировании DOCX оно читает выбранный администратором вид цены непосредственно из позиции `Каталог общий`. По умолчанию используется поле `project_price` (`проект`). Старое одиночное поле `price` не переносится.
+
+Экспорт доступен только пользователю, имеющему право просмотра выбранного ценового поля. Поэтому DOCX не позволяет обойти действующее ограничение видимости цен.
 
 ## 8. Этапы реализации
 
@@ -108,14 +113,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 +134,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

+ 31 - 9
resources/views/partials/newFilterElement.blade.php

@@ -1,13 +1,13 @@
 <div class="dropdown-menu filter-menu-wide" aria-labelledby="{{$id}}">
     <div class="px-1">
-        <div class="d-flex mb-2 {{ $isSort ? '' : 'd-none' }}">
-            <div class="me-3">Сортировка</div>
-            <div class="d-flex filter-sort-controls">
-                <button type="button" class="btn btn-outline-secondary btn-sm w-100" id="sort-by-asc-{{$id}}"><i class="bi bi-arrow-up"></i>ASC</button>
-                <button type="button" class="btn btn-outline-secondary btn-sm w-100" id="sort-by-desc-{{$id}}"><i class="bi bi-arrow-down"></i>DESC</button>
+        <div class="d-flex align-items-center gap-2 mb-2 {{ $isSort ? '' : 'd-none' }}">
+            <div>Сортировка</div>
+            <div class="d-flex flex-grow-1 gap-1 filter-sort-controls">
+                <button type="button" class="btn btn-outline-secondary btn-sm flex-fill text-nowrap" id="sort-by-asc-{{$id}}"><i class="bi bi-arrow-up me-1"></i>ASC</button>
+                <button type="button" class="btn btn-outline-secondary btn-sm flex-fill text-nowrap" id="sort-by-desc-{{$id}}"><i class="bi bi-arrow-down me-1"></i>DESC</button>
             </div>
         </div>
-        @if($type === 'ranges')
+        @if(in_array($type, ['ranges', 'dates'], true))
             @php
                 $inputType = $type === 'dates' ? 'date' : 'number';
                 $fromKey = $id . '_from';
@@ -116,6 +116,8 @@
 
                 let filterData = @json(isset($data['values']) ? array_values(array_keys($data['values'])) : []);
                 let sortAsc = true;
+                let filterDataLoaded = filterData.length > 0;
+                let filterDataLoading = false;
 
                 function renderFilterList(data) {
                     const html = data.map(item => `
@@ -146,13 +148,23 @@
                     });
                 }
 
-                if (filterData.length) {
-                    renderFilterList(sortData(sortAsc));
-                } else {
+                async function loadFilterData() {
+                    if (filterDataLoaded || filterDataLoading) {
+                        return;
+                    }
+
+                    filterDataLoading = true;
+                    $container.html('<div class="text-muted">Загрузка...</div>');
+
                     try {
                         const response = await fetch(`{!! route('getFilters', ['column' => $id, 'table' => $table]) !!}`);
+                        if (!response.ok) {
+                            throw new Error(`HTTP ${response.status}`);
+                        }
                         const data = await response.json();
 
+                        filterDataLoaded = true;
+
                         if (Array.isArray(data) && data.length) {
                             if(data[0] === null) data[0] = '-пусто-';
                             filterData = data;
@@ -168,9 +180,19 @@
                     } catch (error) {
                         console.error("Ошибка при загрузке фильтров:", error);
                         $container.html('<div class="text-danger">Ошибка загрузки</div>');
+                    } finally {
+                        filterDataLoading = false;
                     }
                 }
 
+                if (filterDataLoaded) {
+                    renderFilterList(sortData(sortAsc));
+                } else {
+                    const $filterToggle = $("#{{$id}}");
+                    $filterToggle.on("click", loadFilterData);
+                    $filterToggle.closest(".dropdown").on("show.bs.dropdown", loadFilterData);
+                }
+
                 $sortBtn.on("click", function (e) {
                     e.preventDefault();
                     sortAsc = !sortAsc;

+ 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"
                     >

+ 10 - 10
resources/views/stock/index.blade.php

@@ -35,15 +35,15 @@
             <thead class="table-light">
             <tr>
                 <th>Картинка</th>
-                <th>Артикул</th>
-                <th>Наименование</th>
-                <th>Характеристики</th>
-                <th>Ед.</th>
-                <th class="text-end">На складе</th>
-                <th class="text-end">Забронировано</th>
-                <th class="text-end">Остаток</th>
-                <th class="text-end">Заказано</th>
-                <th>Примечание</th>
+                @include('stock.partials.column-header', ['name' => 'article', 'title' => 'Артикул'])
+                @include('stock.partials.column-header', ['name' => 'calculator_name', 'title' => 'Наименование'])
+                @include('stock.partials.column-header', ['name' => 'kind', 'title' => 'Характеристики'])
+                @include('stock.partials.column-header', ['name' => 'unit', 'title' => 'Ед.'])
+                @include('stock.partials.column-header', ['name' => 'physical_quantity', 'title' => 'На складе', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'reserved_quantity', 'title' => 'Забронировано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'available_stock_quantity', 'title' => 'Остаток', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'ordered_stock_quantity', 'title' => 'Заказано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'latest_order_note', 'title' => 'Примечание'])
             </tr>
             </thead>
             <tbody>
@@ -75,7 +75,7 @@
                     <td class="text-end">{{ $reserved }}</td>
                     <td class="text-end fw-semibold">{{ max(0, $physical - $reserved) }}</td>
                     <td class="text-end">{{ (int) ($item->ordered_stock_quantity ?? 0) }}</td>
-                    <td>{{ $item->latestStockOrder?->note }}</td>
+                    <td>{{ $item->latest_order_note }}</td>
                 </tr>
             @empty
                 <tr><td colspan="10" class="text-center text-muted py-4">Складские позиции не найдены.</td></tr>

+ 9 - 3
resources/views/stock/orders.blade.php

@@ -42,9 +42,15 @@
         <table class="table table-sm table-bordered table-hover align-middle">
             <thead class="table-light">
             <tr>
-                <th>№ заказа</th><th>Артикул / наименование</th><th>Статус</th>
-                <th class="text-end">Заказано</th><th class="text-end">Бронь</th><th class="text-end">Остаток</th>
-                <th>Примечание</th><th>Добавлен</th><th></th>
+                @include('stock.partials.column-header', ['name' => 'order_number', 'title' => '№ заказа'])
+                @include('stock.partials.column-header', ['name' => 'item_article', 'title' => 'Артикул / наименование'])
+                @include('stock.partials.column-header', ['name' => 'status_name', 'title' => 'Статус'])
+                @include('stock.partials.column-header', ['name' => 'ordered_quantity', 'title' => 'Заказано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'reserved_quantity', 'title' => 'Бронь', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'free_quantity', 'title' => 'Остаток', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'note', 'title' => 'Примечание'])
+                @include('stock.partials.column-header', ['name' => 'created_at', 'title' => 'Добавлен'])
+                <th></th>
             </tr>
             </thead>
             <tbody>

+ 33 - 0
resources/views/stock/partials/column-header.blade.php

@@ -0,0 +1,33 @@
+@php
+    $isCurrentSortColumn = $sortBy === $name;
+    $isFiltered = request()->filled("filters.{$name}")
+        || request()->filled("filters.{$name}_from")
+        || request()->filled("filters.{$name}_to");
+    $filter = $columnFilters[$name] ?? [];
+@endphp
+
+<th @class([$class ?? null])>
+    <div class="d-flex align-items-center justify-content-between gap-1">
+        <span>{{ $title }}</span>
+        <div class="d-flex align-items-center gap-1 text-nowrap">
+            @if($isCurrentSortColumn)
+                <i class="bi {{ $orderBy === 'desc' ? 'bi-arrow-up-square-fill' : 'bi-arrow-down-square-fill' }} text-primary"></i>
+            @endif
+            <div class="dropdown d-inline-block" data-bs-auto-close="outside">
+                <i id="{{ $name }}"
+                   class="dropdown-toggle bi {{ $isFiltered ? 'bi-funnel-fill text-danger' : 'bi-funnel' }} cursor-pointer"
+                   data-bs-toggle="dropdown"
+                   data-bs-auto-close="outside"
+                   aria-expanded="false"></i>
+                @include('partials.newFilterElement', [
+                    'id' => $name,
+                    'data' => $filter['data'] ?? null,
+                    'type' => $filter['type'] ?? null,
+                    'table' => $tableId,
+                    'isSort' => true,
+                    'orderBy' => $orderBy,
+                ])
+            </div>
+        </div>
+    </div>
+</th>

+ 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/DeliveryRequest.xls


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');

+ 140 - 0
tests/Feature/CommonCatalogControllerTest.php

@@ -5,8 +5,10 @@ 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\File;
 use App\Models\Role;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -56,6 +58,75 @@ class CommonCatalogControllerTest extends TestCase
             ->assertSee($item->calculator_name);
     }
 
+    public function test_common_catalog_column_filter_values_are_available(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-001',
+            'builders_price' => 123456.78,
+        ]);
+        $deletedItem = CommonCatalogItem::factory()->create(['article' => 'FILTER-DELETED']);
+        $deletedItem->delete();
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'article',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['FILTER-001'])
+            ->assertJsonMissing(['FILTER-DELETED']);
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'builders_price_txt',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment([123456.78]);
+    }
+
+    public function test_common_catalog_column_filter_is_applied(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-MATCH',
+            'calculator_name' => 'Нужная позиция',
+            'builders_price' => 123456.78,
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-OTHER',
+            'calculator_name' => 'Другая позиция',
+            'builders_price' => 987654.32,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.index', [
+                'filters' => ['article' => 'FILTER-MATCH'],
+            ]))
+            ->assertOk()
+            ->assertSee('Нужная позиция')
+            ->assertDontSee('Другая позиция');
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.index', [
+                'filters' => ['builders_price_txt' => '123456.78'],
+            ]))
+            ->assertOk()
+            ->assertSee('Нужная позиция')
+            ->assertDontSee('Другая позиция');
+    }
+
+    public function test_common_catalog_filter_values_respect_field_access(): void
+    {
+        CommonCatalogItem::factory()->create(['builders_price' => 123456.78]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'builders_price_txt',
+            ]))
+            ->assertForbidden();
+    }
+
     public function test_manager_cannot_mutate_common_catalog(): void
     {
         $this->actingAs($this->manager)
@@ -203,6 +274,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');

+ 128 - 0
tests/Feature/StockControllerTest.php

@@ -62,6 +62,76 @@ class StockControllerTest extends TestCase
             ->assertSeeText('STOCK-001');
     }
 
+    public function test_availability_supports_column_filters_and_computed_sorting(): void
+    {
+        $smallerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-SMALL']);
+        $largerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-LARGE']);
+        $smallerOrder = $this->createOrder($smallerItem, 'SMALL-ORDER', 2);
+        $this->createOrder($largerItem, 'LARGE-ORDER', 7);
+        $smallerOrder->update(['note' => 'Примечание малой партии']);
+        $this->inventory->reserve($largerItem, $this->manager, 2, null, $this->admin);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['article' => 'STOCK-SMALL'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['physical_quantity_from' => 5],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['latest_order_note' => 'Примечание малой партии'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['reserved_quantity_from' => 1, 'available_stock_quantity_from' => 5],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'sortBy' => 'physical_quantity',
+                'order' => 'desc',
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === [
+                'STOCK-LARGE',
+                'STOCK-SMALL',
+            ]);
+    }
+
+    public function test_stock_column_filter_values_are_loaded_lazily(): void
+    {
+        $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-FILTER-VALUE']);
+        $this->createOrder($item, 'FILTER-ORDER', 1);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_availability',
+                'column' => 'article',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['STOCK-FILTER-VALUE']);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_orders',
+                'column' => 'order_number',
+            ]))
+            ->assertForbidden();
+    }
+
     public function test_orders_are_available_only_to_admin_and_assistant_head(): void
     {
         $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
@@ -69,6 +139,64 @@ class StockControllerTest extends TestCase
         $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
     }
 
+    public function test_stock_orders_support_column_filters_and_related_sorting(): void
+    {
+        $firstItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-A']);
+        $secondItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-B']);
+        $firstOrder = $this->createOrder($firstItem, 'ORDER-A', 2, StockOrder::STATUS_ORDERED);
+        $secondOrder = $this->createOrder($secondItem, 'ORDER-B', 5);
+        $firstOrder->forceFill(['created_at' => '2026-01-10 10:00:00'])->save();
+        $secondOrder->forceFill(['created_at' => '2026-02-10 10:00:00'])->save();
+        $this->inventory->reserve($secondItem, $this->manager, 2, null, $this->admin);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['status_name' => 'Заказан'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-A']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['ordered_quantity_from' => 4],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['created_at_from' => '2026-02-01', 'created_at_to' => '2026-02-28'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['reserved_quantity_from' => 1, 'free_quantity_from' => 3],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'sortBy' => 'item_article',
+                'order' => 'desc',
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === [
+                'ORDER-B',
+                'ORDER-A',
+            ]);
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_orders',
+                'column' => 'status_name',
+            ]))
+            ->assertOk()
+            ->assertExactJson(['Заказан', 'На складе']);
+    }
+
     public function test_admin_can_create_order_linked_to_common_catalog(): void
     {
         $item = CommonCatalogItem::factory()->create();

+ 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());
     }
 }

+ 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;
+    }
+}

+ 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);

+ 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',
+        ];
+    }
+}