Sfoglia il codice sorgente

updated common catalog format and prices

Alexander Musikhin 1 giorno fa
parent
commit
cc37675725

+ 138 - 24
app/Http/Controllers/CommonCatalogController.php

@@ -10,6 +10,7 @@ use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
 use App\Models\Import;
+use App\Services\Access\FieldAccessService;
 use App\Services\FileService;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\RedirectResponse;
@@ -28,9 +29,13 @@ class CommonCatalogController extends Controller
             'id' => 'ID',
             'image' => 'Внешний вид',
             'article' => 'Артикул',
-            'calculator_name' => 'Наименование по калькулятору',
+            'calculator_name' => 'Наименование',
             'kind' => 'Вид',
-            'dimensions' => 'Габаритные размеры',
+            'dimension_length' => 'Габариты: длина',
+            'dimension_width' => 'Габариты: ширина',
+            'dimension_height' => 'Габариты: высота',
+            'site_length' => 'Участок: длина',
+            'site_width' => 'Участок: ширина',
             'fall_height' => 'Высота падения',
             'additional_info' => 'Дополнительные сведения',
             'dimension_unit' => 'Единица измерения габаритов',
@@ -43,43 +48,77 @@ class CommonCatalogController extends Controller
             'unit' => 'Ед.',
             'series' => 'Серия',
             'trademark' => 'ТМ',
-            'note' => 'Примечание',
+            'calculator_enabled_txt' => 'Калькулятор',
+            'builders_price_txt' => 'строители',
+            'wholesale_price_txt' => 'опт',
+            'recommended_price_txt' => 'рек',
+            'retail_price_txt' => 'розница',
+            'project_price_txt' => 'проект',
+            'project_with_installation_price_txt' => 'проект+м',
+            'pik_price_txt' => 'пик',
+            'recommended_plus_10_price_txt' => 'рек+10',
         ],
         'searchFields' => [
             'article',
             'calculator_name',
             'kind',
-            'dimensions',
+            'dimension_length',
+            'dimension_width',
+            'dimension_height',
+            'site_length',
+            'site_width',
             'additional_info',
             'composition',
             'series',
             'trademark',
-            'note',
         ],
     ];
 
-    public function index(Request $request): View
+    public function index(Request $request, FieldAccessService $fieldAccess): View
     {
         session(['gp_common_catalog' => $request->query()]);
         $nav = $this->startNavigationContext($request);
         $model = new CommonCatalogItem;
+        $readableFields = $this->readableFields($request, $fieldAccess);
+        $request = $this->sanitizeListRequest($request, $readableFields);
+        $this->data['header'] = $fieldAccess->visibleHeaders(
+            $request->user(),
+            'common-catalog',
+            $this->data['header'],
+        );
+        $this->data['searchFields'] = array_values(array_intersect(
+            $this->data['searchFields'],
+            $readableFields,
+        ));
 
         $this->createFilters(
             $model,
-            'kind',
-            'dimension_unit',
-            'age_group',
-            'unit',
-            'series',
-            'trademark',
+            ...array_values(array_intersect([
+                'kind',
+                'dimension_unit',
+                'age_group',
+                'unit',
+                'series',
+                'trademark',
+            ], $readableFields)),
         );
         $this->createRangeFilters(
             $model,
-            'fall_height',
-            'weight',
-            'volume',
-            'places',
-            'max_users',
+            ...array_values(array_intersect([
+                'fall_height',
+                'weight',
+                'volume',
+                'places',
+                'max_users',
+                'builders_price',
+                'wholesale_price',
+                'recommended_price',
+                'retail_price',
+                'project_price',
+                'project_with_installation_price',
+                'pik_price',
+                'recommended_plus_10_price',
+            ], $readableFields)),
         );
 
         $query = CommonCatalogItem::query()->with('imageFile');
@@ -94,22 +133,36 @@ class CommonCatalogController extends Controller
         return view('common_catalog.index', $this->data);
     }
 
-    public function create(Request $request): View
+    public function create(Request $request, FieldAccessService $fieldAccess): View
     {
-        return $this->itemView($request);
+        return $this->itemView($request, $fieldAccess);
     }
 
-    public function show(Request $request, CommonCatalogItem $commonCatalogItem): View
+    public function show(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FieldAccessService $fieldAccess,
+    ): View
     {
         return $this->itemView(
             $request,
+            $fieldAccess,
             $commonCatalogItem->load(['imageFile', 'documents']),
         );
     }
 
-    public function store(StoreCommonCatalogItemRequest $request): RedirectResponse
+    public function store(
+        StoreCommonCatalogItemRequest $request,
+        FieldAccessService $fieldAccess,
+    ): RedirectResponse
     {
-        $item = CommonCatalogItem::query()->create($request->validated());
+        $item = CommonCatalogItem::query()->create(
+            $fieldAccess->filterValidatedPayload(
+                $request->user(),
+                'common-catalog',
+                $request->validated(),
+            ),
+        );
 
         return redirect()
             ->route('common-catalog.show', $this->withNav(
@@ -122,8 +175,13 @@ class CommonCatalogController extends Controller
     public function update(
         StoreCommonCatalogItemRequest $request,
         CommonCatalogItem $commonCatalogItem,
+        FieldAccessService $fieldAccess,
     ): RedirectResponse {
-        $commonCatalogItem->update($request->validated());
+        $commonCatalogItem->update($fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'common-catalog',
+            $request->validated(),
+        ));
 
         return $this->redirectToItem($request, $commonCatalogItem)
             ->with('success', 'Позиция общего каталога обновлена.');
@@ -278,7 +336,11 @@ class CommonCatalogController extends Controller
             ->with('success', 'Задача импорта общего каталога создана.');
     }
 
-    private function itemView(Request $request, ?CommonCatalogItem $item = null): View
+    private function itemView(
+        Request $request,
+        FieldAccessService $fieldAccess,
+        ?CommonCatalogItem $item = null,
+    ): View
     {
         $nav = $this->resolveNavToken($request);
         $this->rememberNavigation($request, $nav);
@@ -289,10 +351,62 @@ class CommonCatalogController extends Controller
             route('common-catalog.index', session('gp_common_catalog')),
         );
         $this->data['item'] = $item;
+        $fields = array_keys(config('access.common-catalog.fields', []));
+        $this->data['commonCatalogReadableFields'] = array_fill_keys(
+            $fieldAccess->filterReadableFields($request->user(), 'common-catalog', $fields),
+            true,
+        );
+        $this->data['commonCatalogWritableFields'] = [];
+        foreach ($fields as $field) {
+            $this->data['commonCatalogWritableFields'][$field] = $request->user()
+                ->canUpdateField('common-catalog', $field);
+        }
 
         return view('common_catalog.edit', $this->data);
     }
 
+    private function readableFields(Request $request, FieldAccessService $fieldAccess): array
+    {
+        return $fieldAccess->filterReadableFields(
+            $request->user(),
+            'common-catalog',
+            array_keys(config('access.common-catalog.fields', [])),
+        );
+    }
+
+    private function sanitizeListRequest(Request $request, array $readableFields): Request
+    {
+        if (
+            $request->filled('sortBy')
+            && ! in_array($this->normalizeField($request->string('sortBy')->toString()), $readableFields, true)
+        ) {
+            $request->merge(['sortBy' => CommonCatalogItem::DEFAULT_SORT_BY]);
+        }
+
+        if ($request->has('filters') && is_array($request->input('filters'))) {
+            $request->merge([
+                'filters' => array_filter(
+                    $request->input('filters'),
+                    fn (mixed $value, string $field): bool => in_array(
+                        $this->normalizeField($field),
+                        $readableFields,
+                        true,
+                    ),
+                    ARRAY_FILTER_USE_BOTH,
+                ),
+            ]);
+        }
+
+        return $request;
+    }
+
+    private function normalizeField(string $field): string
+    {
+        $field = preg_replace('/_(from|to)$/', '', $field) ?: $field;
+
+        return str_ends_with($field, '_txt') ? substr($field, 0, -4) : $field;
+    }
+
     private function redirectToItem(
         Request $request,
         CommonCatalogItem $commonCatalogItem,

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

@@ -48,7 +48,10 @@ class StockOrderController extends Controller
         return view('stock.orders', [
             'active' => 'stock_orders',
             'orders' => $orders,
-            'items' => CommonCatalogItem::query()->orderBy('article')->get(['id', 'article', 'calculator_name']),
+            'items' => CommonCatalogItem::query()
+                ->orderBy('article')
+                ->orderBy('calculator_name')
+                ->get(['id', 'article', 'calculator_name']),
             'orderedOrderNumbers' => StockOrder::query()
                 ->where('status', StockOrder::STATUS_ORDERED)
                 ->whereNotNull('order_number')

+ 44 - 8
app/Http/Requests/StoreCommonCatalogItemRequest.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Http\Requests;
 
 use App\Models\CommonCatalogItem;
+use App\Services\Access\AccessService;
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Validation\Rule;
 
@@ -12,25 +13,43 @@ class StoreCommonCatalogItemRequest extends FormRequest
 {
     public function authorize(): bool
     {
-        return true;
+        if (! $this->user()) {
+            return false;
+        }
+
+        if (! $this->routeIs('common-catalog.store')) {
+            return true;
+        }
+
+        $access = app(AccessService::class);
+
+        return $access->canUpdateField($this->user(), 'common-catalog', 'article')
+            && $access->canUpdateField($this->user(), 'common-catalog', 'calculator_name');
     }
 
     public function rules(): array
     {
         $item = $this->route('commonCatalogItem');
 
-        return [
+        $rules = [
             'article' => [
                 'required',
                 'string',
                 'max:100',
-                Rule::unique('common_catalog_items', 'article')->ignore(
-                    $item instanceof CommonCatalogItem ? $item->getKey() : null,
-                ),
+                Rule::unique('common_catalog_items', 'article')
+                    ->where(fn ($query) => $query->where(
+                        'calculator_name',
+                        (string) $this->input('calculator_name'),
+                    ))
+                    ->ignore($item instanceof CommonCatalogItem ? $item->getKey() : null),
             ],
-            'calculator_name' => ['required', 'string'],
+            'calculator_name' => ['required', 'string', 'max:255'],
             'kind' => ['nullable', 'string', 'max:255'],
-            'dimensions' => ['nullable', 'string'],
+            'dimension_length' => ['nullable', 'string', 'max:255'],
+            'dimension_width' => ['nullable', 'string', 'max:255'],
+            'dimension_height' => ['nullable', 'string', 'max:255'],
+            'site_length' => ['nullable', 'string', 'max:255'],
+            'site_width' => ['nullable', 'string', 'max:255'],
             'fall_height' => ['nullable', 'numeric', 'min:0'],
             'additional_info' => ['nullable', 'string'],
             'dimension_unit' => ['nullable', 'string', 'max:50'],
@@ -43,7 +62,24 @@ class StoreCommonCatalogItemRequest extends FormRequest
             'unit' => ['nullable', 'string', 'max:50'],
             'series' => ['nullable', 'string', 'max:255'],
             'trademark' => ['nullable', 'string', 'max:255'],
-            'note' => ['nullable', 'string'],
+            'calculator_enabled' => ['nullable', 'boolean'],
+            'builders_price' => ['nullable', 'numeric', 'min:0'],
+            'wholesale_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_price' => ['nullable', 'numeric', 'min:0'],
+            'retail_price' => ['nullable', 'numeric', 'min:0'],
+            'project_price' => ['nullable', 'numeric', 'min:0'],
+            'project_with_installation_price' => ['nullable', 'numeric', 'min:0'],
+            'pik_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
         ];
+
+        $access = app(AccessService::class);
+        foreach (array_keys($rules) as $field) {
+            if (! $access->canUpdateField($this->user(), 'common-catalog', $field)) {
+                unset($rules[$field]);
+            }
+        }
+
+        return $rules;
     }
 }

+ 138 - 1
app/Models/CommonCatalogItem.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+use App\Helpers\Price;
 use Database\Factories\CommonCatalogItemFactory;
 use Illuminate\Database\Eloquent\Casts\Attribute;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -28,6 +29,11 @@ class CommonCatalogItem extends Model
         'article',
         'calculator_name',
         'kind',
+        'dimension_length',
+        'dimension_width',
+        'dimension_height',
+        'site_length',
+        'site_width',
         'dimensions',
         'fall_height',
         'additional_info',
@@ -41,10 +47,30 @@ class CommonCatalogItem extends Model
         'unit',
         'series',
         'trademark',
+        'calculator_enabled',
+        'builders_price',
+        'wholesale_price',
+        'recommended_price',
+        'retail_price',
+        'project_price',
+        'project_with_installation_price',
+        'pik_price',
+        'recommended_plus_10_price',
         'note',
     ];
 
-    protected $appends = ['image'];
+    protected $appends = [
+        'image',
+        'calculator_enabled_txt',
+        'builders_price_txt',
+        'wholesale_price_txt',
+        'recommended_price_txt',
+        'retail_price_txt',
+        'project_price_txt',
+        'project_with_installation_price_txt',
+        'pik_price_txt',
+        'recommended_plus_10_price_txt',
+    ];
 
     protected function casts(): array
     {
@@ -54,9 +80,101 @@ class CommonCatalogItem extends Model
             'volume' => 'decimal:3',
             'places' => 'integer',
             'max_users' => 'integer',
+            'calculator_enabled' => 'boolean',
         ];
     }
 
+    protected function buildersPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function wholesalePrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function recommendedPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function retailPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function projectPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function projectWithInstallationPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function pikPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function recommendedPlus10Price(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function calculatorEnabledTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn (): string => match ($this->calculator_enabled) {
+                true => 'да',
+                false => 'нет',
+                null => '',
+            },
+        );
+    }
+
+    protected function buildersPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('builders_price');
+    }
+
+    protected function wholesalePriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('wholesale_price');
+    }
+
+    protected function recommendedPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('recommended_price');
+    }
+
+    protected function retailPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('retail_price');
+    }
+
+    protected function projectPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('project_price');
+    }
+
+    protected function projectWithInstallationPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('project_with_installation_price');
+    }
+
+    protected function pikPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('pik_price');
+    }
+
+    protected function recommendedPlus10PriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('recommended_plus_10_price');
+    }
+
     public function imageFile(): BelongsTo
     {
         return $this->belongsTo(File::class, 'image_file_id');
@@ -96,4 +214,23 @@ class CommonCatalogItem extends Model
             get: fn (): string => (string) ($this->imageFile?->thumbnail_link ?? ''),
         );
     }
+
+    private function priceAttribute(): Attribute
+    {
+        return Attribute::make(
+            get: fn (mixed $value): ?float => $value === null ? null : (int) $value / 100,
+            set: fn (mixed $value): ?int => $value === null || $value === ''
+                ? null
+                : (int) round((float) $value * 100),
+        );
+    }
+
+    private function formattedPriceAttribute(string $field): Attribute
+    {
+        return Attribute::make(
+            get: fn (): string => isset($this->attributes[$field]) && $this->attributes[$field] !== null
+                ? Price::format((int) $this->attributes[$field] / 100)
+                : '-',
+        );
+    }
 }

+ 160 - 51
app/Services/Export/ExportCommonCatalogService.php

@@ -12,6 +12,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Style\Alignment;
 use PhpOffice\PhpSpreadsheet\Style\Border;
 use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@@ -19,12 +20,14 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 class ExportCommonCatalogService
 {
     public const HEADERS = [
-        'ID',
-        'Внешний вид',
         'Артикул',
-        'Наименование по калькулятору',
+        'Наименование',
         'Вид',
-        'Габаритные размеры',
+        'Габариты',
+        null,
+        null,
+        'Размер участка',
+        null,
         'Высота падения',
         'Дополнительные сведения',
         "Единица измерения\nгабаритов",
@@ -35,80 +38,124 @@ class ExportCommonCatalogService
         'Возрастная группа',
         'Макс.кол-во пользователей',
         'Ед.',
+        'Внешний вид',
         'Серия',
         'ТМ',
-        'Примечание',
+        'Калькулятор',
+        'строители',
+        'опт',
+        'рек',
+        'розница',
+        'проект',
+        'проект+м',
+        'пик',
+        'рек+10',
+    ];
+
+    public const SUBHEADERS = [
+        null,
+        null,
+        null,
+        'Длина',
+        'Ширина',
+        'Высота',
+        'Длина',
+        'Ширина',
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        1.05,
+        1.1,
+        1.5,
+        2,
+        1.2,
+        'инд.',
+        1.21,
     ];
 
     public function handle(int $userId): string
     {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
-        $sheet->setTitle('Каталог общий');
+        $sheet->setTitle('Общие сведения');
         $sheet->fromArray(self::HEADERS, null, 'A1');
+        $sheet->fromArray(self::SUBHEADERS, null, 'A2');
+        $this->mergeHeaders($sheet);
+        $this->styleHeaders($sheet);
 
-        $sheet->getStyle('A1:S1')->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(42);
-        $sheet->freezePane('A2');
-        $sheet->setAutoFilter('A1:S1');
-
-        $row = 2;
+        $row = 3;
         CommonCatalogItem::query()
             ->with('imageFile')
             ->orderBy('article')
+            ->orderBy('calculator_name')
             ->chunk(200, function ($items) use ($sheet, &$row): void {
                 foreach ($items as $item) {
-                    $sheet->setCellValue("A{$row}", $item->id);
-                    $sheet->setCellValueExplicit("C{$row}", $item->article, DataType::TYPE_STRING);
-                    $sheet->setCellValue("D{$row}", $item->calculator_name);
-                    $sheet->setCellValue("E{$row}", $item->kind);
-                    $sheet->setCellValue("F{$row}", $item->dimensions);
-                    $sheet->setCellValue("G{$row}", $item->fall_height);
-                    $sheet->setCellValue("H{$row}", $item->additional_info);
-                    $sheet->setCellValue("I{$row}", $item->dimension_unit);
-                    $sheet->setCellValue("J{$row}", $item->weight);
-                    $sheet->setCellValue("K{$row}", $item->volume);
-                    $sheet->setCellValue("L{$row}", $item->places);
-                    $sheet->setCellValue("M{$row}", $item->composition);
-                    $sheet->setCellValue("N{$row}", $item->age_group);
-                    $sheet->setCellValue("O{$row}", $item->max_users);
-                    $sheet->setCellValue("P{$row}", $item->unit);
-                    $sheet->setCellValue("Q{$row}", $item->series);
-                    $sheet->setCellValue("R{$row}", $item->trademark);
-                    $sheet->setCellValue("S{$row}", $item->note);
+                    $sheet->setCellValueExplicit("A{$row}", $item->article, DataType::TYPE_STRING);
+                    $sheet->setCellValue("B{$row}", $item->calculator_name);
+                    $sheet->setCellValue("C{$row}", $item->kind);
+                    $sheet->setCellValue("D{$row}", $item->dimension_length);
+                    $sheet->setCellValue("E{$row}", $item->dimension_width);
+                    $sheet->setCellValue("F{$row}", $item->dimension_height);
+                    $sheet->setCellValue("G{$row}", $item->site_length);
+                    $sheet->setCellValue("H{$row}", $item->site_width);
+                    $sheet->setCellValue("I{$row}", $item->fall_height);
+                    $sheet->setCellValue("J{$row}", $item->additional_info);
+                    $sheet->setCellValue("K{$row}", $item->dimension_unit);
+                    $sheet->setCellValue("L{$row}", $item->weight);
+                    $sheet->setCellValue("M{$row}", $item->volume);
+                    $sheet->setCellValue("N{$row}", $item->places);
+                    $sheet->setCellValue("O{$row}", $item->composition);
+                    $sheet->setCellValue("P{$row}", $item->age_group);
+                    $sheet->setCellValue("Q{$row}", $item->max_users);
+                    $sheet->setCellValue("R{$row}", $item->unit);
+                    $sheet->setCellValue("T{$row}", $item->series);
+                    $sheet->setCellValue("U{$row}", $item->trademark);
+                    $sheet->setCellValue("V{$row}", match ($item->calculator_enabled) {
+                        true => 'да',
+                        false => 'нет',
+                        null => null,
+                    });
+                    $sheet->setCellValue("W{$row}", $item->builders_price);
+                    $sheet->setCellValue("X{$row}", $item->wholesale_price);
+                    $sheet->setCellValue("Y{$row}", $item->recommended_price);
+                    $sheet->setCellValue("Z{$row}", $item->retail_price);
+                    $sheet->setCellValue("AA{$row}", $item->project_price);
+                    $sheet->setCellValue("AB{$row}", $item->project_with_installation_price);
+                    $sheet->setCellValue("AC{$row}", $item->pik_price);
+                    $sheet->setCellValue("AD{$row}", $item->recommended_plus_10_price);
 
                     $this->addImage($sheet, $item, $row);
                     $row++;
                 }
             });
 
-        if ($row > 2) {
-            $sheet->getStyle('A1:S'.($row - 1))
+        if ($row > 3) {
+            $lastRow = $row - 1;
+            $sheet->getStyle("A1:AD{$lastRow}")
                 ->getBorders()
                 ->getAllBorders()
                 ->setBorderStyle(Border::BORDER_THIN);
-            $sheet->getStyle('A2:S'.($row - 1))->getAlignment()
+            $sheet->getStyle("A3:AD{$lastRow}")->getAlignment()
                 ->setVertical(Alignment::VERTICAL_TOP)
                 ->setWrapText(true);
+            $sheet->getStyle("W3:AD{$lastRow}")
+                ->getNumberFormat()
+                ->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
         }
 
-        foreach (['A' => 8, 'B' => 18, 'C' => 16, 'D' => 38, 'E' => 24, 'F' => 28] as $column => $width) {
-            $sheet->getColumnDimension($column)->setWidth($width);
-        }
-        foreach (range('G', 'S') as $column) {
-            $sheet->getColumnDimension($column)->setWidth(18);
-        }
+        $this->setColumnWidths($sheet);
 
         $directory = 'export/common_catalog';
         $filename = 'common_catalog_'.now()->format('Y-m-d_H-i-s').'.xlsx';
@@ -130,6 +177,68 @@ class ExportCommonCatalogService
         return $link;
     }
 
+    private function mergeHeaders(Worksheet $sheet): void
+    {
+        foreach (['A', 'B', 'C', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W'] as $column) {
+            $sheet->mergeCells("{$column}1:{$column}2");
+        }
+        $sheet->mergeCells('D1:F1');
+        $sheet->mergeCells('G1:H1');
+    }
+
+    private function styleHeaders(Worksheet $sheet): void
+    {
+        $sheet->getStyle('A1:AD2')->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->getRowDimension(2)->setRowHeight(24);
+        $sheet->freezePane('A3');
+    }
+
+    private function setColumnWidths(Worksheet $sheet): void
+    {
+        foreach ([
+            'A' => 14,
+            'B' => 38,
+            'C' => 24,
+            'D' => 13,
+            'E' => 13,
+            'F' => 13,
+            'G' => 13,
+            'H' => 13,
+            'I' => 15,
+            'J' => 28,
+            'K' => 18,
+            'L' => 14,
+            'M' => 14,
+            'N' => 11,
+            'O' => 28,
+            'P' => 18,
+            'Q' => 18,
+            'R' => 10,
+            'S' => 18,
+            'T' => 20,
+            'U' => 18,
+            'V' => 14,
+        ] as $column => $width) {
+            $sheet->getColumnDimension($column)->setWidth($width);
+        }
+
+        foreach (['W', 'X', 'Y', 'Z', 'AA', 'AB', 'AC', 'AD'] as $column) {
+            $sheet->getColumnDimension($column)->setWidth(16);
+        }
+    }
+
     private function addImage(Worksheet $sheet, CommonCatalogItem $item, int $row): void
     {
         $path = $item->imageFile?->path;
@@ -140,7 +249,7 @@ class ExportCommonCatalogService
         $drawing = new Drawing;
         $drawing->setName($item->article);
         $drawing->setPath(Storage::disk('public')->path($path));
-        $drawing->setCoordinates("B{$row}");
+        $drawing->setCoordinates("S{$row}");
         $drawing->setHeight(68);
         $drawing->setOffsetX(4);
         $drawing->setOffsetY(4);

+ 132 - 29
app/Services/Import/ImportCommonCatalogService.php

@@ -7,11 +7,14 @@ namespace App\Services\Import;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
 use App\Models\Import;
+use App\Models\User;
+use App\Services\Access\AccessService;
 use App\Services\Export\ExportCommonCatalogService;
 use App\Services\FileService;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\Cell;
 use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
 use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
@@ -32,38 +35,57 @@ class ImportCommonCatalogService
             $path = Storage::disk('upload')->path((string) $this->import->filename);
             $spreadsheet = (new Xlsx)->load($path);
             $sheet = $spreadsheet->getActiveSheet();
-            $this->assertHeaders($sheet->rangeToArray('A1:S1', null, true, true, false)[0]);
+            $this->assertHeaders(
+                $sheet->rangeToArray('A1:AD1', null, true, true, false)[0],
+                $sheet->rangeToArray('A2:AD2', null, true, true, false)[0],
+            );
             $drawings = $this->drawingsByRow($sheet->getDrawingCollection());
+            $articleCounts = $this->articleCounts($sheet);
+            $user = User::query()->findOrFail($this->userId);
+            $access = app(AccessService::class);
 
             $created = 0;
             $updated = 0;
             $errors = 0;
 
-            for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
-                $article = $this->stringValue($sheet->getCell("C{$row}")->getFormattedValue());
+            for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
+                $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
                 if ($article === null) {
                     continue;
                 }
 
                 $data = [
                     'article' => $article,
-                    'calculator_name' => $this->stringValue($sheet->getCell("D{$row}")->getValue()),
-                    'kind' => $this->stringValue($sheet->getCell("E{$row}")->getValue()),
-                    'dimensions' => $this->stringValue($sheet->getCell("F{$row}")->getValue()),
-                    'fall_height' => $this->numericValue($sheet->getCell("G{$row}")->getCalculatedValue()),
-                    'additional_info' => $this->stringValue($sheet->getCell("H{$row}")->getValue()),
-                    'dimension_unit' => $this->stringValue($sheet->getCell("I{$row}")->getValue()),
-                    'weight' => $this->numericValue($sheet->getCell("J{$row}")->getCalculatedValue()),
-                    'volume' => $this->numericValue($sheet->getCell("K{$row}")->getCalculatedValue()),
-                    'places' => $this->integerValue($sheet->getCell("L{$row}")->getCalculatedValue()),
-                    'composition' => $this->stringValue($sheet->getCell("M{$row}")->getValue()),
-                    'age_group' => $this->stringValue($sheet->getCell("N{$row}")->getFormattedValue()),
-                    'max_users' => $this->integerValue($sheet->getCell("O{$row}")->getCalculatedValue()),
-                    'unit' => $this->stringValue($sheet->getCell("P{$row}")->getValue()),
-                    'series' => $this->stringValue($sheet->getCell("Q{$row}")->getValue()),
-                    'trademark' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
-                    'note' => $this->stringValue($sheet->getCell("S{$row}")->getValue()),
+                    'calculator_name' => $this->stringValue($sheet->getCell("B{$row}")->getValue()),
+                    'kind' => $this->stringValue($sheet->getCell("C{$row}")->getValue()),
+                    'dimension_length' => $this->dimensionValue($sheet->getCell("D{$row}")),
+                    'dimension_width' => $this->dimensionValue($sheet->getCell("E{$row}")),
+                    'dimension_height' => $this->dimensionValue($sheet->getCell("F{$row}")),
+                    'site_length' => $this->dimensionValue($sheet->getCell("G{$row}")),
+                    'site_width' => $this->dimensionValue($sheet->getCell("H{$row}")),
+                    'fall_height' => $this->numericValue($sheet->getCell("I{$row}")->getCalculatedValue()),
+                    'additional_info' => $this->stringValue($sheet->getCell("J{$row}")->getValue()),
+                    'dimension_unit' => $this->stringValue($sheet->getCell("K{$row}")->getValue()),
+                    'weight' => $this->numericValue($sheet->getCell("L{$row}")->getCalculatedValue()),
+                    'volume' => $this->numericValue($sheet->getCell("M{$row}")->getCalculatedValue()),
+                    'places' => $this->integerValue($sheet->getCell("N{$row}")->getCalculatedValue()),
+                    'composition' => $this->stringValue($sheet->getCell("O{$row}")->getValue()),
+                    'age_group' => $this->stringValue($sheet->getCell("P{$row}")->getFormattedValue()),
+                    'max_users' => $this->integerValue($sheet->getCell("Q{$row}")->getCalculatedValue()),
+                    'unit' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
+                    'series' => $this->stringValue($sheet->getCell("T{$row}")->getValue()),
+                    'trademark' => $this->stringValue($sheet->getCell("U{$row}")->getValue()),
+                    'calculator_enabled' => $this->booleanValue($sheet->getCell("V{$row}")->getValue()),
+                    'builders_price' => $this->numericValue($sheet->getCell("W{$row}")->getCalculatedValue()),
+                    'wholesale_price' => $this->numericValue($sheet->getCell("X{$row}")->getCalculatedValue()),
+                    'recommended_price' => $this->numericValue($sheet->getCell("Y{$row}")->getCalculatedValue()),
+                    'retail_price' => $this->numericValue($sheet->getCell("Z{$row}")->getCalculatedValue()),
+                    'project_price' => $this->numericValue($sheet->getCell("AA{$row}")->getCalculatedValue()),
+                    'project_with_installation_price' => $this->numericValue($sheet->getCell("AB{$row}")->getCalculatedValue()),
+                    'pik_price' => $this->numericValue($sheet->getCell("AC{$row}")->getCalculatedValue()),
+                    'recommended_plus_10_price' => $this->numericValue($sheet->getCell("AD{$row}")->getCalculatedValue()),
                 ];
+                $data = $access->filterWritableData($user, 'common-catalog', $data);
 
                 $validator = Validator::make($data, $this->rules());
                 if ($validator->fails()) {
@@ -76,12 +98,17 @@ class ImportCommonCatalogService
                     continue;
                 }
 
-                $item = CommonCatalogItem::query()->where('article', $article)->first();
+                $validated = $validator->validated();
+                $item = $this->findExistingItem(
+                    $article,
+                    $validated['calculator_name'],
+                    $articleCounts[$article] ?? 1,
+                );
                 if ($item) {
-                    $item->update($validator->validated());
+                    $item->update($validated);
                     $updated++;
                 } else {
-                    $item = CommonCatalogItem::query()->create($validator->validated());
+                    $item = CommonCatalogItem::query()->create($validated);
                     $created++;
                 }
 
@@ -113,22 +140,62 @@ class ImportCommonCatalogService
         }
     }
 
-    private function assertHeaders(array $headers): void
+    private function assertHeaders(array $headers, array $subheaders): void
     {
         $actual = array_map($this->normalizeHeader(...), $headers);
         $expected = array_map($this->normalizeHeader(...), ExportCommonCatalogService::HEADERS);
+        $actualSubheaders = array_map($this->normalizeHeader(...), $subheaders);
+        $expectedSubheaders = array_map($this->normalizeHeader(...), ExportCommonCatalogService::SUBHEADERS);
 
-        if ($actual !== $expected) {
-            throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый шаблон.');
+        if ($actual !== $expected || $actualSubheaders !== $expectedSubheaders) {
+            throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый формат.');
         }
     }
 
+    private function articleCounts(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): array
+    {
+        $counts = [];
+        for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
+            $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
+            if ($article !== null) {
+                $counts[$article] = ($counts[$article] ?? 0) + 1;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function findExistingItem(
+        string $article,
+        ?string $name,
+        int $sourceOccurrences,
+    ): ?CommonCatalogItem {
+        $exact = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->where('calculator_name', $name)
+            ->first();
+        if ($exact) {
+            return $exact;
+        }
+
+        if ($sourceOccurrences !== 1) {
+            return null;
+        }
+
+        $items = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->limit(2)
+            ->get();
+
+        return $items->count() === 1 ? $items->first() : null;
+    }
+
     /** @param iterable<BaseDrawing> $drawings */
     private function drawingsByRow(iterable $drawings): array
     {
         $result = [];
         foreach ($drawings as $drawing) {
-            if (preg_match('/^B(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
+            if (preg_match('/^S(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
                 $result[(int) $matches[1]] = $drawing;
             }
         }
@@ -216,6 +283,16 @@ class ImportCommonCatalogService
         return str_replace([' ', ','], ['', '.'], $value);
     }
 
+    private function dimensionValue(Cell $cell): ?string
+    {
+        $calculated = $cell->getCalculatedValue();
+        if (is_int($calculated) || is_float($calculated)) {
+            return rtrim(rtrim(number_format((float) $calculated, 10, '.', ''), '0'), '.');
+        }
+
+        return $this->stringValue($cell->getFormattedValue());
+    }
+
     private function integerValue(mixed $value): int|string|null
     {
         $value = $this->numericValue($value);
@@ -226,13 +303,31 @@ class ImportCommonCatalogService
         return is_numeric($value) ? (int) $value : (string) $value;
     }
 
+    private function booleanValue(mixed $value): bool|string|null
+    {
+        $value = $this->stringValue($value);
+        if ($value === null) {
+            return null;
+        }
+
+        return match (mb_strtolower($value)) {
+            'да', 'yes', '1' => true,
+            'нет', 'no', '0' => false,
+            default => $value,
+        };
+    }
+
     private function rules(): array
     {
         return [
             'article' => ['required', 'string', 'max:100'],
-            'calculator_name' => ['required', 'string'],
+            'calculator_name' => ['nullable', 'string', 'max:255'],
             'kind' => ['nullable', 'string', 'max:255'],
-            'dimensions' => ['nullable', 'string'],
+            'dimension_length' => ['nullable', 'string', 'max:255'],
+            'dimension_width' => ['nullable', 'string', 'max:255'],
+            'dimension_height' => ['nullable', 'string', 'max:255'],
+            'site_length' => ['nullable', 'string', 'max:255'],
+            'site_width' => ['nullable', 'string', 'max:255'],
             'fall_height' => ['nullable', 'numeric', 'min:0'],
             'additional_info' => ['nullable', 'string'],
             'dimension_unit' => ['nullable', 'string', 'max:50'],
@@ -245,7 +340,15 @@ class ImportCommonCatalogService
             'unit' => ['nullable', 'string', 'max:50'],
             'series' => ['nullable', 'string', 'max:255'],
             'trademark' => ['nullable', 'string', 'max:255'],
-            'note' => ['nullable', 'string'],
+            'calculator_enabled' => ['nullable', 'boolean'],
+            'builders_price' => ['nullable', 'numeric', 'min:0'],
+            'wholesale_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_price' => ['nullable', 'numeric', 'min:0'],
+            'retail_price' => ['nullable', 'numeric', 'min:0'],
+            'project_price' => ['nullable', 'numeric', 'min:0'],
+            'project_with_installation_price' => ['nullable', 'numeric', 'min:0'],
+            'pik_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
         ];
     }
 }

+ 13 - 1
app/Services/Import/ImportStockOrdersService.php

@@ -60,7 +60,19 @@ class ImportStockOrdersService
                         throw new RuntimeException('Не указан артикул.');
                     }
 
-                    $item = CommonCatalogItem::query()->where('article', $article)->firstOrFail();
+                    $items = CommonCatalogItem::query()
+                        ->where('article', $article)
+                        ->limit(2)
+                        ->get();
+                    if ($items->isEmpty()) {
+                        throw new RuntimeException("Позиция с артикулом {$article} не найдена.");
+                    }
+                    if ($items->count() > 1) {
+                        throw new RuntimeException(
+                            "Артикул {$article} соответствует нескольким вариантам. Добавьте заказ вручную, выбрав наименование.",
+                        );
+                    }
+                    $item = $items->first();
                     $status = $this->status((string) $sheet->getCell("C{$row}")->getFormattedValue());
                     $quantity = filter_var(
                         $sheet->getCell("D{$row}")->getCalculatedValue(),

+ 33 - 0
config/access.php

@@ -126,6 +126,39 @@ return [
             'documents.upload' => 'Загрузка документов',
             'documents.delete' => 'Удаление документов',
         ],
+        'fields' => [
+            'id' => 'ID',
+            'image' => 'Внешний вид',
+            'article' => 'Артикул',
+            'calculator_name' => 'Наименование',
+            'kind' => 'Вид',
+            'dimension_length' => 'Габариты: длина',
+            'dimension_width' => 'Габариты: ширина',
+            'dimension_height' => 'Габариты: высота',
+            'site_length' => 'Размер участка: длина',
+            'site_width' => 'Размер участка: ширина',
+            'fall_height' => 'Высота падения',
+            'additional_info' => 'Дополнительные сведения',
+            'dimension_unit' => 'Единица измерения габаритов',
+            'weight' => 'Вес, кг.',
+            'volume' => 'Объем, м3',
+            'places' => 'Места',
+            'composition' => 'Состав',
+            'age_group' => 'Возрастная группа',
+            'max_users' => 'Макс. кол-во пользователей',
+            'unit' => 'Ед.',
+            'series' => 'Серия',
+            'trademark' => 'ТМ',
+            'calculator_enabled' => 'Калькулятор',
+            'builders_price' => 'Цена: строители',
+            'wholesale_price' => 'Цена: опт',
+            'recommended_price' => 'Цена: рек',
+            'retail_price' => 'Цена: розница',
+            'project_price' => 'Цена: проект',
+            'project_with_installation_price' => 'Цена: проект+м',
+            'pik_price' => 'Цена: пик',
+            'recommended_plus_10_price' => 'Цена: рек+10',
+        ],
     ],
     'documents' => [
         'name' => 'Документация',

+ 14 - 2
database/factories/CommonCatalogItemFactory.php

@@ -18,7 +18,11 @@ class CommonCatalogItemFactory extends Factory
             'article' => fake()->unique()->numerify('####'),
             'calculator_name' => fake()->sentence(),
             'kind' => fake()->randomElement(['Игровое оборудование', 'Теневой навес', 'Мебель']),
-            'dimensions' => fake()->randomElement(['2,0 × 1,5 × 1,2 м', '3,0 × 2,0 × 2,5 м']),
+            'dimension_length' => (string) fake()->randomFloat(2, 1, 10),
+            'dimension_width' => (string) fake()->randomFloat(2, 1, 10),
+            'dimension_height' => (string) fake()->randomFloat(2, 1, 5),
+            'site_length' => (string) fake()->randomFloat(2, 1, 15),
+            'site_width' => (string) fake()->randomFloat(2, 1, 15),
             'fall_height' => fake()->randomFloat(3, 0, 3),
             'additional_info' => fake()->optional()->sentence(),
             'dimension_unit' => 'м',
@@ -31,7 +35,15 @@ class CommonCatalogItemFactory extends Factory
             'unit' => 'шт.',
             'series' => fake()->word(),
             'trademark' => fake()->company(),
-            'note' => fake()->optional()->sentence(),
+            'calculator_enabled' => fake()->boolean(),
+            'builders_price' => fake()->randomFloat(2, 1000, 3000000),
+            'wholesale_price' => fake()->randomFloat(2, 1000, 3000000),
+            'recommended_price' => fake()->randomFloat(2, 1000, 3000000),
+            'retail_price' => fake()->randomFloat(2, 1000, 3000000),
+            'project_price' => fake()->randomFloat(2, 1000, 3000000),
+            'project_with_installation_price' => fake()->randomFloat(2, 1000, 3000000),
+            'pik_price' => fake()->randomFloat(2, 1000, 3000000),
+            'recommended_plus_10_price' => fake()->randomFloat(2, 1000, 3000000),
         ];
     }
 }

+ 61 - 0
database/migrations/2026_08_05_000001_expand_common_catalog_items_for_actual_catalog.php

@@ -0,0 +1,61 @@
+<?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->dropUnique(['article']);
+            $table->string('calculator_name')->change();
+            $table->string('dimension_length')->nullable()->after('kind');
+            $table->string('dimension_width')->nullable()->after('dimension_length');
+            $table->string('dimension_height')->nullable()->after('dimension_width');
+            $table->string('site_length')->nullable()->after('dimension_height');
+            $table->string('site_width')->nullable()->after('site_length');
+            $table->boolean('calculator_enabled')->nullable()->after('trademark');
+            $table->unsignedBigInteger('builders_price')->nullable()->after('calculator_enabled');
+            $table->unsignedBigInteger('wholesale_price')->nullable()->after('builders_price');
+            $table->unsignedBigInteger('recommended_price')->nullable()->after('wholesale_price');
+            $table->unsignedBigInteger('retail_price')->nullable()->after('recommended_price');
+            $table->unsignedBigInteger('project_price')->nullable()->after('retail_price');
+            $table->unsignedBigInteger('project_with_installation_price')->nullable()->after('project_price');
+            $table->unsignedBigInteger('pik_price')->nullable()->after('project_with_installation_price');
+            $table->unsignedBigInteger('recommended_plus_10_price')->nullable()->after('pik_price');
+            $table->unique(
+                ['article', 'calculator_name'],
+                'common_catalog_items_article_name_unique',
+            );
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->dropUnique('common_catalog_items_article_name_unique');
+            $table->dropColumn([
+                'dimension_length',
+                'dimension_width',
+                'dimension_height',
+                'site_length',
+                'site_width',
+                'calculator_enabled',
+                'builders_price',
+                'wholesale_price',
+                'recommended_price',
+                'retail_price',
+                'project_price',
+                'project_with_installation_price',
+                'pik_price',
+                'recommended_plus_10_price',
+            ]);
+            $table->text('calculator_name')->change();
+            $table->unique('article');
+        });
+    }
+};

+ 24 - 0
database/migrations/2026_08_05_000002_sync_common_catalog_field_permissions.php

@@ -0,0 +1,24 @@
+<?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')
+            || ! Schema::hasTable('role_permissions')
+            || ! Schema::hasTable('user_permissions')
+        ) {
+            return;
+        }
+
+        app(RbacSeeder::class)->run();
+    }
+};

+ 24 - 0
database/migrations/2026_08_05_000003_make_common_catalog_calculator_name_nullable.php

@@ -0,0 +1,24 @@
+<?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('calculator_name')->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->string('calculator_name')->nullable(false)->change();
+        });
+    }
+};

+ 38 - 1
database/seeders/RbacSeeder.php

@@ -11,6 +11,17 @@ use Illuminate\Support\Facades\DB;
 
 class RbacSeeder extends Seeder
 {
+    private const COMMON_CATALOG_PRICE_FIELDS = [
+        'builders_price',
+        'wholesale_price',
+        'recommended_price',
+        'retail_price',
+        'project_price',
+        'project_with_installation_price',
+        'pik_price',
+        'recommended_plus_10_price',
+    ];
+
     public function run(): void
     {
         DB::transaction(function () {
@@ -130,6 +141,15 @@ class RbacSeeder extends Seeder
             'filters.view',
         ];
 
+        $commonCatalogPublicFields = array_diff(
+            array_keys(config('access.common-catalog.fields', [])),
+            self::COMMON_CATALOG_PRICE_FIELDS,
+        );
+        $auth = array_merge($auth, array_map(
+            fn (string $field): string => "common-catalog.fields.{$field}.view",
+            $commonCatalogPublicFields,
+        ));
+
         $manager = array_merge($auth, [
             'orders.update',
             'orders.create',
@@ -196,9 +216,14 @@ class RbacSeeder extends Seeder
             'schedule.scope.manager',
         ]);
 
+        $assistantHead = array_values(array_diff(
+            $all,
+            $this->commonCatalogAdminOnlyPermissions(),
+        ));
+
         $rolePermissions = [
             Role::ADMIN => $all,
-            Role::ASSISTANT_HEAD => $all,
+            Role::ASSISTANT_HEAD => $assistantHead,
             Role::MANAGER => $manager,
             Role::BRIGADIER => $brigadier,
             Role::WAREHOUSE_HEAD => $warehouseHead,
@@ -248,4 +273,16 @@ class RbacSeeder extends Seeder
                 && in_array(substr($slug, strrpos($slug, '.') + 1), $actions, true)
         ));
     }
+
+    private function commonCatalogAdminOnlyPermissions(): array
+    {
+        $permissions = ['common-catalog.export'];
+
+        foreach (self::COMMON_CATALOG_PRICE_FIELDS as $field) {
+            $permissions[] = "common-catalog.fields.{$field}.view";
+            $permissions[] = "common-catalog.fields.{$field}.update";
+        }
+
+        return $permissions;
+    }
 }

+ 16 - 14
docs/refactor/plan.md

@@ -38,7 +38,9 @@
 - [x] На новые модули выбранный год не влияет.
 - [x] `Технич. описание` встроено в модуль `Каталог общий`; готовый Laravel-модуль используем как источник экспорта.
 - [x] Модуль техописаний `to.stroyprofit.com` изучен: его данные и DOCX-экспорты переносятся в `Каталог общий` без отдельного раздела.
-- [x] Цена из `to.stroyprofit.com` не является полем техописания и не дублируется в `Каталог общий`: единственным источником актуальной цены должен быть модуль `Склад`, а DOCX-экспорт только читает её оттуда.
+- [x] Цены принадлежат `Каталог общий` и хранятся непосредственно у позиции; актуальный формат и реальные значения определены файлом `docs/refactor/Каталог общий.xlsx`.
+- [x] В общем каталоге используются восемь видов цен: `строители`, `опт`, `рек`, `розница`, `проект`, `проект+м`, `пик`, `рек+10`.
+- [x] По умолчанию цены и экспорт общего каталога доступны только администратору; просмотр и редактирование цен управляются существующими permissions отдельных полей.
 - [x] Исходный калькулятор `calc.stroyprofit.com` используется как источник модели данных, формул и экспортов, но его код и текущие формулы не переносятся без проверки.
 - [x] Финальный состав и способ реализации `Калькуляций` определяются отдельным ТЗ; допустимы внутренняя реализация в Laravel и интеграционный вариант.
 - [x] Старый `График отгрузок` и действующий обработчик 1С в `manager.stroyprofit.com` изучены как исходная система для `Графика заказов`.
@@ -78,9 +80,9 @@
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
-| 4. Общий каталог: ядро | Реализован и проверен | Пользователь проверил создание, редактирование, импорт и экспорт; отдельная модель данных, права, фото и документы покрыты автоматическими тестами. |
+| 4. Общий каталог | Расширение реализовано, ожидает пользовательской проверки | Импорт, экспорт и карточка переведены на фактический 30-колоночный формат `Каталог общий.xlsx`; добавлены восемь цен и ограничения доступа. |
 | 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
-| 6. Склад: ядро | Ядро проверено, запланировано расширение цены | Ядро склада принято после пользовательской проверки; единый источник и история цены добавлены в план после анализа техописаний. PDF-экспорт остается отдельной задачей. |
+| 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
 | 7. Техническое описание | Анализ завершён, готово к реализации | Изучены данные, импорт, изображения, DOCX-шаблоны и массовый/раздельный экспорт `to.stroyprofit.com`; требуется перенос в общий каталог. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
 | 9. Графики заказов и доставок | Исходная система изучена, ожидается отдельное ТЗ | Найдены старый график и фактический формат обмена с 1С. Нормализация принята как архитектурный принцип, но финальные поля, статусы и правила уточнит заказчик. |
@@ -88,7 +90,7 @@
 
 ## Этап 1. Реорганизация меню и заглушки
 
-Статус: **реализован и проверен пользователем**.
+Статус: **ядро проверено пользователем; расширение под актуальный каталог реализовано и ожидает пользовательской проверки**.
 
 - [x] Проверить текущие permissions для всех существующих пунктов меню.
 - [x] Для новых пунктов-заглушек открыть доступ всем авторизованным пользователям.
@@ -152,7 +154,7 @@
 - [x] Спроектировать маршруты с префиксом `common-catalog/...`, таблицы и права с техническим именем `common-catalog`.
 - [x] Сверить текущую модель каталога ДКР с требованиями общего каталога как источник паттернов, без смешивания данных.
 - [x] Зафиксировать состав и порядок колонок общего каталога.
-- [x] Зафиксировать файл `docs/refactor/Шаблон Каталог ощий.xlsx` как источник колонок общего каталога.
+- [x] Зафиксировать файл `docs/refactor/Каталог общий.xlsx` как актуальный источник колонок и реальных данных общего каталога; старый шаблон оставить историческим референсом.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
 - [x] Спроектировать обязательность и валидацию утвержденных полей.
 - [x] Использовать текущий файловый механизм для хранения фото и документов позиции.
@@ -161,6 +163,10 @@
 - [x] Зафиксировать встраивание `Технич. описание` в карточку общего каталога.
 - [x] Добавить в карточку место под будущую кнопку/блок `Калькуляции`.
 - [x] Зафиксировать интеграционный ключ `common_catalog_items.id` для будущих связей с графиками, рекламациями и складом наличия; сами связи реализуются в этапах этих модулей.
+- [x] Перевести поля, импорт и экспорт на фактический формат `docs/refactor/Каталог общий.xlsx` из 30 колонок и двух строк заголовка.
+- [x] Добавить восемь видов цен непосредственно в `common_catalog_items`.
+- [x] Скрыть цены по умолчанию от всех ролей, кроме администратора, через field-permissions.
+- [x] Ограничить экспорт общего каталога администратором по умолчанию.
 
 ## Этап 5. Документация
 
@@ -178,7 +184,7 @@
 
 ## Этап 6. Склад: ядро
 
-Статус: **ядро реализовано и проверено пользователем; управление ценой добавлено в план как расширение**.
+Статус: **ядро реализовано и проверено пользователем**.
 
 Ядро склада можно реализовывать после появления позиций общего каталога. PDF-экспорт по шаблону вынесен отдельным подпунктом, потому что точный шаблон еще не согласован.
 
@@ -195,10 +201,6 @@
 - [x] Реализовать импорт заказов/остатков.
 - [x] Настроить права: `Наличие` всем, `Заказы` админу и роли `assistant_head`.
 - [x] Покрыть критичную логику расчетов тестами.
-- [ ] Уточнить назначение цены из старого модуля: отпускная, закупочная или иная.
-- [ ] Добавить в `Склад` единый источник актуальной цены позиции без дублирования в общем каталоге и техописании.
-- [ ] Сохранять историю изменения цены и пользователя, выполнившего изменение.
-- [ ] Добавить цену в список/карточку `Склад` и отдельные права на её просмотр/редактирование.
 - [ ] После согласования шаблона реализовать экспорт наличия в PDF.
 
 ## Этап 7. Техническое описание в общем каталоге
@@ -210,14 +212,14 @@
 - [x] Составить карту экспортов и форматов выгрузки.
 - [x] Сопоставить данные техописаний с полями `Каталог общий`.
 - [x] Решить, что данные техописаний расширяют `common_catalog_items`, а не образуют отдельный модуль каталога.
-- [x] Исключить цену из полей техописания и общего каталога: её владельцем является `Склад`.
+- [x] Зафиксировать, что техописание не хранит отдельную копию цены, а использует нужный вид цены из `Каталог общий`.
 - [ ] Добавить остальные недостающие поля техописаний в `Каталог общий` с безопасной миграцией.
 - [ ] Подготовить перенос или обогащение 938 позиций по артикулу в согласованном режиме без потери уже заполненных полей общего каталога.
-- [ ] Использовать цену источника только для начального заполнения цены в `Склад` после подтверждения её назначения; не импортировать её в техописание.
+- [ ] Сопоставить старое одиночное поле цены техописаний с нужным видом цены общего каталога, если оно потребуется при переносе.
 - [ ] Перенести изображения через текущий файловый механизм CRM.
 - [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
 - [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
-- [ ] Подставлять в DOCX актуальную цену из `Склад`, не сохраняя отдельную копию цены в данных техописания.
+- [ ] Подставлять в DOCX согласованный вид цены из `Каталог общий`, не сохраняя отдельную копию в данных техописания.
 - [ ] Реализовать общий DOCX и отдельные DOCX в ZIP через очередь и приватное хранение файлов.
 - [ ] Покрыть миграцию данных и варианты экспорта автоматическими тестами.
 
@@ -296,6 +298,6 @@
 - [ ] Где находится актуальная резервная копия или исходные Excel-данные `calc.stroyprofit.com`?
 - [ ] Какие формулы и нормативы калькулятора подтверждены заказчиком?
 - [ ] Нужно ли переносить в общий каталог все 938 записей `to.stroyprofit.com` как исходные или только обогащать совпавшие артикулы?
-- [ ] Что означает цена в `to.stroyprofit.com`: отпускную, закупочную или иную цену, и является ли она общей для позиции либо зависит от складского заказа/партии?
+- [ ] Какой из восьми видов цены общего каталога использовать в DOCX техописания вместо старого одиночного поля `price`?
 - [ ] Какой точный PDF-шаблон нужен для экспорта `Склад`?
 - [ ] Какие поля, статусы, правила отгрузок и связи с заказами нужны для `График доставок`?

+ 73 - 52
docs/refactor/tz-catalog-common.md

@@ -5,7 +5,8 @@
 - [План реорганизации](plan.md)
 - [Карта нового меню](menu.md)
 - [ТЗ: Модуль ДКР](tz-dkr.md)
-- [Шаблон каталога](Шаблон%20Каталог%20ощий.xlsx)
+- [Актуальный каталог с реальными данными](Каталог%20общий.xlsx)
+- [Предыдущий шаблон каталога](Шаблон%20Каталог%20ощий.xlsx)
 
 ## 1. Назначение
 
@@ -23,7 +24,7 @@
 
 ## 2. Статус
 
-Статус модуля: **ядро реализовано и проверено пользователем**.
+Статус модуля: **ядро реализовано и проверено пользователем; переход на актуальный 30-колоночный формат с ценами реализован и ожидает пользовательской проверки**.
 
 Проверены создание и редактирование позиций, импорт и экспорт каталога.
 
@@ -72,13 +73,18 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 | Просмотр заглушки | Любой авторизованный пользователь |
 | Просмотр общего каталога после реализации | `common-catalog.view` |
 | Создание/редактирование/удаление | Права с префиксом `common-catalog.*` |
-| Импорт/экспорт | Права с префиксом `common-catalog.*` |
+| Импорт | `common-catalog.import` |
+| Экспорт | `common-catalog.export`, по умолчанию только администратор |
+| Просмотр/редактирование отдельного поля | `common-catalog.fields.{field}.view/update` |
 
 Требования:
 
 - не переиспользовать `catalog.view` автоматически, потому что это право относится к каталогу ДКР;
 - новые права общего каталога использовать с техническим именем `common-catalog`;
 - заглушку `Каталог общий` показывать всем авторизованным пользователям.
+- восемь полей цен по умолчанию видны и доступны для редактирования только администратору;
+- доступ к каждой цене можно настраивать через существующий механизм прав отдельных полей;
+- экспорт общего каталога по умолчанию доступен только администратору.
 
 ## 5. Основные сущности
 
@@ -114,63 +120,67 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - экспорт;
 - импорт, если он будет предусмотрен для общего каталога.
 
-Источник полей: файл [Шаблон Каталог ощий.xlsx](Шаблон%20Каталог%20ощий.xlsx), лист 1, строка 1. В строке 3 шаблона есть пример заполнения.
+Источник полей и данных: файл [Каталог общий.xlsx](Каталог%20общий.xlsx), лист `Общие сведения`. Заголовок занимает строки 1–2, данные начинаются со строки 3. Файл содержит реальные позиции, изображения и цены.
 
 Колонки:
 
-1. `ID`;
-2. `Внешний вид`;
-3. `Артикул`;
-4. `Наименование по калькулятору`;
-5. `Вид`;
-6. `Габаритные размеры`;
-7. `Высота падения`;
-8. `Дополнительные сведения`;
-9. `Единица измерения габаритов`;
-10. `Вес, кг.`;
-11. `Объем, м3`;
-12. `Места`;
-13. `Состав`;
-14. `Возрастная группа`;
-15. `Макс.кол-во пользователей`;
-16. `Ед.`;
-17. `Серия`;
-18. `ТМ`;
-19. `Примечание`.
+1. `Артикул`;
+2. `Наименование`;
+3. `Вид`;
+4–6. `Габариты`: длина, ширина, высота;
+7–8. `Размер участка`: длина, ширина;
+9. `Высота падения`;
+10. `Дополнительные сведения`;
+11. `Единица измерения габаритов`;
+12. `Вес, кг.`;
+13. `Объем, м3`;
+14. `Места`;
+15. `Состав`;
+16. `Возрастная группа`;
+17. `Макс.кол-во пользователей`;
+18. `Ед.`;
+19. `Внешний вид`;
+20. `Серия`;
+21. `ТМ`;
+22. `Калькулятор`;
+23–30. цены `строители`, `опт`, `рек`, `розница`, `проект`, `проект+м`, `пик`, `рек+10`.
 
 Требования к колонкам:
 
-- указанный перечень и порядок являются целевым форматом общего каталога;
+- указанный перечень, порядок и две строки заголовка являются целевым форматом общего каталога;
 - при импорте принимать заголовок `Единица измерения габаритов` в формате шаблона с переносом строки между словами `измерения` и `габаритов`;
 - поле `Артикул` хранить строкой, чтобы сохранять ведущие нули;
 - `Внешний вид` отображает фото позиции;
 - состав полей общего каталога не зависит от состава полей каталога ДКР;
-- цена не является полем общего каталога: её единственным владельцем является модуль `Склад`;
-- обязательность заполнения и правила валидации полей уточняются при проектировании модели данных.
+- все восемь цен хранятся непосредственно у позиции общего каталога;
+- цены хранятся точно до копеек и не зависят от складской партии;
+- габаритные поля хранятся строками, потому что реальные данные содержат диапазоны и текстовые значения (`от 180`, `0,19-0,72`);
+- при ручном создании обязательны артикул и наименование;
+- при импорте фактического каталога допускается пустое наименование: строка сохраняется без подстановки выдуманного значения;
+- остальные поля допускают постепенное заполнение.
 
 Предварительная модель полей по шаблону:
 
 | Колонка | Поле | Тип данных | Примечание |
 |---|---|---|---|
-| A | `ID` | integer | Внутренний идентификатор/номер строки каталога. |
-| B | `Внешний вид` | image/file | Фото позиции; в шаблоне есть встроенное изображение. |
-| C | `Артикул` | string | Сохранять ведущие нули, пример: `0254`. |
-| D | `Наименование по калькулятору` | text | Может содержать переносы строк. |
-| E | `Вид` | string | Категория/вид позиции. |
-| F | `Габаритные размеры` | text | Может содержать текст и единицы измерения. |
-| G | `Высота падения` | decimal | Числовое значение, допускается `0`. |
-| H | `Дополнительные сведения` | text nullable | Дополнительное описание. |
-| I | `Единица измерения габаритов` | string | В шаблоне заголовок разбит переносом строки. |
-| J | `Вес, кг.` | decimal | Вес в килограммах. |
-| K | `Объем, м3` | decimal | Объем в кубических метрах. |
-| L | `Места` | integer | Количество мест. |
-| M | `Состав` | text nullable | Состав комплекта/позиции. |
-| N | `Возрастная группа` | string | Пример: `0+`. |
-| O | `Макс.кол-во пользователей` | integer | Максимальное количество пользователей. |
-| P | `Ед.` | string | Единица учета, пример: `шт.`. |
-| Q | `Серия` | string | Серия позиции. |
-| R | `ТМ` | string | Торговая марка. |
-| S | `Примечание` | text nullable | Свободное примечание. |
+| A | `article` | string | Сохранять ведущие нули, пример: `0254`. |
+| B | `calculator_name` | text | Наименование; внутреннее имя поля сохранено для совместимости. |
+| C | `kind` | string nullable | Категория/вид позиции. |
+| D–F | `dimension_length`, `dimension_width`, `dimension_height` | string nullable | Отдельные размеры; допускают диапазоны и текст. |
+| G–H | `site_length`, `site_width` | string nullable | Размеры участка. |
+| I | `fall_height` | decimal nullable | Высота падения. |
+| J | `additional_info` | text nullable | Дополнительное описание. |
+| K | `dimension_unit` | string nullable | Единица измерения габаритов. |
+| L–M | `weight`, `volume` | decimal nullable | Вес и объём. |
+| N | `places` | integer nullable | Количество мест. |
+| O | `composition` | text nullable | Состав комплекта/позиции. |
+| P | `age_group` | string nullable | Возрастная группа. |
+| Q | `max_users` | integer nullable | Максимальное количество пользователей. |
+| R | `unit` | string nullable | Единица учета. |
+| S | `image_file_id` | image/file nullable | Встроенное изображение импортируется через файловый механизм CRM. |
+| T–U | `series`, `trademark` | string nullable | Серия и торговая марка. |
+| V | `calculator_enabled` | boolean nullable | Значения `да` / `нет` / пусто. |
+| W–AD | восемь полей `*_price` | money nullable | Цены хранятся в копейках, в UI и XLSX отображаются в рублях. |
 
 ## 7. Карточка позиции
 
@@ -215,8 +225,13 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - спроектировать импорт и экспорт общего каталога;
 - использовать текущий импорт/экспорт каталога ДКР как технический референс, если это ускорит реализацию;
 - использовать утвержденные колонки общего каталога в заданном порядке;
-- использовать [Шаблон Каталог ощий.xlsx](Шаблон%20Каталог%20ощий.xlsx) как целевой шаблон импорта/экспорта общего каталога;
-- при импорте обновлять существующие позиции по стабильному ключу;
+- использовать [Каталог общий.xlsx](Каталог%20общий.xlsx) как целевой формат импорта/экспорта общего каталога;
+- проверять обе строки заголовка и начинать обработку данных со строки 3;
+- сохранять ведущие нули в артикулах;
+- учитывать реальные варианты с одинаковым артикулом и разными наименованиями: уникальность позиции определяется парой `Артикул + Наименование`;
+- если импортируемый файл содержит один вариант артикула и в базе найдена ровно одна такая позиция, разрешать обновление наименования без создания дубля;
+- импортировать встроенные изображения из колонки `S`;
+- применять field-permissions к импортируемым значениям, чтобы пользователь без права редактирования цены не мог изменить её импортом;
 - ошибки импорта должны быть понятны пользователю.
 
 ## 10. Связи с другими модулями
@@ -224,10 +239,10 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 | Модуль | Связь | Требование |
 |---|---|---|
 | ДКР | Имеет собственный каталог | Не смешивать каталог ДКР с общим каталогом. |
-| Склад | Использует картинку, артикул, наименование, характеристики, ед. изм.; хранит актуальную цену | Склад берёт справочные поля из общего каталога, но является единственным источником цены. |
+| Склад | Использует картинку, артикул, наименование, характеристики и ед. изм. | Цены принадлежат общему каталогу и не хранятся в складских партиях. |
 | Графики | Используют данные по позициям | При разработке графиков опираться на общий каталог. |
 | Рекламации | Используют данные по позициям | Сохранить/добавить связь рекламаций с позициями общего каталога. |
-| Технич. описание | Является частью общего каталога | Источник `to.stroyprofit.com` изучен; описательные поля и DOCX-экспорты переносятся в карточку, а цена читается из `Склад`. |
+| Технич. описание | Является частью общего каталога | Описательные поля и DOCX-экспорты переносятся в карточку; согласованный вид цены читается из этой же позиции каталога. |
 | Калькуляции | Открываются из карточки позиции | Финальное поведение кнопки и способ реализации определяются отдельным ТЗ; исходный `calc.stroyprofit.com` используется как референс. |
 
 ## 11. Что не входит в первый этап
@@ -256,9 +271,13 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - [x] Зафиксировать состав и порядок колонок общего каталога.
 - [x] Зафиксировать шаблон общего каталога как источник колонок.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
-- [x] Спроектировать обязательность и валидацию утвержденных полей: обязательны артикул и наименование по калькулятору; числовые значения неотрицательны; остальные поля допускают постепенное заполнение.
+- [x] Спроектировать обязательность и валидацию утвержденных полей: при ручном вводе обязательны артикул и наименование, импорт принимает фактические строки без наименования; числовые значения неотрицательны; остальные поля допускают постепенное заполнение.
 - [x] Реализовать утвержденные поля в списке и карточке позиции.
-- [x] Реализовать фоновый импорт/экспорт по утвержденным 19 колонкам с обновлением по артикулу и поддержкой встроенных изображений.
+- [x] Реализовать первоначальный фоновый импорт/экспорт по 19-колоночному шаблону.
+- [x] Перевести импорт/экспорт на актуальный формат из 30 колонок и двух строк заголовка.
+- [x] Добавить пять раздельных размерных полей, признак калькулятора и восемь цен.
+- [x] Подключить field-permissions общего каталога; цены по умолчанию доступны только администратору.
+- [x] Ограничить экспорт общего каталога администратором по умолчанию.
 - [x] Реализовать блок документов как набор файлов.
 - [x] Зафиксировать, что `Технич. описание` включается внутрь карточки общего каталога.
 - [x] Добавить в карточку место под будущий модуль `Калькуляции`.
@@ -272,7 +291,9 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - Текущие маршруты `catalog.index` и `catalog.show` сохранены как маршруты каталога ДКР.
 - Рабочий раздел `Каталог общий` доступен основным авторизованным ролям через право `common-catalog.view`.
 - После реализации общий каталог не смешивает данные с каталогом ДКР без отдельной миграции/синхронизации.
-- Список, импорт и экспорт общего каталога используют утвержденный состав и порядок колонок из файла `docs/refactor/Шаблон Каталог ощий.xlsx`.
+- Список, карточка, импорт и экспорт используют состав и порядок колонок из файла `docs/refactor/Каталог общий.xlsx`.
+- Пользователь без field-permission не видит цену и не может изменить её прямым POST-запросом или импортом.
+- Экспорт по умолчанию доступен только администратору.
 - Карточка позиции общего каталога поддерживает фото и набор документов; места интеграции техописания и калькуляции подготовлены до реализации соответствующих этапов.
 - ДКР продолжает использовать свой каталог без поломки существующих связей.
 - Реорганизация меню выполнена как часть общей структуры Manager 2.0.

+ 2 - 25
docs/refactor/tz-stock-availability.md

@@ -16,7 +16,7 @@
 
 ## 2. Статус
 
-Статус модуля: **ядро реализовано и проверено; управление ценой добавлено в план как расширение**.
+Статус модуля: **ядро реализовано и проверено**.
 
 Реализованы `Наличие`, `Заказы`, карточка позиции, бронирование, списание, отмена, история движений, импорт и постоянные права. PDF-экспорт ожидает согласованного шаблона.
 
@@ -192,21 +192,6 @@
 - расчет остатка по заказу;
 - отображение активных броней.
 
-### Цена позиции
-
-`Склад` является единственным владельцем актуальной цены позиции. Цена не должна дублироваться в `Каталог общий` или данных технического описания.
-
-Требования:
-
-- цена связана с позицией общего каталога по `common_catalog_item_id`;
-- актуальная цена отображается в списке наличия и карточке позиции склада;
-- изменение цены не изменяет справочные и технические данные позиции;
-- сохраняются история значений, дата изменения и пользователь;
-- права просмотра и редактирования цены проверяются отдельно на сервере;
-- техническое описание получает актуальную цену из склада при формировании DOCX;
-- цена из `to.stroyprofit.com` может быть использована только как начальное значение после подтверждения её назначения;
-- если цена зависит от складского заказа или партии, это хранится нормализованно и не заменяет историю общей цены без явно согласованного правила.
-
 ## 11. Экспорт
 
 Требования:
@@ -222,7 +207,7 @@
 | Модуль | Связь | Требование |
 |---|---|---|
 | Каталог общий | Источник картинки, артикула, наименования, характеристик, ед. изм. | Обязательная связь. |
-| Технич. описание | Использует актуальную цену при формировании DOCX | Цена хранится только в `Склад`; техописание её не дублирует. |
+| Технич. описание | Использует данные позиции при формировании DOCX | Цена хранится в `Каталог общий`; склад не владеет ценами. |
 | График отгрузок / График заказов | Данные об отгрузках | Уточнить после реализации графиков. |
 | Запчасти | Эталон логики | Использовать паттерны реализации. |
 
@@ -239,8 +224,6 @@
 
 Отдельная таблица остатков не создаётся: физическое наличие, активная бронь, доступный остаток и заказанное количество рассчитываются из заказов и броней. Заказ связан с общей позицией по `common_catalog_item_id`; при импорте позиция разрешается по уникальному артикулу общего каталога.
 
-Цена относится к складскому учёту позиции и должна храниться отдельно от технических характеристик. Финальная таблица цены проектируется после уточнения, является ли цена общей для позиции или относится к конкретному заказу/партии; в любом варианте изменение не должно уничтожать историю.
-
 ## 14. Отложенная функциональность
 
 - PDF-шаблон;
@@ -266,10 +249,6 @@
 - [ ] Реализовать PDF-экспорт.
 - [x] Настроить права доступа.
 - [x] Покрыть расчеты остатков тестами.
-- [ ] Уточнить назначение и область действия цены.
-- [ ] Реализовать единый источник и историю цены в модуле `Склад`.
-- [ ] Добавить цену в список/карточку склада, права и интерфейс управления ценой.
-- [ ] Подключить чтение актуальной цены в экспорте техописаний.
 
 ## 16. Критерии приемки
 
@@ -289,5 +268,3 @@
 
 - Какой точный PDF-шаблон нужен для экспорта?
 - Как будет выглядеть связь с графиком отгрузок/заказов?
-- Что означает цена из `to.stroyprofit.com`: отпускную, закупочную или иную цену?
-- Цена общая для позиции или зависит от складского заказа/партии?

+ 9 - 9
docs/refactor/tz-technical-description.md

@@ -24,7 +24,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - шаблонов/форматов экспорта;
 - возможных UI-решений, если они применимы к текущей CRM.
 
-В источнике находятся 938 позиций из 24 серий. Для всех позиций заполнены цена, характеристики, полное и краткое техническое описание, группа, название для формы и изображение. Наличие цены в старом модуле зафиксировано как факт анализа, но цена не считается частью техописания в новой CRM.
+В источнике находятся 938 позиций из 24 серий. Для всех позиций заполнены цена, характеристики, полное и краткое техническое описание, группа, название для формы и изображение. Наличие одиночной цены в старом модуле зафиксировано как факт анализа; в новой CRM цены хранятся у позиции общего каталога по восьми отдельным видам.
 
 ## 3. Место в меню
 
@@ -63,7 +63,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - формирование выгрузки технического описания в установленном формате;
 - использование готового Laravel-модуля как основы для экспорта;
 - хранение данных в структуре общего каталога или связанной таблице, если данные нельзя уложить в основную карточку позиции.
-- получение актуальной цены для DOCX из модуля `Склад` без собственной копии цены в техописании.
+- получение согласованного вида цены для DOCX из `Каталог общий` без собственной копии цены в техописании.
 
 ## 6. Что не переносим из готового модуля
 
@@ -71,7 +71,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - отдельная модель прав, не связанная с каталогом;
 - прямое копирование устаревшего механизма авторизации и публичного хранения экспортов;
 - перезапись актуальных данных общего каталога без сопоставления по артикулу.
-- поле цены в таблицах общего каталога или техописания.
+- отдельное поле цены в данных техописания.
 
 ## 7. Результаты анализа
 
@@ -98,7 +98,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 В текущем `common_catalog_items` отсутствуют готовые поля для названия печатной формы, текстовых характеристик, полного и краткого технического описания. Эти описательные поля должны дополнять общий каталог.
 
-Цена обрабатывается отдельно: единственным источником актуального значения является `Склад`. Значение из `to.stroyprofit.com` можно использовать только для начального заполнения складской цены после подтверждения её назначения. Техописание читает актуальную цену при формировании DOCX и не хранит собственную копию.
+Техописание не хранит собственную копию цены. При формировании DOCX оно читает согласованный вид цены непосредственно из позиции `Каталог общий`. Старое одиночное поле `price` переносится только после сопоставления с одним из восьми новых видов цены.
 
 ## 8. Этапы реализации
 
@@ -107,14 +107,14 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - [x] Составить карту экспортов и форматов выгрузки.
 - [x] Сопоставить данные техописаний с полями `Каталог общий`.
 - [x] Решить, что данные хранятся в карточке общего каталога, а не в отдельном модуле.
-- [x] Исключить цену из модели техописания и общего каталога.
+- [x] Исключить отдельную копию цены из модели техописания; владельцем цен является общий каталог.
 - [ ] Добавить остальные недостающие поля общего каталога.
 - [ ] Перенести данные по артикулу с защитой уже заполненных полей.
-- [ ] После уточнения назначения цены перенести её начальное значение в единый источник модуля `Склад`.
+- [ ] При необходимости сопоставить старое поле `price` с согласованным видом цены общего каталога.
 - [ ] Перенести изображения в текущий файловый механизм CRM.
 - [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
 - [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
-- [ ] Подставлять в экспорт актуальную цену из `Склад`.
+- [ ] Подставлять в экспорт согласованный вид цены из `Каталог общий`.
 - [ ] Выполнять массовые экспорты через очередь и выдавать результат из приватного хранилища.
 - [x] Не добавлять отдельный пункт меню `Технич. описание` в финальную навигацию.
 
@@ -122,7 +122,7 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 
 - Техническое описание доступно из карточки позиции общего каталога.
 - Данные техописания не дублируют общий каталог без необходимости.
-- Цена не хранится в техописании и берётся из единого источника модуля `Склад`.
+- Цена не дублируется в техописании и берётся из позиции общего каталога.
 - Экспорт техописания работает в согласованном формате.
 - Готовый Laravel-модуль использован как источник экспортной логики.
 - Права просмотра/редактирования согласованы с правами общего каталога.
@@ -132,4 +132,4 @@ Laravel-модуль `to.stroyprofit.com` изучен и используетс
 - Переносить все 938 позиций как источник данных или только обогащать артикулы, уже существующие в общем каталоге?
 - Сохраняются ли оба текущих DOCX-шаблона без визуальных изменений?
 - Нужна ли история изменения текстов технического описания?
-- Какой вид цены хранится в старом модуле и зависит ли она от конкретного складского заказа/партии?
+- Какому из восьми видов цены общего каталога соответствует старое поле `price`?

BIN
docs/refactor/Каталог общий.xlsx


+ 118 - 36
resources/views/common_catalog/edit.blade.php

@@ -1,11 +1,31 @@
 @extends('layouts.app')
 
 @section('content')
+    @php
+        $commonCatalogReadableFields = $commonCatalogReadableFields ?? [];
+        $commonCatalogWritableFields = $commonCatalogWritableFields ?? [];
+        $canView = static fn (string $field): bool => (bool) ($commonCatalogReadableFields[$field] ?? false);
+        $canUpdate = static fn (string $field): bool => (bool) ($commonCatalogWritableFields[$field] ?? false);
+        $calculatorEnabledValue = old(
+            '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',
+        ];
+    @endphp
     <div class="px-3">
         <div class="row mb-3">
             <div class="col-md-7 d-flex align-items-center">
                 <h3 class="mb-0">
-                    {{ $item ? 'Позиция '.$item->article : 'Новая позиция общего каталога' }}
+                    {{ $item ? 'Позиция '.($canView('article') ? $item->article : '') : 'Новая позиция общего каталога' }}
                 </h3>
             </div>
             @if($item)
@@ -30,7 +50,7 @@
             @endif
         </div>
 
-        @if($item?->imageFile)
+        @if($item?->imageFile && $canView('image'))
             <div class="card mb-3">
                 <div class="card-body d-flex align-items-start gap-3">
                     <a href="{{ $item->imageFile->link }}" data-toggle="lightbox" data-gallery="common-catalog">
@@ -56,55 +76,117 @@
 
             <div class="row">
                 <div class="col-xl-6">
-                    @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $item?->article])
+                    @if($canView('article'))
+                        @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $item?->article, 'disabled' => !$canUpdate('article')])
+                    @endif
 
-                    <div class="row mb-2">
-                        <label for="calculator_name" class="col-form-label small col-md-4 text-md-end">
-                            Наименование по калькулятору <sup>*</sup>
-                        </label>
-                        <div class="col-md-8">
-                            <textarea name="calculator_name" id="calculator_name" rows="4" required
-                                      class="form-control form-control-sm @error('calculator_name') is-invalid @enderror">{{ old('calculator_name', $item?->calculator_name) }}</textarea>
-                            @error('calculator_name')
-                                <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
-                            @enderror
+                    @if($canView('calculator_name'))
+                        <div class="row mb-2">
+                            <label for="calculator_name" class="col-form-label small col-md-4 text-md-end">
+                                Наименование <sup>*</sup>
+                            </label>
+                            <div class="col-md-8">
+                                <textarea name="calculator_name" id="calculator_name" rows="4" required
+                                          @disabled(!$canUpdate('calculator_name'))
+                                          class="form-control form-control-sm @error('calculator_name') is-invalid @enderror">{{ old('calculator_name', $item?->calculator_name) }}</textarea>
+                                @error('calculator_name')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
                         </div>
-                    </div>
+                    @endif
 
-                    @include('partials.input', ['name' => 'kind', 'title' => 'Вид', 'value' => $item?->kind])
-                    @include('partials.input', ['name' => 'dimensions', 'title' => 'Габаритные размеры', 'value' => $item?->dimensions])
-                    @include('partials.input', ['name' => 'fall_height', 'title' => 'Высота падения', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->fall_height])
-                    @include('partials.input', ['name' => 'dimension_unit', 'title' => 'Единица измерения габаритов', 'value' => $item?->dimension_unit])
-                    @include('partials.input', ['name' => 'weight', 'title' => 'Вес, кг.', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->weight])
-                    @include('partials.input', ['name' => 'volume', 'title' => 'Объем, м3', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->volume])
-                    @include('partials.input', ['name' => 'places', 'title' => 'Места', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->places])
-                    @include('partials.input', ['name' => 'max_users', 'title' => 'Макс. кол-во пользователей', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->max_users])
+                    @if($canView('kind')) @include('partials.input', ['name' => 'kind', 'title' => 'Вид', 'value' => $item?->kind, 'disabled' => !$canUpdate('kind')]) @endif
+                    @if($canView('dimension_length')) @include('partials.input', ['name' => 'dimension_length', 'title' => 'Габариты: длина', 'value' => $item?->dimension_length, 'disabled' => !$canUpdate('dimension_length')]) @endif
+                    @if($canView('dimension_width')) @include('partials.input', ['name' => 'dimension_width', 'title' => 'Габариты: ширина', 'value' => $item?->dimension_width, 'disabled' => !$canUpdate('dimension_width')]) @endif
+                    @if($canView('dimension_height')) @include('partials.input', ['name' => 'dimension_height', 'title' => 'Габариты: высота', 'value' => $item?->dimension_height, 'disabled' => !$canUpdate('dimension_height')]) @endif
+                    @if($canView('site_length')) @include('partials.input', ['name' => 'site_length', 'title' => 'Размер участка: длина', 'value' => $item?->site_length, 'disabled' => !$canUpdate('site_length')]) @endif
+                    @if($canView('site_width')) @include('partials.input', ['name' => 'site_width', 'title' => 'Размер участка: ширина', 'value' => $item?->site_width, 'disabled' => !$canUpdate('site_width')]) @endif
+                    @if($canView('fall_height')) @include('partials.input', ['name' => 'fall_height', 'title' => 'Высота падения', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->fall_height, 'disabled' => !$canUpdate('fall_height')]) @endif
+
+                    @if($canView('additional_info'))
+                        <div class="row mb-2">
+                            <label for="additional_info" class="col-form-label small col-md-4 text-md-end">Дополнительные сведения</label>
+                            <div class="col-md-8">
+                                <textarea name="additional_info" id="additional_info" rows="4"
+                                          @disabled(!$canUpdate('additional_info'))
+                                          class="form-control form-control-sm @error('additional_info') is-invalid @enderror">{{ old('additional_info', $item?->additional_info) }}</textarea>
+                                @error('additional_info')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
+                        </div>
+                    @endif
                 </div>
                 <div class="col-xl-6">
-                    @include('partials.input', ['name' => 'age_group', 'title' => 'Возрастная группа', 'value' => $item?->age_group])
-                    @include('partials.input', ['name' => 'unit', 'title' => 'Ед.', 'value' => $item?->unit])
-                    @include('partials.input', ['name' => 'series', 'title' => 'Серия', 'value' => $item?->series])
-                    @include('partials.input', ['name' => 'trademark', 'title' => 'ТМ', 'value' => $item?->trademark])
+                    @if($canView('dimension_unit')) @include('partials.input', ['name' => 'dimension_unit', 'title' => 'Единица измерения габаритов', 'value' => $item?->dimension_unit, 'disabled' => !$canUpdate('dimension_unit')]) @endif
+                    @if($canView('weight')) @include('partials.input', ['name' => 'weight', 'title' => 'Вес, кг.', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->weight, 'disabled' => !$canUpdate('weight')]) @endif
+                    @if($canView('volume')) @include('partials.input', ['name' => 'volume', 'title' => 'Объем, м3', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->volume, 'disabled' => !$canUpdate('volume')]) @endif
+                    @if($canView('places')) @include('partials.input', ['name' => 'places', 'title' => 'Места', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->places, 'disabled' => !$canUpdate('places')]) @endif
 
-                    @foreach([
-                        'additional_info' => 'Дополнительные сведения',
-                        'composition' => 'Состав',
-                        'note' => 'Примечание',
-                    ] as $field => $label)
+                    @if($canView('composition'))
                         <div class="row mb-2">
-                            <label for="{{ $field }}" class="col-form-label small col-md-4 text-md-end">{{ $label }}</label>
+                            <label for="composition" class="col-form-label small col-md-4 text-md-end">Состав</label>
                             <div class="col-md-8">
-                                <textarea name="{{ $field }}" id="{{ $field }}" rows="4"
-                                          class="form-control form-control-sm @error($field) is-invalid @enderror">{{ old($field, $item?->{$field}) }}</textarea>
-                                @error($field)
+                                <textarea name="composition" id="composition" rows="4"
+                                          @disabled(!$canUpdate('composition'))
+                                          class="form-control form-control-sm @error('composition') is-invalid @enderror">{{ old('composition', $item?->composition) }}</textarea>
+                                @error('composition')
                                     <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
                                 @enderror
                             </div>
                         </div>
-                    @endforeach
+                    @endif
+
+                    @if($canView('age_group')) @include('partials.input', ['name' => 'age_group', 'title' => 'Возрастная группа', 'value' => $item?->age_group, 'disabled' => !$canUpdate('age_group')]) @endif
+                    @if($canView('max_users')) @include('partials.input', ['name' => 'max_users', 'title' => 'Макс. кол-во пользователей', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->max_users, 'disabled' => !$canUpdate('max_users')]) @endif
+                    @if($canView('unit')) @include('partials.input', ['name' => 'unit', 'title' => 'Ед.', 'value' => $item?->unit, 'disabled' => !$canUpdate('unit')]) @endif
+                    @if($canView('series')) @include('partials.input', ['name' => 'series', 'title' => 'Серия', 'value' => $item?->series, 'disabled' => !$canUpdate('series')]) @endif
+                    @if($canView('trademark')) @include('partials.input', ['name' => 'trademark', 'title' => 'ТМ', 'value' => $item?->trademark, 'disabled' => !$canUpdate('trademark')]) @endif
+
+                    @if($canView('calculator_enabled'))
+                        <div class="row mb-2">
+                            <label for="calculator_enabled" class="col-form-label small col-md-4 text-md-end">Калькулятор</label>
+                            <div class="col-md-8">
+                                <select name="calculator_enabled" id="calculator_enabled"
+                                        @disabled(!$canUpdate('calculator_enabled'))
+                                        class="form-select form-select-sm @error('calculator_enabled') is-invalid @enderror">
+                                    <option value="">—</option>
+                                    <option value="1" @selected((string) $calculatorEnabledValue === '1')>да</option>
+                                    <option value="0" @selected((string) $calculatorEnabledValue === '0')>нет</option>
+                                </select>
+                                @error('calculator_enabled')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
+                        </div>
+                    @endif
                 </div>
             </div>
 
+            @if(collect(array_keys($priceFields))->contains(fn (string $field): bool => $canView($field)))
+                <div class="card mt-3">
+                    <div class="card-header"><strong>Цены</strong></div>
+                    <div class="card-body row">
+                        @foreach($priceFields as $field => $label)
+                            @if($canView($field))
+                                <div class="col-xl-6">
+                                    @include('partials.input', [
+                                        'name' => $field,
+                                        'title' => $label,
+                                        'type' => 'number',
+                                        'min' => 0,
+                                        'step' => '0.01',
+                                        'value' => $item?->{$field},
+                                        'disabled' => !$canUpdate($field),
+                                    ])
+                                </div>
+                            @endif
+                        @endforeach
+                    </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>

+ 110 - 4
tests/Feature/CommonCatalogControllerTest.php

@@ -25,11 +25,14 @@ class CommonCatalogControllerTest extends TestCase
 
     private User $manager;
 
+    private User $assistantHead;
+
     protected function setUp(): void
     {
         parent::setUp();
         $this->admin = User::factory()->create(['role' => Role::ADMIN]);
         $this->manager = User::factory()->create(['role' => Role::MANAGER]);
+        $this->assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
     }
 
     public function test_guest_cannot_access_common_catalog(): void
@@ -60,6 +63,69 @@ class CommonCatalogControllerTest extends TestCase
             ->assertForbidden();
     }
 
+    public function test_prices_are_visible_to_admin_and_hidden_from_other_roles_by_default(): void
+    {
+        $item = CommonCatalogItem::factory()->create([
+            'article' => 'PRICE-001',
+            'builders_price' => 123456.78,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertSeeText('Цены')
+            ->assertSeeText('строители')
+            ->assertSee('123456.78', false);
+
+        $this->actingAs($this->manager)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertDontSeeText('Цены')
+            ->assertDontSeeText('строители')
+            ->assertDontSee('123456.78', false);
+
+        $this->actingAs($this->assistantHead)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertDontSeeText('Цены')
+            ->assertDontSeeText('строители');
+    }
+
+    public function test_assistant_cannot_change_hidden_price_with_forged_payload(): void
+    {
+        $item = CommonCatalogItem::factory()->create([
+            'calculator_name' => 'Старое наименование',
+            'builders_price' => 100,
+        ]);
+        $payload = $this->validData();
+        $payload['article'] = $item->article;
+        $payload['calculator_name'] = 'Новое наименование';
+        $payload['builders_price'] = 999999;
+
+        $this->actingAs($this->assistantHead)
+            ->put(route('common-catalog.update', $item), $payload)
+            ->assertRedirect();
+
+        $item->refresh();
+        $this->assertSame('Новое наименование', $item->calculator_name);
+        $this->assertSame(100.0, $item->builders_price);
+    }
+
+    public function test_export_is_available_only_to_admin_by_default(): void
+    {
+        Bus::fake([ExportCommonCatalogJob::class]);
+
+        $this->actingAs($this->assistantHead)
+            ->post(route('common-catalog.export'))
+            ->assertForbidden();
+        Bus::assertNotDispatched(ExportCommonCatalogJob::class);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.export'))
+            ->assertRedirect(route('common-catalog.index'));
+        Bus::assertDispatched(ExportCommonCatalogJob::class);
+    }
+
     public function test_admin_can_create_update_and_delete_item(): void
     {
         $this->actingAs($this->admin)
@@ -87,16 +153,43 @@ class CommonCatalogControllerTest extends TestCase
         $this->assertSoftDeleted('common_catalog_items', ['id' => $item->id]);
     }
 
-    public function test_article_and_calculator_name_are_required_and_article_is_unique(): void
+    public function test_article_and_name_are_required_and_their_pair_is_unique(): void
     {
-        CommonCatalogItem::factory()->create(['article' => '0007']);
+        CommonCatalogItem::factory()->create([
+            'article' => '0007',
+            'calculator_name' => 'Игровой комплекс',
+        ]);
 
         $this->actingAs($this->admin)
             ->post(route('common-catalog.store'), [
-                'article' => '0007',
+                'article' => '',
                 'calculator_name' => '',
             ])
             ->assertSessionHasErrors(['article', 'calculator_name']);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), [
+                'article' => '0007',
+                'calculator_name' => 'Игровой комплекс',
+            ])
+            ->assertSessionHasErrors(['article']);
+    }
+
+    public function test_same_article_is_allowed_for_different_named_variants(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => '6416',
+            'calculator_name' => 'Оборудование игровой площадки',
+        ]);
+        $payload = $this->validData();
+        $payload['article'] = '6416';
+        $payload['calculator_name'] = 'Оборудование игровой площадки (Цинк)';
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), $payload)
+            ->assertRedirect();
+
+        $this->assertDatabaseCount('common_catalog_items', 2);
     }
 
     public function test_item_card_contains_technical_description_and_calculation_placeholders(): void
@@ -192,7 +285,11 @@ class CommonCatalogControllerTest extends TestCase
             'article' => '0007',
             'calculator_name' => "0007\nИгровой комплекс",
             'kind' => 'Игровое оборудование',
-            'dimensions' => '2 × 3 × 1,5 м',
+            'dimension_length' => '2',
+            'dimension_width' => '3',
+            'dimension_height' => '1,5',
+            'site_length' => '5',
+            'site_width' => '6',
             'fall_height' => 1.2,
             'dimension_unit' => 'м',
             'weight' => 350.5,
@@ -203,6 +300,15 @@ class CommonCatalogControllerTest extends TestCase
             'unit' => 'шт.',
             'series' => 'Двор',
             'trademark' => 'Наш Двор',
+            'calculator_enabled' => true,
+            'builders_price' => 100000,
+            'wholesale_price' => 105000,
+            'recommended_price' => 110000,
+            'retail_price' => 150000,
+            'project_price' => 200000,
+            'project_with_installation_price' => 240000,
+            'pik_price' => 110000,
+            'recommended_plus_10_price' => 121000,
         ];
     }
 }

+ 12 - 3
tests/Unit/Services/Export/ExportCommonCatalogServiceTest.php

@@ -38,6 +38,10 @@ class ExportCommonCatalogServiceTest extends TestCase
             'article' => '0254',
             'calculator_name' => 'Оборудование для благоустройства',
             'image_file_id' => $image->id,
+            'dimension_length' => 'от 180',
+            'calculator_enabled' => true,
+            'builders_price' => 1418500,
+            'recommended_plus_10_price' => 1888023.50,
         ]);
 
         (new ExportCommonCatalogService)->handle($user->id);
@@ -46,9 +50,14 @@ class ExportCommonCatalogServiceTest extends TestCase
         Storage::disk('public')->assertExists($file->path);
 
         $sheet = IOFactory::load(Storage::disk('public')->path($file->path))->getActiveSheet();
-        $this->assertSame(ExportCommonCatalogService::HEADERS, $sheet->rangeToArray('A1:S1')[0]);
-        $this->assertSame('0254', $sheet->getCell('C2')->getValue());
-        $this->assertSame('Оборудование для благоустройства', $sheet->getCell('D2')->getValue());
+        $this->assertSame(ExportCommonCatalogService::HEADERS, $sheet->rangeToArray('A1:AD1')[0]);
+        $this->assertEquals(ExportCommonCatalogService::SUBHEADERS, $sheet->rangeToArray('A2:AD2')[0]);
+        $this->assertSame('0254', $sheet->getCell('A3')->getValue());
+        $this->assertSame('Оборудование для благоустройства', $sheet->getCell('B3')->getValue());
+        $this->assertSame('от 180', $sheet->getCell('D3')->getValue());
+        $this->assertSame('да', $sheet->getCell('V3')->getValue());
+        $this->assertSame(1418500.0, $sheet->getCell('W3')->getValue());
+        $this->assertSame(1888023.5, $sheet->getCell('AD3')->getValue());
         $this->assertCount(1, $sheet->getDrawingCollection());
     }
 }

+ 107 - 20
tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Tests\Unit\Services\Import;
 
 use App\Models\Import;
+use App\Models\Role;
 use App\Models\User;
 use App\Services\Export\ExportCommonCatalogService;
 use App\Services\Import\ImportCommonCatalogService;
@@ -12,6 +13,7 @@ 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\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@@ -28,7 +30,7 @@ class ImportCommonCatalogServiceTest extends TestCase
     {
         Storage::fake('upload');
         Storage::fake('public');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Оборудование для благоустройства', withImage: true);
 
         $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
@@ -36,6 +38,8 @@ class ImportCommonCatalogServiceTest extends TestCase
             'article' => '0254',
             'calculator_name' => 'Оборудование для благоустройства',
             'places' => 38,
+            'builders_price' => 141850000,
+            'recommended_plus_10_price' => 188802350,
         ]);
         $item = \App\Models\CommonCatalogItem::query()->firstOrFail();
         $this->assertNotNull($item->imageFile);
@@ -54,17 +58,75 @@ class ImportCommonCatalogServiceTest extends TestCase
     public function test_import_accepts_template_header_with_line_break(): void
     {
         Storage::fake('upload');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Тест', "Единица измерения\nгабаритов");
 
         $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
         $this->assertSame('DONE', $import->refresh()->status);
     }
 
+    public function test_import_accepts_real_catalog_row_without_calculator_name(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $import = $this->createImport($user, null);
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => null,
+        ]);
+        $this->assertStringContainsString('ошибок: 0', (string) $import->refresh()->result);
+    }
+
+    public function test_import_does_not_change_prices_without_field_permission(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+        $item = \App\Models\CommonCatalogItem::factory()->create([
+            'article' => '0254',
+            'calculator_name' => 'Старое наименование',
+            'builders_price' => 100,
+        ]);
+        $import = $this->createImport($user, 'Новое наименование');
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $item->refresh();
+        $this->assertSame('Новое наименование', $item->calculator_name);
+        $this->assertSame(100.0, $item->builders_price);
+    }
+
+    public function test_import_preserves_rows_with_same_article_as_named_variants(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $import = $this->createImport(
+            $user,
+            'Оборудование игровой площадки',
+            duplicateVariant: true,
+        );
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $this->assertDatabaseCount('common_catalog_items', 2);
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Оборудование игровой площадки',
+            'builders_price' => 141850000,
+        ]);
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Оборудование игровой площадки (Цинк)',
+            'builders_price' => 162280000,
+        ]);
+    }
+
     public function test_import_rejects_wrong_headers_with_clear_error(): void
     {
         Storage::fake('upload');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Тест', 'Неверный заголовок');
 
         $this->expectException(RuntimeException::class);
@@ -79,35 +141,60 @@ class ImportCommonCatalogServiceTest extends TestCase
 
     private function createImport(
         User $user,
-        string $calculatorName,
+        ?string $calculatorName,
         string $dimensionHeader = "Единица измерения\nгабаритов",
         bool $withImage = false,
+        bool $duplicateVariant = false,
     ): Import {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
         $headers = ExportCommonCatalogService::HEADERS;
-        $headers[8] = $dimensionHeader;
+        $headers[10] = $dimensionHeader;
         $sheet->fromArray($headers, null, 'A1');
-        $sheet->setCellValueExplicit('C3', '0254', DataType::TYPE_STRING);
-        $sheet->setCellValue('D3', $calculatorName);
-        $sheet->setCellValue('E3', 'Теневой навес');
-        $sheet->setCellValue('F3', '9,15 x 3,46 x 3,43 м');
-        $sheet->setCellValue('G3', 0);
-        $sheet->setCellValue('I3', 'м');
-        $sheet->setCellValue('J3', 2153.84);
-        $sheet->setCellValue('K3', 7.93);
-        $sheet->setCellValue('L3', 38);
-        $sheet->setCellValue('N3', '0+');
-        $sheet->setCellValue('O3', 10);
-        $sheet->setCellValue('P3', 'шт.');
-        $sheet->setCellValue('Q3', 'Теневые навесы');
-        $sheet->setCellValue('R3', 'Наш Двор');
+        $sheet->fromArray(ExportCommonCatalogService::SUBHEADERS, null, 'A2');
+        $sheet->setCellValueExplicit('A3', '0254', DataType::TYPE_STRING);
+        $sheet->setCellValue('B3', $calculatorName);
+        $sheet->setCellValue('C3', 'Теневой навес');
+        $sheet->setCellValue('D3', 'от 180');
+        $sheet->setCellValue('E3', '1,37-1,55');
+        $sheet->setCellValue('F3', 3.43);
+        $sheet->setCellValue('G3', 9.15);
+        $sheet->setCellValue('H3', 3.46);
+        $sheet->setCellValue('I3', 0);
+        $sheet->setCellValue('K3', 'м');
+        $sheet->setCellValue('L3', 2153.84);
+        $sheet->setCellValue('M3', 7.93);
+        $sheet->setCellValue('N3', 38);
+        $sheet->setCellValue('P3', '0+');
+        $sheet->setCellValue('Q3', 10);
+        $sheet->setCellValue('R3', 'шт.');
+        $sheet->setCellValue('T3', 'Теневые навесы');
+        $sheet->setCellValue('U3', 'Наш Двор');
+        $sheet->setCellValue('V3', 'да');
+        $sheet->setCellValue('W3', 1418500);
+        $sheet->setCellValue('X3', 1489425);
+        $sheet->setCellValue('Y3', 1560350);
+        $sheet->setCellValue('Z3', 2127750);
+        $sheet->setCellValue('AA3', 2837000);
+        $sheet->setCellValue('AB3', 3404400);
+        $sheet->setCellValue('AC3', 1560350);
+        $sheet->setCellValue('AD3', 1888023.50);
+
+        if ($duplicateVariant) {
+            for ($index = 1; $index <= 30; $index++) {
+                $column = Coordinate::stringFromColumnIndex($index);
+                $sheet->setCellValue("{$column}4", $sheet->getCell("{$column}3")->getValue());
+            }
+            $sheet->setCellValue('B4', $calculatorName.' (Цинк)');
+            $sheet->setCellValue('V4', 'нет');
+            $sheet->setCellValue('W4', 1622800);
+        }
 
         if ($withImage) {
             $image = UploadedFile::fake()->image('catalog-item.png', 100, 100);
             $drawing = new Drawing;
             $drawing->setPath($image->getPathname());
-            $drawing->setCoordinates('B3');
+            $drawing->setCoordinates('S3');
             $drawing->setWorksheet($sheet);
         }
 

+ 24 - 0
tests/Unit/Services/Import/ImportStockOrdersServiceTest.php

@@ -57,6 +57,30 @@ class ImportStockOrdersServiceTest extends TestCase
         $this->assertSame('DONE', $secondImport->refresh()->status);
     }
 
+    public function test_import_does_not_choose_arbitrary_catalog_variant_for_duplicate_article(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create();
+        CommonCatalogItem::factory()->create([
+            'article' => '00125',
+            'calculator_name' => 'Обычное исполнение',
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => '00125',
+            'calculator_name' => 'Исполнение Цинк',
+        ]);
+        $import = $this->createImport($user, 'Заказан', 5, 'Ожидаем поставку');
+
+        $this->assertTrue($this->service($import, $user)->handle());
+
+        $this->assertDatabaseCount('stock_orders', 0);
+        $this->assertSame('DONE', $import->refresh()->status);
+        $this->assertStringContainsString(
+            'соответствует нескольким вариантам',
+            (string) $import->result,
+        );
+    }
+
     private function createImport(User $user, string $status, int $quantity, string $note): Import
     {
         $spreadsheet = new Spreadsheet;