Ver Fonte

implemented common catalog core

Alexander Musikhin há 1 dia atrás
pai
commit
a87c1b602e

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

@@ -0,0 +1,305 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreCommonCatalogItemRequest;
+use App\Jobs\Export\ExportCommonCatalogJob;
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\Import;
+use App\Services\FileService;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Throwable;
+
+class CommonCatalogController extends Controller
+{
+    protected array $data = [
+        'active' => 'common_catalog',
+        'title' => 'Каталог общий',
+        'id' => 'common_catalog_items',
+        'header' => [
+            'id' => 'ID',
+            'image' => 'Внешний вид',
+            'article' => 'Артикул',
+            'calculator_name' => 'Наименование по калькулятору',
+            'kind' => 'Вид',
+            'dimensions' => 'Габаритные размеры',
+            'fall_height' => 'Высота падения',
+            'additional_info' => 'Дополнительные сведения',
+            'dimension_unit' => 'Единица измерения габаритов',
+            'weight' => 'Вес, кг.',
+            'volume' => 'Объем, м3',
+            'places' => 'Места',
+            'composition' => 'Состав',
+            'age_group' => 'Возрастная группа',
+            'max_users' => 'Макс. кол-во пользователей',
+            'unit' => 'Ед.',
+            'series' => 'Серия',
+            'trademark' => 'ТМ',
+            'note' => 'Примечание',
+        ],
+        'searchFields' => [
+            'article',
+            'calculator_name',
+            'kind',
+            'dimensions',
+            'additional_info',
+            'composition',
+            'series',
+            'trademark',
+            'note',
+        ],
+    ];
+
+    public function index(Request $request): View
+    {
+        session(['gp_common_catalog' => $request->query()]);
+        $nav = $this->startNavigationContext($request);
+        $model = new CommonCatalogItem;
+
+        $this->createFilters(
+            $model,
+            'kind',
+            'dimension_unit',
+            'age_group',
+            'unit',
+            'series',
+            'trademark',
+        );
+        $this->createRangeFilters(
+            $model,
+            'fall_height',
+            'weight',
+            'volume',
+            'places',
+            'max_users',
+        );
+
+        $query = CommonCatalogItem::query()->with('imageFile');
+        $this->acceptFilters($query, $request);
+        $this->acceptSearch($query, $request);
+        $this->setSortAndOrderBy($model, $request);
+        $this->applyStableSorting($query);
+
+        $this->data['items'] = $query->paginate($this->data['per_page'])->withQueryString();
+        $this->data['nav'] = $nav;
+
+        return view('common_catalog.index', $this->data);
+    }
+
+    public function create(Request $request): View
+    {
+        return $this->itemView($request);
+    }
+
+    public function show(Request $request, CommonCatalogItem $commonCatalogItem): View
+    {
+        return $this->itemView(
+            $request,
+            $commonCatalogItem->load(['imageFile', 'documents']),
+        );
+    }
+
+    public function store(StoreCommonCatalogItemRequest $request): RedirectResponse
+    {
+        $item = CommonCatalogItem::query()->create($request->validated());
+
+        return redirect()
+            ->route('common-catalog.show', $this->withNav(
+                ['commonCatalogItem' => $item],
+                $this->resolveNavToken($request),
+            ))
+            ->with('success', 'Позиция общего каталога создана.');
+    }
+
+    public function update(
+        StoreCommonCatalogItemRequest $request,
+        CommonCatalogItem $commonCatalogItem,
+    ): RedirectResponse {
+        $commonCatalogItem->update($request->validated());
+
+        return $this->redirectToItem($request, $commonCatalogItem)
+            ->with('success', 'Позиция общего каталога обновлена.');
+    }
+
+    public function destroy(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FileService $fileService,
+    ): RedirectResponse {
+        $commonCatalogItem->load(['imageFile', 'documents']);
+        $files = $commonCatalogItem->documents;
+        if ($commonCatalogItem->imageFile) {
+            $files->push($commonCatalogItem->imageFile);
+        }
+
+        $commonCatalogItem->documents()->detach();
+        $commonCatalogItem->update(['image_file_id' => null]);
+        $commonCatalogItem->delete();
+
+        foreach ($files->unique('id') as $file) {
+            $fileService->deleteFileWithThumbnail($file);
+            $file->delete();
+        }
+
+        return redirect()
+            ->route('common-catalog.index', session('gp_common_catalog'))
+            ->with('success', 'Позиция общего каталога удалена.');
+    }
+
+    public function uploadImage(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FileService $fileService,
+    ): RedirectResponse {
+        $data = $request->validate([
+            'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
+        ]);
+
+        try {
+            $oldImage = $commonCatalogItem->imageFile;
+            $image = $fileService->saveUploadedFile(
+                "common_catalog/items/{$commonCatalogItem->id}/image",
+                $data['image'],
+            );
+            $commonCatalogItem->update(['image_file_id' => $image->id]);
+
+            if ($oldImage) {
+                $fileService->deleteFileWithThumbnail($oldImage);
+                $oldImage->delete();
+            }
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return $this->redirectToItem($request, $commonCatalogItem)
+                ->with('error', 'Не удалось загрузить изображение.');
+        }
+
+        return $this->redirectToItem($request, $commonCatalogItem)
+            ->with('success', 'Изображение загружено.');
+    }
+
+    public function deleteImage(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FileService $fileService,
+    ): RedirectResponse {
+        $image = $commonCatalogItem->imageFile;
+        if ($image) {
+            $commonCatalogItem->update(['image_file_id' => null]);
+            $fileService->deleteFileWithThumbnail($image);
+            $image->delete();
+        }
+
+        return $this->redirectToItem($request, $commonCatalogItem)
+            ->with('success', 'Изображение удалено.');
+    }
+
+    public function uploadDocument(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FileService $fileService,
+    ): RedirectResponse {
+        $data = $request->validate([
+            'document' => ['required', 'file', 'max:20480'],
+        ]);
+
+        try {
+            $document = $fileService->saveUploadedFile(
+                "common_catalog/items/{$commonCatalogItem->id}/documents",
+                $data['document'],
+            );
+            $commonCatalogItem->documents()->attach($document);
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return $this->redirectToItem($request, $commonCatalogItem)
+                ->with('error', 'Не удалось загрузить документ.');
+        }
+
+        return $this->redirectToItem($request, $commonCatalogItem)
+            ->with('success', 'Документ загружен.');
+    }
+
+    public function deleteDocument(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        File $file,
+        FileService $fileService,
+    ): RedirectResponse {
+        abort_unless($commonCatalogItem->documents()->whereKey($file->getKey())->exists(), 404);
+
+        $commonCatalogItem->documents()->detach($file);
+        $fileService->deleteFileWithThumbnail($file);
+        $file->delete();
+
+        return $this->redirectToItem($request, $commonCatalogItem)
+            ->with('success', 'Документ удалён.');
+    }
+
+    public function export(Request $request): RedirectResponse
+    {
+        ExportCommonCatalogJob::dispatch((int) $request->user()->getKey());
+
+        return redirect()
+            ->route('common-catalog.index', session('gp_common_catalog'))
+            ->with('success', 'Задача экспорта общего каталога создана.');
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $data = $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
+        ]);
+
+        $path = Str::random(2).'/'.Str::uuid().'.xlsx';
+        Storage::disk('upload')->put($path, $data['import_file']->getContent());
+
+        $import = Import::query()->create([
+            'type' => 'common_catalog',
+            'year' => null,
+            'filename' => $path,
+            'status' => 'new',
+            'user_id' => $request->user()->getKey(),
+            'original_filename' => $data['import_file']->getClientOriginalName(),
+        ]);
+
+        ImportJob::dispatch($import, (int) $request->user()->getKey());
+
+        return redirect()
+            ->route('common-catalog.index', session('gp_common_catalog'))
+            ->with('success', 'Задача импорта общего каталога создана.');
+    }
+
+    private function itemView(Request $request, ?CommonCatalogItem $item = null): View
+    {
+        $nav = $this->resolveNavToken($request);
+        $this->rememberNavigation($request, $nav);
+        $this->data['nav'] = $nav;
+        $this->data['back_url'] = $this->navigationBackUrl(
+            $request,
+            $nav,
+            route('common-catalog.index', session('gp_common_catalog')),
+        );
+        $this->data['item'] = $item;
+
+        return view('common_catalog.edit', $this->data);
+    }
+
+    private function redirectToItem(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+    ): RedirectResponse {
+        return redirect()->route('common-catalog.show', $this->withNav(
+            ['commonCatalogItem' => $commonCatalogItem],
+            $this->resolveNavToken($request),
+        ));
+    }
+}

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

@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Models\CommonCatalogItem;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class StoreCommonCatalogItemRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        $item = $this->route('commonCatalogItem');
+
+        return [
+            'article' => [
+                'required',
+                'string',
+                'max:100',
+                Rule::unique('common_catalog_items', 'article')->ignore(
+                    $item instanceof CommonCatalogItem ? $item->getKey() : null,
+                ),
+            ],
+            'calculator_name' => ['required', 'string'],
+            'kind' => ['nullable', 'string', 'max:255'],
+            'dimensions' => ['nullable', 'string'],
+            'fall_height' => ['nullable', 'numeric', 'min:0'],
+            'additional_info' => ['nullable', 'string'],
+            'dimension_unit' => ['nullable', 'string', 'max:50'],
+            'weight' => ['nullable', 'numeric', 'min:0'],
+            'volume' => ['nullable', 'numeric', 'min:0'],
+            'places' => ['nullable', 'integer', 'min:0'],
+            'composition' => ['nullable', 'string'],
+            'age_group' => ['nullable', 'string', 'max:100'],
+            'max_users' => ['nullable', 'integer', 'min:0'],
+            'unit' => ['nullable', 'string', 'max:50'],
+            'series' => ['nullable', 'string', 'max:255'],
+            'trademark' => ['nullable', 'string', 'max:255'],
+            'note' => ['nullable', 'string'],
+        ];
+    }
+}

+ 28 - 0
app/Jobs/Export/ExportCommonCatalogJob.php

@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportCommonCatalogService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+
+class ExportCommonCatalogJob implements ShouldQueue
+{
+    use Queueable;
+
+    public function __construct(private readonly int $userId) {}
+
+    public function handle(ExportCommonCatalogService $service): void
+    {
+        $link = $service->handle($this->userId);
+
+        event(new SendWebSocketMessageEvent(
+            'Экспорт общего каталога завершён!',
+            $this->userId,
+            ['link' => $link],
+        ));
+    }
+}

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

@@ -5,6 +5,7 @@ namespace App\Jobs\Import;
 use App\Events\SendWebSocketMessageEvent;
 use App\Models\Import;
 use App\Services\Import\ImportMafOrdersService;
+use App\Services\Import\ImportCommonCatalogService;
 use App\Services\Import\ImportSparePartOrdersService;
 use App\Services\ImportCatalogService;
 use Illuminate\Support\Facades\Storage;
@@ -48,6 +49,9 @@ class ImportJob implements ShouldQueue
                 case 'catalog':
                     (new ImportCatalogService($this->import, $this->import->year))->handle();
                     break;
+                case 'common_catalog':
+                    (new ImportCommonCatalogService($this->import, $this->userId))->handle();
+                    break;
                 case 'spare_part_orders':
                     $this->handleSparePartOrdersImport();
                     break;

+ 77 - 0
app/Models/CommonCatalogItem.php

@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\CommonCatalogItemFactory;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class CommonCatalogItem extends Model
+{
+    /** @use HasFactory<CommonCatalogItemFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    public const DEFAULT_SORT_BY = 'article';
+
+    protected $fillable = [
+        'image_file_id',
+        'article',
+        'calculator_name',
+        'kind',
+        'dimensions',
+        'fall_height',
+        'additional_info',
+        'dimension_unit',
+        'weight',
+        'volume',
+        'places',
+        'composition',
+        'age_group',
+        'max_users',
+        'unit',
+        'series',
+        'trademark',
+        'note',
+    ];
+
+    protected $appends = ['image'];
+
+    protected function casts(): array
+    {
+        return [
+            'fall_height' => 'decimal:3',
+            'weight' => 'decimal:3',
+            'volume' => 'decimal:3',
+            'places' => 'integer',
+            'max_users' => 'integer',
+        ];
+    }
+
+    public function imageFile(): BelongsTo
+    {
+        return $this->belongsTo(File::class, 'image_file_id');
+    }
+
+    public function documents(): BelongsToMany
+    {
+        return $this->belongsToMany(
+            File::class,
+            'common_catalog_item_documents',
+        )->withTimestamps();
+    }
+
+    protected function image(): Attribute
+    {
+        return Attribute::make(
+            get: fn (): string => (string) ($this->imageFile?->thumbnail_link ?? ''),
+        );
+    }
+}

+ 1 - 0
app/Models/Import.php

@@ -20,6 +20,7 @@ class Import extends Model
         'reclamations' => 'Рекламации',
         'mafs' => 'МАФы',
         'catalog' => 'Каталог',
+        'common_catalog' => 'Каталог общий',
         'spare_part_orders' => 'Заказы запчастей',
         'maf_orders' => 'Заказы МАФ',
     ];

+ 150 - 0
app/Services/Export/ExportCommonCatalogService.php

@@ -0,0 +1,150 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Export;
+
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportCommonCatalogService
+{
+    public const HEADERS = [
+        'ID',
+        'Внешний вид',
+        'Артикул',
+        'Наименование по калькулятору',
+        'Вид',
+        'Габаритные размеры',
+        'Высота падения',
+        'Дополнительные сведения',
+        "Единица измерения\nгабаритов",
+        'Вес, кг.',
+        'Объем, м3',
+        'Места',
+        'Состав',
+        'Возрастная группа',
+        'Макс.кол-во пользователей',
+        'Ед.',
+        'Серия',
+        'ТМ',
+        'Примечание',
+    ];
+
+    public function handle(int $userId): string
+    {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Каталог общий');
+        $sheet->fromArray(self::HEADERS, null, 'A1');
+
+        $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;
+        CommonCatalogItem::query()
+            ->with('imageFile')
+            ->orderBy('article')
+            ->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);
+
+                    $this->addImage($sheet, $item, $row);
+                    $row++;
+                }
+            });
+
+        if ($row > 2) {
+            $sheet->getStyle('A1:S'.($row - 1))
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN);
+            $sheet->getStyle('A2:S'.($row - 1))->getAlignment()
+                ->setVertical(Alignment::VERTICAL_TOP)
+                ->setWrapText(true);
+        }
+
+        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);
+        }
+
+        $directory = 'export/common_catalog';
+        $filename = 'common_catalog_'.now()->format('Y-m-d_H-i-s').'.xlsx';
+        $relativePath = "{$directory}/{$filename}";
+        Storage::disk('public')->makeDirectory($directory);
+        (new Xlsx($spreadsheet))->save(Storage::disk('public')->path($relativePath));
+        $spreadsheet->disconnectWorksheets();
+
+        $link = url('/storage/'.$relativePath);
+        File::query()->create([
+            'link' => $link,
+            'path' => $relativePath,
+            'user_id' => $userId,
+            'original_name' => $filename,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'is_generated' => true,
+        ]);
+
+        return $link;
+    }
+
+    private function addImage(Worksheet $sheet, CommonCatalogItem $item, int $row): void
+    {
+        $path = $item->imageFile?->path;
+        if (! $path || ! Storage::disk('public')->exists($path)) {
+            return;
+        }
+
+        $drawing = new Drawing;
+        $drawing->setName($item->article);
+        $drawing->setPath(Storage::disk('public')->path($path));
+        $drawing->setCoordinates("B{$row}");
+        $drawing->setHeight(68);
+        $drawing->setOffsetX(4);
+        $drawing->setOffsetY(4);
+        $drawing->setWorksheet($sheet);
+        $sheet->getRowDimension($row)->setRowHeight(56);
+    }
+}

+ 251 - 0
app/Services/Import/ImportCommonCatalogService.php

@@ -0,0 +1,251 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\Import;
+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\Reader\Xlsx;
+use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing;
+use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
+use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing;
+use RuntimeException;
+use Throwable;
+
+class ImportCommonCatalogService
+{
+    public function __construct(
+        private readonly Import $import,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): bool
+    {
+        try {
+            $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]);
+            $drawings = $this->drawingsByRow($sheet->getDrawingCollection());
+
+            $created = 0;
+            $updated = 0;
+            $errors = 0;
+
+            for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
+                $article = $this->stringValue($sheet->getCell("C{$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()),
+                ];
+
+                $validator = Validator::make($data, $this->rules());
+                if ($validator->fails()) {
+                    $errors++;
+                    $this->import->log(
+                        "Строка {$row}: ".implode(' ', $validator->errors()->all()),
+                        'WARNING',
+                    );
+
+                    continue;
+                }
+
+                $item = CommonCatalogItem::query()->where('article', $article)->first();
+                if ($item) {
+                    $item->update($validator->validated());
+                    $updated++;
+                } else {
+                    $item = CommonCatalogItem::query()->create($validator->validated());
+                    $created++;
+                }
+
+                if (isset($drawings[$row])) {
+                    try {
+                        $this->replaceImage($item, $drawings[$row]);
+                    } catch (Throwable $exception) {
+                        $errors++;
+                        $this->import->log(
+                            "Строка {$row}: изображение не импортировано ({$exception->getMessage()}).",
+                            'WARNING',
+                        );
+                    }
+                }
+            }
+
+            $this->import->log("Создано: {$created}; обновлено: {$updated}; ошибок: {$errors}.");
+            $this->import->status = 'DONE';
+            $this->import->save();
+            $spreadsheet->disconnectWorksheets();
+
+            return true;
+        } catch (Throwable $exception) {
+            $this->import->log($exception->getMessage(), 'ERROR');
+            $this->import->status = 'ERROR';
+            $this->import->save();
+
+            throw $exception;
+        }
+    }
+
+    private function assertHeaders(array $headers): void
+    {
+        $actual = array_map($this->normalizeHeader(...), $headers);
+        $expected = array_map($this->normalizeHeader(...), ExportCommonCatalogService::HEADERS);
+
+        if ($actual !== $expected) {
+            throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый шаблон.');
+        }
+    }
+
+    /** @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) {
+                $result[(int) $matches[1]] = $drawing;
+            }
+        }
+
+        return $result;
+    }
+
+    private function replaceImage(CommonCatalogItem $item, BaseDrawing $drawing): void
+    {
+        [$contents, $mimeType, $extension] = $this->drawingContents($drawing);
+        $relativePath = "common_catalog/items/{$item->id}/image/".Str::uuid().".{$extension}";
+        Storage::disk('public')->put($relativePath, $contents);
+
+        $newImage = File::query()->create([
+            'user_id' => $this->userId,
+            'original_name' => "{$item->article}.{$extension}",
+            'mime_type' => $mimeType,
+            'path' => $relativePath,
+            'link' => url('/storage/'.$relativePath),
+        ]);
+        app(FileService::class)->ensureThumbnail($newImage);
+
+        $oldImage = $item->imageFile;
+        $item->update(['image_file_id' => $newImage->id]);
+        if ($oldImage) {
+            app(FileService::class)->deleteFileWithThumbnail($oldImage);
+            $oldImage->delete();
+        }
+    }
+
+    private function drawingContents(BaseDrawing $drawing): array
+    {
+        if ($drawing instanceof MemoryDrawing) {
+            ob_start();
+            ($drawing->getRenderingFunction())($drawing->getImageResource());
+            $contents = ob_get_clean();
+            $mimeType = $drawing->getMimeType();
+        } elseif ($drawing instanceof Drawing) {
+            $contents = file_get_contents($drawing->getPath());
+            $mimeType = $this->mimeType($contents ?: '');
+        } else {
+            throw new RuntimeException('Неподдерживаемый формат изображения.');
+        }
+
+        if (! is_string($contents) || $contents === '') {
+            throw new RuntimeException('Пустое изображение.');
+        }
+
+        $extension = match ($mimeType) {
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/webp' => 'webp',
+            default => throw new RuntimeException("Неподдерживаемый MIME-тип {$mimeType}"),
+        };
+
+        return [$contents, $mimeType, $extension];
+    }
+
+    private function mimeType(string $contents): string
+    {
+        $info = new \finfo(FILEINFO_MIME_TYPE);
+
+        return (string) $info->buffer($contents);
+    }
+
+    private function normalizeHeader(mixed $value): string
+    {
+        return trim((string) preg_replace('/\s+/u', ' ', (string) $value));
+    }
+
+    private function stringValue(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? null : $value;
+    }
+
+    private function numericValue(mixed $value): int|float|string|null
+    {
+        $value = $this->stringValue($value);
+        if ($value === null) {
+            return null;
+        }
+
+        return str_replace([' ', ','], ['', '.'], $value);
+    }
+
+    private function integerValue(mixed $value): int|string|null
+    {
+        $value = $this->numericValue($value);
+        if ($value === null) {
+            return null;
+        }
+
+        return is_numeric($value) ? (int) $value : (string) $value;
+    }
+
+    private function rules(): array
+    {
+        return [
+            'article' => ['required', 'string', 'max:100'],
+            'calculator_name' => ['required', 'string'],
+            'kind' => ['nullable', 'string', 'max:255'],
+            'dimensions' => ['nullable', 'string'],
+            'fall_height' => ['nullable', 'numeric', 'min:0'],
+            'additional_info' => ['nullable', 'string'],
+            'dimension_unit' => ['nullable', 'string', 'max:50'],
+            'weight' => ['nullable', 'numeric', 'min:0'],
+            'volume' => ['nullable', 'numeric', 'min:0'],
+            'places' => ['nullable', 'integer', 'min:0'],
+            'composition' => ['nullable', 'string'],
+            'age_group' => ['nullable', 'string', 'max:100'],
+            'max_users' => ['nullable', 'integer', 'min:0'],
+            'unit' => ['nullable', 'string', 'max:50'],
+            'series' => ['nullable', 'string', 'max:255'],
+            'trademark' => ['nullable', 'string', 'max:255'],
+            'note' => ['nullable', 'string'],
+        ];
+    }
+}

+ 16 - 0
config/access.php

@@ -111,6 +111,22 @@ return [
             'places' => 'Мест',
         ],
     ],
+    'common-catalog' => [
+        'name' => 'Каталог общий',
+        'entity' => 'common_catalog_item',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'import' => 'Импорт',
+            'export' => 'Экспорт',
+            'image.upload' => 'Загрузка изображения',
+            'image.delete' => 'Удаление изображения',
+            'documents.upload' => 'Загрузка документов',
+            'documents.delete' => 'Удаление документов',
+        ],
+    ],
     'maf' => [
         'name' => 'МАФ',
         'entity' => 'product_sku',

+ 14 - 1
config/access_routes.php

@@ -4,7 +4,6 @@ return [
     'exact' => [
         'area.ajax-get-areas-by-district' => 'areas.ajax.view',
         'calculations.index' => true,
-        'common-catalog.index' => true,
         'documents.index' => true,
         'getFilters' => 'filters.view',
         'notifications.index' => true,
@@ -64,6 +63,20 @@ return [
             'delete-certificate' => 'catalog.certificates.delete',
             'upload-thumbnail' => 'catalog.thumbnail.upload',
         ],
+        'common-catalog.' => [
+            'index' => 'common-catalog.view',
+            'show' => 'common-catalog.view',
+            'create' => 'common-catalog.create',
+            'store' => 'common-catalog.create',
+            'update' => 'common-catalog.update',
+            'destroy' => 'common-catalog.delete',
+            'import' => 'common-catalog.import',
+            'export' => 'common-catalog.export',
+            'image.upload' => 'common-catalog.image.upload',
+            'image.delete' => 'common-catalog.image.delete',
+            'documents.upload' => 'common-catalog.documents.upload',
+            'documents.delete' => 'common-catalog.documents.delete',
+        ],
         'contract.' => [
             'index' => 'contracts.view',
             'show' => 'contracts.view',

+ 37 - 0
database/factories/CommonCatalogItemFactory.php

@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\CommonCatalogItem;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<CommonCatalogItem> */
+class CommonCatalogItemFactory extends Factory
+{
+    protected $model = CommonCatalogItem::class;
+
+    public function definition(): array
+    {
+        return [
+            '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 м']),
+            'fall_height' => fake()->randomFloat(3, 0, 3),
+            'additional_info' => fake()->optional()->sentence(),
+            'dimension_unit' => 'м',
+            'weight' => fake()->randomFloat(3, 10, 2000),
+            'volume' => fake()->randomFloat(3, 1, 20),
+            'places' => fake()->numberBetween(1, 50),
+            'composition' => fake()->optional()->sentence(),
+            'age_group' => fake()->randomElement(['0+', '3+', '6+']),
+            'max_users' => fake()->numberBetween(1, 20),
+            'unit' => 'шт.',
+            'series' => fake()->word(),
+            'trademark' => fake()->company(),
+            'note' => fake()->optional()->sentence(),
+        ];
+    }
+}

+ 52 - 0
database/migrations/2026_08_03_000001_create_common_catalog_items_table.php

@@ -0,0 +1,52 @@
+<?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::create('common_catalog_items', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('image_file_id')->nullable()->constrained('files')->nullOnDelete();
+            $table->string('article', 100)->unique();
+            $table->text('calculator_name');
+            $table->string('kind')->nullable()->index();
+            $table->text('dimensions')->nullable();
+            $table->decimal('fall_height', 12, 3)->nullable();
+            $table->text('additional_info')->nullable();
+            $table->string('dimension_unit', 50)->nullable();
+            $table->decimal('weight', 12, 3)->nullable();
+            $table->decimal('volume', 12, 3)->nullable();
+            $table->unsignedInteger('places')->nullable();
+            $table->text('composition')->nullable();
+            $table->string('age_group', 100)->nullable();
+            $table->unsignedInteger('max_users')->nullable();
+            $table->string('unit', 50)->nullable();
+            $table->string('series')->nullable()->index();
+            $table->string('trademark')->nullable()->index();
+            $table->text('note')->nullable();
+            $table->timestamps();
+            $table->softDeletes();
+        });
+
+        Schema::create('common_catalog_item_documents', function (Blueprint $table): void {
+            $table->foreignId('common_catalog_item_id')
+                ->constrained('common_catalog_items')
+                ->cascadeOnDelete();
+            $table->foreignId('file_id')->constrained('files')->cascadeOnDelete();
+            $table->timestamps();
+            $table->primary(['common_catalog_item_id', 'file_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('common_catalog_item_documents');
+        Schema::dropIfExists('common_catalog_items');
+    }
+};

+ 1 - 0
database/seeders/RbacSeeder.php

@@ -120,6 +120,7 @@ class RbacSeeder extends Seeder
             'notifications.mark_read',
             'areas.ajax.view',
             'catalog.search',
+            'common-catalog.view',
             'pricing_codes.search',
             'filters.view',
         ];

+ 7 - 7
docs/refactor/menu.md

@@ -89,7 +89,7 @@ Manager
 
 ### Рекламации
 
-Статус: **Частично**.
+Статус: **Ядро реализовано, ожидает пользовательской проверки**.
 
 В текущем разделе реализованы отдельные вкладки `Все` и `ДКР`. Вкладка `ДКР` отделяет рекламации, по которым разрешено формирование документации для оплаты.
 
@@ -145,12 +145,12 @@ Manager
 
 | Пункт | Статус | Текущий маршрут | Комментарий |
 |---|---|---|---|
-| Позиции | Нужно реализовать | - | Новый общий справочник. Состав и порядок колонок берутся из `docs/refactor/Шаблон Каталог ощий.xlsx`, нужны импорт/экспорт. |
-| Карточка позиции | Нужно реализовать | - | Центральная карточка общей позиции/МАФ. |
-| Фото МАФ | Нужно реализовать | - | Фото позиции общего каталога. |
-| Документы | Нужно реализовать | - | Сопутствующие документы позиции через текущий файловый механизм. |
-| Техническое описание | Нужно реализовать внутри карточки | - | Встроено в общий каталог как выгрузка/представление позиции в нужном формате. |
-| Калькуляция | Нужно реализовать | - | API-запрос по артикулу и отображение возвращенной калькуляции, если она есть. |
+| Позиции | Реализовано | `common-catalog.index` | Отдельный общий справочник с поиском, фильтрами, пагинацией и импортом/экспортом по утвержденному XLSX-формату. |
+| Карточка позиции | Реализовано | `common-catalog.show` | Центральная карточка общей позиции/МАФ со всеми полями шаблона. |
+| Фото МАФ | Реализовано | `common-catalog.image.*` | Фото хранится через текущий файловый механизм и выводится в списке/карточке. |
+| Документы | Реализовано | `common-catalog.documents.*` | Поддержан набор сопутствующих документов позиции. |
+| Техническое описание | Подготовлено место внутри карточки | `common-catalog.show` | Экспорт будет подключен после получения готового Laravel-модуля. |
+| Калькуляция | Подготовлена кнопка | `common-catalog.show` | API-запрос будет подключен после получения контракта API. |
 
 ### Документация
 

+ 16 - 14
docs/refactor/plan.md

@@ -72,10 +72,10 @@
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
-| 4. Общий каталог: ядро | Готов к реализации | Есть маршрутный префикс, техническое имя, шаблон Excel, состав колонок и предварительные типы данных. |
+| 4. Общий каталог: ядро | Реализован и проверен | Пользователь проверил создание, редактирование, импорт и экспорт; отдельная модель данных, права, фото и документы покрыты автоматическими тестами. |
 | 5. Документация | Готов к реализации | Решены дерево папок, наследование прав, версии и хранение файлов. |
-| 6. Склад наличие: ядро | Готов после ядра общего каталога | Логика берется из `Запчасти`, но позиции должны браться из `Каталог общий`. PDF-экспорт остается отдельным открытым подпунктом. |
-| 7. Техническое описание | Готово после ядра общего каталога и анализа готового Laravel-модуля | Функция встроена в карточку общего каталога, но нужно получить модуль и форматы выгрузки. |
+| 6. Склад наличие: ядро | Готов к реализации | Ядро общего каталога доступно как источник позиций; PDF-экспорт остается отдельным открытым подпунктом. |
+| 7. Техническое описание | Готово к анализу Laravel-модуля | Место в карточке общего каталога подготовлено, но нужно получить модуль и форматы выгрузки. |
 | 8. Калькуляции | Не готово к полной реализации | Нужен контракт внешнего API. Можно сделать только место/кнопку в карточке общего каталога. |
 | 9. Графики заказов и доставок | Не готово к полной реализации | Нужны формат обмена с 1С, поля, статусы и правила доставок/отгрузок. |
 | 10. Рекламации из графика заказов | Готово только после графика заказов | Сценарий согласован, но точка входа зависит от карточки заказа в графике. |
@@ -136,23 +136,25 @@
 
 ## Этап 4. Каталог общий: ядро
 
-Статус: **готов к реализации ядра**.
+Статус: **реализован и проверен пользователем**.
+
+Пользовательская проверка пройдена для создания и редактирования позиций, импорта и экспорта.
 
 В этот этап не входят внешняя API-интеграция калькуляций и перенос экспорта техописаний. Для них нужны отдельные вводные.
 
-- [ ] Зафиксировать, что текущий `catalog.*` относится к каталогу ДКР.
-- [ ] Спроектировать маршруты с префиксом `common-catalog/...`, таблицы и права с техническим именем `common-catalog`.
-- [ ] Сверить текущую модель каталога ДКР с требованиями общего каталога как источник паттернов, без смешивания данных.
+- [x] Зафиксировать, что текущий `catalog.*` относится к каталогу ДКР.
+- [x] Спроектировать маршруты с префиксом `common-catalog/...`, таблицы и права с техническим именем `common-catalog`.
+- [x] Сверить текущую модель каталога ДКР с требованиями общего каталога как источник паттернов, без смешивания данных.
 - [x] Зафиксировать состав и порядок колонок общего каталога.
 - [x] Зафиксировать файл `docs/refactor/Шаблон Каталог ощий.xlsx` как источник колонок общего каталога.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
-- [ ] Спроектировать обязательность и валидацию утвержденных полей.
-- [ ] Использовать текущий файловый механизм для хранения фото и документов позиции.
-- [ ] Доработать импорт/экспорт позиций.
-- [ ] Доработать карточку позиции.
+- [x] Спроектировать обязательность и валидацию утвержденных полей.
+- [x] Использовать текущий файловый механизм для хранения фото и документов позиции.
+- [x] Доработать импорт/экспорт позиций.
+- [x] Доработать карточку позиции.
 - [x] Зафиксировать встраивание `Технич. описание` в карточку общего каталога.
-- [ ] Добавить в карточку место под будущую кнопку/блок `Калькуляции`.
-- [ ] Проверить связи с графиками, рекламациями и складом наличия.
+- [x] Добавить в карточку место под будущую кнопку/блок `Калькуляции`.
+- [x] Зафиксировать интеграционный ключ `common_catalog_items.id` для будущих связей с графиками, рекламациями и складом наличия; сами связи реализуются в этапах этих модулей.
 
 ## Этап 5. Документация
 
@@ -246,7 +248,7 @@
 - [ ] Проверить меню под пользователем с ограниченными правами.
 - [ ] Проверить, что существующие маршруты ДКР работают после перегруппировки меню.
 - [ ] Проверить, что текущий каталог доступен внутри ДКР.
-- [ ] Проверить, что `Каталог общий` открыт как отдельный новый раздел/заглушка.
+- [ ] Проверить, что `Каталог общий` открыт как отдельный рабочий раздел.
 - [ ] Проверить, что все новые пункты-заглушки открываются у авторизованных пользователей и не дают 404/403.
 - [ ] Проверить, что выбранный год не влияет на новые модули.
 - [ ] Зафиксировать результат проверки в этом плане.

+ 25 - 18
docs/refactor/tz-catalog-common.md

@@ -23,7 +23,13 @@
 
 ## 2. Статус
 
-Статус модуля: **нужно реализовать**.
+Статус модуля: **ядро реализовано и проверено пользователем**.
+
+Проверены создание и редактирование позиций, импорт и экспорт каталога.
+
+Реализация использует ключ модуля, маршрутов и прав `common-catalog`. Физические
+SQL-таблицы названы `common_catalog_items` и `common_catalog_item_documents` по
+принятому в Laravel соглашению snake_case.
 
 В текущей CRM уже есть раздел `Каталог` ДКР:
 
@@ -237,35 +243,36 @@
 
 ## 12. Этапы реализации
 
-- [ ] Проверить текущие маршруты `catalog.*`.
-- [ ] Зафиксировать, что текущие маршруты `catalog.*` относятся к каталогу ДКР.
-- [ ] Использовать префикс маршрутов `common-catalog/...` для `Каталог общий`.
-- [ ] Использовать техническое имя `common-catalog` для таблиц и прав общего каталога.
-- [ ] Добавить верхний пункт `Каталог общий`.
-- [ ] Добавить страницу-заглушку `Каталог общий`.
-- [ ] Спроектировать карточку позиции общего каталога.
-- [ ] Проверить загрузку фото позиции.
-- [ ] Спроектировать загрузку документов позиции через текущий файловый механизм.
+- [x] Проверить текущие маршруты `catalog.*`.
+- [x] Зафиксировать, что текущие маршруты `catalog.*` относятся к каталогу ДКР.
+- [x] Использовать префикс маршрутов `common-catalog/...` для `Каталог общий`.
+- [x] Использовать техническое имя `common-catalog` для модуля и прав, SQL-префикс `common_catalog` — для таблиц.
+- [x] Добавить верхний пункт `Каталог общий`.
+- [x] Заменить страницу-заглушку рабочим списком `Каталог общий`.
+- [x] Спроектировать и реализовать карточку позиции общего каталога.
+- [x] Проверить загрузку фото позиции.
+- [x] Реализовать загрузку документов позиции через текущий файловый механизм.
 - [x] Зафиксировать состав и порядок колонок общего каталога.
 - [x] Зафиксировать шаблон общего каталога как источник колонок.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
-- [ ] Спроектировать обязательность и валидацию утвержденных полей.
-- [ ] Реализовать утвержденные поля в списке и карточке позиции.
-- [ ] Подготовить расширение блока документов до набора файлов, если нужно.
+- [x] Спроектировать обязательность и валидацию утвержденных полей: обязательны артикул и наименование по калькулятору; числовые значения неотрицательны; остальные поля допускают постепенное заполнение.
+- [x] Реализовать утвержденные поля в списке и карточке позиции.
+- [x] Реализовать фоновый импорт/экспорт по утвержденным 19 колонкам с обновлением по артикулу и поддержкой встроенных изображений.
+- [x] Реализовать блок документов как набор файлов.
 - [x] Зафиксировать, что `Технич. описание` включается внутрь карточки общего каталога.
-- [ ] Добавить в карточку место под API-запрос `Калькуляции`.
-- [ ] Проверить связи с модулями, которым нужен общий каталог.
-- [ ] Проверить, что заглушка `Каталог общий` видна всем авторизованным пользователям.
+- [x] Добавить в карточку место под API-запрос `Калькуляции`.
+- [x] Зафиксировать `common_catalog_items.id` как внешний ключ для будущих связей модулей; добавление связей выполняется вместе с соответствующими модулями.
+- [x] Настроить `common-catalog.view` для основных авторизованных ролей и отдельные права на изменение, файлы, импорт и экспорт.
 
 ## 13. Критерии приемки
 
 - В верхнем меню есть пункт `Каталог общий`.
 - В меню `ДКР` остается пункт `Каталог`.
 - Текущие маршруты `catalog.index` и `catalog.show` сохранены как маршруты каталога ДКР.
-- Заглушка `Каталог общий` доступна всем авторизованным пользователям.
+- Рабочий раздел `Каталог общий` доступен основным авторизованным ролям через право `common-catalog.view`.
 - После реализации общий каталог не смешивает данные с каталогом ДКР без отдельной миграции/синхронизации.
 - Список, импорт и экспорт общего каталога используют утвержденный состав и порядок колонок из файла `docs/refactor/Шаблон Каталог ощий.xlsx`.
-- Карточка позиции общего каталога поддерживает фото, документы, техописание/выгрузку и калькуляцию по артикулу.
+- Карточка позиции общего каталога поддерживает фото и набор документов; места интеграции техописания и калькуляции подготовлены до получения внешних вводных.
 - ДКР продолжает использовать свой каталог без поломки существующих связей.
 - Реорганизация меню выполнена как часть общей структуры Manager 2.0.
 

+ 165 - 0
resources/views/common_catalog/edit.blade.php

@@ -0,0 +1,165 @@
+@extends('layouts.app')
+
+@section('content')
+    <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 : 'Новая позиция общего каталога' }}
+                </h3>
+            </div>
+            @if($item)
+                <div class="col-md-5 d-flex flex-wrap align-items-center justify-content-end gap-2 action-toolbar">
+                    @if(hasPermission('common-catalog.image.upload'))
+                        <button type="button" class="btn btn-sm btn-outline-success"
+                                onclick="document.getElementById('common-catalog-image-input').click()">
+                            <i class="bi bi-image"></i> Загрузить фото
+                        </button>
+                        <form action="{{ route('common-catalog.image.upload', ['commonCatalogItem' => $item, 'nav' => $nav ?? null]) }}"
+                              method="POST" enctype="multipart/form-data" class="visually-hidden">
+                            @csrf
+                            <input id="common-catalog-image-input" type="file" name="image"
+                                   accept=".jpg,.jpeg,.png,.webp" required onchange="this.form.submit()">
+                        </form>
+                    @endif
+                    <button type="button" class="btn btn-sm btn-outline-secondary" disabled
+                            title="Будет подключено после получения контракта API">
+                        <i class="bi bi-calculator"></i> Калькуляция
+                    </button>
+                </div>
+            @endif
+        </div>
+
+        @if($item?->imageFile)
+            <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">
+                        <img src="{{ $item->imageFile->thumbnail_link }}" alt="{{ $item->article }}"
+                             class="img-thumbnail" style="max-width: 180px; max-height: 180px">
+                    </a>
+                    @if(hasPermission('common-catalog.image.delete'))
+                        <form action="{{ route('common-catalog.image.delete', ['commonCatalogItem' => $item, 'nav' => $nav ?? null]) }}"
+                              method="POST" onsubmit="return confirm('Удалить изображение?')">
+                            @csrf
+                            @method('DELETE')
+                            <button type="submit" class="btn btn-sm btn-outline-danger">Удалить фото</button>
+                        </form>
+                    @endif
+                </div>
+            </div>
+        @endif
+
+        <form action="{{ $item ? route('common-catalog.update', $item) : route('common-catalog.store') }}" method="POST">
+            @csrf
+            @if($item) @method('PUT') @endif
+            <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+
+            <div class="row">
+                <div class="col-xl-6">
+                    @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $item?->article])
+
+                    <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
+                        </div>
+                    </div>
+
+                    @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])
+                </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])
+
+                    @foreach([
+                        'additional_info' => 'Дополнительные сведения',
+                        'composition' => 'Состав',
+                        'note' => 'Примечание',
+                    ] as $field => $label)
+                        <div class="row mb-2">
+                            <label for="{{ $field }}" class="col-form-label small col-md-4 text-md-end">{{ $label }}</label>
+                            <div class="col-md-8">
+                                <textarea name="{{ $field }}" id="{{ $field }}" rows="4"
+                                          class="form-control form-control-sm @error($field) is-invalid @enderror">{{ old($field, $item?->{$field}) }}</textarea>
+                                @error($field)
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
+                        </div>
+                    @endforeach
+                </div>
+            </div>
+
+            <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>
+                @endif
+                <a href="{{ $back_url ?? route('common-catalog.index') }}" class="btn btn-sm btn-outline-secondary">Назад</a>
+            </div>
+        </form>
+
+        @if($item)
+            <div class="card mt-4">
+                <div class="card-header d-flex justify-content-between align-items-center">
+                    <strong>Документы</strong>
+                    @if(hasPermission('common-catalog.documents.upload'))
+                        <form action="{{ route('common-catalog.documents.upload', ['commonCatalogItem' => $item, 'nav' => $nav ?? null]) }}"
+                              method="POST" enctype="multipart/form-data" class="d-flex gap-2">
+                            @csrf
+                            <input type="file" name="document" class="form-control form-control-sm" required>
+                            <button type="submit" class="btn btn-sm btn-outline-primary">Загрузить</button>
+                        </form>
+                    @endif
+                </div>
+                <div class="card-body">
+                    @forelse($item->documents as $document)
+                        <div class="d-flex justify-content-between align-items-center border-bottom py-2">
+                            <a href="{{ $document->link }}" target="_blank" rel="noopener">{{ $document->original_name }}</a>
+                            @if(hasPermission('common-catalog.documents.delete'))
+                                <form action="{{ route('common-catalog.documents.delete', ['commonCatalogItem' => $item, 'file' => $document, 'nav' => $nav ?? null]) }}"
+                                      method="POST" onsubmit="return confirm('Удалить документ?')">
+                                    @csrf
+                                    @method('DELETE')
+                                    <button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
+                                </form>
+                            @endif
+                        </div>
+                    @empty
+                        <span class="text-muted">Документы не загружены.</span>
+                    @endforelse
+                </div>
+            </div>
+
+            <div class="card mt-4">
+                <div class="card-header"><strong>Техническое описание</strong></div>
+                <div class="card-body text-muted">
+                    Блок подготовлен к подключению данных и выгрузок из готового Laravel-модуля технических описаний.
+                </div>
+            </div>
+
+            @if(hasPermission('common-catalog.delete'))
+                <form action="{{ route('common-catalog.destroy', $item) }}" method="POST" class="mt-4"
+                      onsubmit="return confirm('Удалить позицию общего каталога?')">
+                    @csrf
+                    @method('DELETE')
+                    <button type="submit" class="btn btn-sm btn-danger">Удалить позицию</button>
+                </form>
+            @endif
+        @endif
+    </div>
+@endsection

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

@@ -0,0 +1,74 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2 page-header-row">
+        <div class="col-12 col-md-6 page-header-title">
+            <h3>Каталог общий</h3>
+        </div>
+        <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if(hasPermission('common-catalog.import'))
+                <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn"
+                        data-bs-toggle="modal" data-bs-target="#commonCatalogImportModal">
+                    <i class="bi bi-upload page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Импорт</span>
+                </button>
+            @endif
+            @if(hasPermission('common-catalog.export'))
+                <form action="{{ route('common-catalog.export') }}" method="POST" class="d-inline">
+                    @csrf
+                    <button type="submit" class="btn btn-sm mb-1 btn-primary page-action-btn">
+                        <i class="bi bi-download page-action-btn__icon"></i>
+                        <span class="page-action-btn__label">Экспорт</span>
+                    </button>
+                </form>
+            @endif
+            @if(hasPermission('common-catalog.create'))
+                <a href="{{ route('common-catalog.create', ['nav' => $nav ?? null]) }}"
+                   class="btn btn-sm mb-1 btn-primary page-action-btn">
+                    <i class="bi bi-plus-lg page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Добавить</span>
+                </a>
+            @endif
+        </div>
+    </div>
+
+    @include('partials.table', [
+        'id' => $id,
+        'header' => $header,
+        'strings' => $items,
+        'routeName' => 'common-catalog.show',
+        'routeParam' => 'commonCatalogItem',
+        'nav' => $nav,
+    ])
+
+    @include('partials.pagination', ['items' => $items])
+
+    @if(hasPermission('common-catalog.import'))
+        <div class="modal fade" id="commonCatalogImportModal" tabindex="-1"
+             aria-labelledby="commonCatalogImportModalLabel" aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="commonCatalogImportModalLabel">Импорт общего каталога</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <div class="modal-body">
+                        <p class="small text-muted">
+                            Используйте утверждённый XLSX-шаблон. Существующие позиции обновляются по артикулу.
+                        </p>
+                        <form action="{{ route('common-catalog.import') }}" method="POST" enctype="multipart/form-data">
+                            @csrf
+                            @include('partials.input', [
+                                'title' => 'XLSX файл',
+                                'name' => 'import_file',
+                                'type' => 'file',
+                                'required' => true,
+                            ])
+                            @include('partials.submit', ['name' => 'Импорт'])
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endif
+@endsection

+ 5 - 3
resources/views/layouts/menu.blade.php

@@ -113,9 +113,11 @@
         </ul>
     </li>
 
-    <li class="nav-item">
-        <a class="nav-link @if(($active ?? '') === 'common_catalog') active @endif" href="{{ route('common-catalog.index') }}">Каталог общий</a>
-    </li>
+    @if(hasPermission('common-catalog.view'))
+        <li class="nav-item">
+            <a class="nav-link @if(($active ?? '') === 'common_catalog') active @endif" href="{{ route('common-catalog.index') }}">Каталог общий</a>
+        </li>
+    @endif
 
     <li class="nav-item">
         <a class="nav-link @if(($active ?? '') === 'documents') active @endif" href="{{ route('documents.index') }}">Документация</a>

+ 15 - 4
routes/web.php

@@ -8,6 +8,7 @@ use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
+use App\Http\Controllers\CommonCatalogController;
 use App\Http\Controllers\ContractorController;
 use App\Http\Controllers\YearDataController;
 use App\Http\Controllers\ContractController;
@@ -152,10 +153,20 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
     Route::get('get-filters', [FilterController::class, 'getFilters'])->name('getFilters');
 
-    Route::get('common-catalog', UnderDevelopmentController::class)
-        ->defaults('title', 'Каталог общий')
-        ->defaults('active', 'common_catalog')
-        ->name('common-catalog.index');
+    Route::prefix('common-catalog')->name('common-catalog.')->group(function () {
+        Route::get('', [CommonCatalogController::class, 'index'])->name('index');
+        Route::get('create', [CommonCatalogController::class, 'create'])->name('create');
+        Route::post('', [CommonCatalogController::class, 'store'])->name('store');
+        Route::post('import', [CommonCatalogController::class, 'import'])->name('import');
+        Route::post('export', [CommonCatalogController::class, 'export'])->name('export');
+        Route::get('{commonCatalogItem}', [CommonCatalogController::class, 'show'])->name('show');
+        Route::put('{commonCatalogItem}', [CommonCatalogController::class, 'update'])->name('update');
+        Route::delete('{commonCatalogItem}', [CommonCatalogController::class, 'destroy'])->name('destroy');
+        Route::post('{commonCatalogItem}/image', [CommonCatalogController::class, 'uploadImage'])->name('image.upload');
+        Route::delete('{commonCatalogItem}/image', [CommonCatalogController::class, 'deleteImage'])->name('image.delete');
+        Route::post('{commonCatalogItem}/documents', [CommonCatalogController::class, 'uploadDocument'])->name('documents.upload');
+        Route::delete('{commonCatalogItem}/documents/{file}', [CommonCatalogController::class, 'deleteDocument'])->name('documents.delete');
+    });
 
     Route::prefix('schedules')->name('schedule.')->group(function () {
         Route::get('orders', UnderDevelopmentController::class)

+ 208 - 0
tests/Feature/CommonCatalogControllerTest.php

@@ -0,0 +1,208 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Jobs\Export\ExportCommonCatalogJob;
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class CommonCatalogControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->admin = User::factory()->create(['role' => Role::ADMIN]);
+        $this->manager = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    public function test_guest_cannot_access_common_catalog(): void
+    {
+        $this->get(route('common-catalog.index'))->assertRedirect(route('login'));
+    }
+
+    public function test_standard_authenticated_roles_can_view_year_independent_catalog(): void
+    {
+        $item = CommonCatalogItem::factory()->create([
+            'article' => '0254',
+            'calculator_name' => 'Оборудование для благоустройства',
+        ]);
+
+        $this->actingAs($this->manager)
+            ->withSession(['year' => 2021])
+            ->get(route('common-catalog.index'))
+            ->assertOk()
+            ->assertViewIs('common_catalog.index')
+            ->assertSee($item->article)
+            ->assertSee($item->calculator_name);
+    }
+
+    public function test_manager_cannot_mutate_common_catalog(): void
+    {
+        $this->actingAs($this->manager)
+            ->post(route('common-catalog.store'), $this->validData())
+            ->assertForbidden();
+    }
+
+    public function test_admin_can_create_update_and_delete_item(): void
+    {
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), $this->validData())
+            ->assertRedirect();
+
+        $item = CommonCatalogItem::query()->where('article', '0007')->firstOrFail();
+        $updated = $this->validData();
+        $updated['calculator_name'] = 'Обновлённое наименование';
+
+        $this->actingAs($this->admin)
+            ->put(route('common-catalog.update', $item), $updated)
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('common_catalog_items', [
+            'id' => $item->id,
+            'article' => '0007',
+            'calculator_name' => 'Обновлённое наименование',
+        ]);
+
+        $this->actingAs($this->admin)
+            ->delete(route('common-catalog.destroy', $item))
+            ->assertRedirect(route('common-catalog.index'));
+
+        $this->assertSoftDeleted('common_catalog_items', ['id' => $item->id]);
+    }
+
+    public function test_article_and_calculator_name_are_required_and_article_is_unique(): void
+    {
+        CommonCatalogItem::factory()->create(['article' => '0007']);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), [
+                'article' => '0007',
+                'calculator_name' => '',
+            ])
+            ->assertSessionHasErrors(['article', 'calculator_name']);
+    }
+
+    public function test_item_card_contains_technical_description_and_calculation_placeholders(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertSeeText('Техническое описание')
+            ->assertSeeText('Калькуляция');
+    }
+
+    public function test_admin_can_upload_and_delete_image(): void
+    {
+        Storage::fake('public');
+        $item = CommonCatalogItem::factory()->create();
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.image.upload', $item), [
+                'image' => UploadedFile::fake()->image('item.png', 200, 200),
+            ])
+            ->assertRedirect();
+
+        $item->refresh();
+        $this->assertNotNull($item->imageFile);
+        Storage::disk('public')->assertExists($item->imageFile->path);
+
+        $fileId = $item->imageFile->id;
+        $this->actingAs($this->admin)
+            ->delete(route('common-catalog.image.delete', $item))
+            ->assertRedirect();
+
+        $this->assertDatabaseMissing('files', ['id' => $fileId]);
+        $this->assertNull($item->refresh()->image_file_id);
+    }
+
+    public function test_admin_can_upload_and_delete_multiple_documents(): void
+    {
+        Storage::fake('public');
+        $item = CommonCatalogItem::factory()->create();
+
+        foreach (['certificate.pdf', 'manual.pdf'] as $filename) {
+            $this->actingAs($this->admin)
+                ->post(route('common-catalog.documents.upload', $item), [
+                    'document' => UploadedFile::fake()->create($filename, 20, 'application/pdf'),
+                ])
+                ->assertRedirect();
+        }
+
+        $this->assertCount(2, $item->refresh()->documents);
+        $document = $item->documents->first();
+
+        $this->actingAs($this->admin)
+            ->delete(route('common-catalog.documents.delete', [$item, $document]))
+            ->assertRedirect();
+
+        $this->assertCount(1, $item->refresh()->documents);
+        $this->assertDatabaseMissing('files', ['id' => $document->id]);
+    }
+
+    public function test_import_and_export_are_queued(): void
+    {
+        Storage::fake('upload');
+        Bus::fake([ImportJob::class, ExportCommonCatalogJob::class]);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.import'), [
+                'import_file' => UploadedFile::fake()->create(
+                    'common-catalog.xlsx',
+                    20,
+                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ),
+            ])
+            ->assertRedirect(route('common-catalog.index'));
+
+        $this->assertDatabaseHas('imports', [
+            'type' => 'common_catalog',
+            'year' => null,
+            'user_id' => $this->admin->id,
+        ]);
+        Bus::assertDispatched(ImportJob::class);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.export'))
+            ->assertRedirect(route('common-catalog.index'));
+        Bus::assertDispatched(ExportCommonCatalogJob::class);
+    }
+
+    private function validData(): array
+    {
+        return [
+            'article' => '0007',
+            'calculator_name' => "0007\nИгровой комплекс",
+            'kind' => 'Игровое оборудование',
+            'dimensions' => '2 × 3 × 1,5 м',
+            'fall_height' => 1.2,
+            'dimension_unit' => 'м',
+            'weight' => 350.5,
+            'volume' => 4.25,
+            'places' => 3,
+            'age_group' => '3+',
+            'max_users' => 8,
+            'unit' => 'шт.',
+            'series' => 'Двор',
+            'trademark' => 'Наш Двор',
+        ];
+    }
+}

+ 1 - 1
tests/Feature/ManagerMenuTest.php

@@ -57,7 +57,7 @@ class ManagerMenuTest extends TestCase
     {
         $user = $this->createUserWithPermissions('contractor_viewer', ['contractors.view']);
 
-        $response = $this->actingAs($user)->get(route('common-catalog.index'));
+        $response = $this->actingAs($user)->get(route('documents.index'));
 
         $response->assertOk()
             ->assertSeeText('Администратор')

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

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Export;
+
+use App\Models\CommonCatalogItem;
+use App\Models\File;
+use App\Models\User;
+use App\Services\Export\ExportCommonCatalogService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportCommonCatalogServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_export_uses_approved_columns_and_preserves_article_as_string(): void
+    {
+        Storage::fake('public');
+        $user = User::factory()->create();
+        $uploadedImage = UploadedFile::fake()->image('0254.png', 100, 100);
+        $imagePath = 'common_catalog/items/1/image/0254.png';
+        Storage::disk('public')->put($imagePath, $uploadedImage->getContent());
+        $image = File::query()->create([
+            'user_id' => $user->id,
+            'original_name' => '0254.png',
+            'mime_type' => 'image/png',
+            'path' => $imagePath,
+            'link' => '/storage/'.$imagePath,
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => '0254',
+            'calculator_name' => 'Оборудование для благоустройства',
+            'image_file_id' => $image->id,
+        ]);
+
+        (new ExportCommonCatalogService)->handle($user->id);
+
+        $file = File::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
+        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->assertCount(1, $sheet->getDrawingCollection());
+    }
+}

+ 125 - 0
tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php

@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\Import;
+use App\Models\User;
+use App\Services\Export\ExportCommonCatalogService;
+use App\Services\Import\ImportCommonCatalogService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use RuntimeException;
+use Tests\TestCase;
+
+class ImportCommonCatalogServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_import_creates_and_updates_by_article_while_preserving_leading_zeroes(): void
+    {
+        Storage::fake('upload');
+        Storage::fake('public');
+        $user = User::factory()->create();
+        $import = $this->createImport($user, 'Оборудование для благоустройства', withImage: true);
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Оборудование для благоустройства',
+            'places' => 38,
+        ]);
+        $item = \App\Models\CommonCatalogItem::query()->firstOrFail();
+        $this->assertNotNull($item->imageFile);
+        Storage::disk('public')->assertExists($item->imageFile->path);
+
+        $secondImport = $this->createImport($user, 'Обновлённое наименование');
+        $this->assertTrue((new ImportCommonCatalogService($secondImport, $user->id))->handle());
+
+        $this->assertDatabaseCount('common_catalog_items', 1);
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Обновлённое наименование',
+        ]);
+    }
+
+    public function test_import_accepts_template_header_with_line_break(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create();
+        $import = $this->createImport($user, 'Тест', "Единица измерения\nгабаритов");
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+        $this->assertSame('DONE', $import->refresh()->status);
+    }
+
+    public function test_import_rejects_wrong_headers_with_clear_error(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create();
+        $import = $this->createImport($user, 'Тест', 'Неверный заголовок');
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('Некорректные заголовки');
+
+        try {
+            (new ImportCommonCatalogService($import, $user->id))->handle();
+        } finally {
+            $this->assertSame('ERROR', $import->refresh()->status);
+        }
+    }
+
+    private function createImport(
+        User $user,
+        string $calculatorName,
+        string $dimensionHeader = "Единица измерения\nгабаритов",
+        bool $withImage = false,
+    ): Import {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $headers = ExportCommonCatalogService::HEADERS;
+        $headers[8] = $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', 'Наш Двор');
+
+        if ($withImage) {
+            $image = UploadedFile::fake()->image('catalog-item.png', 100, 100);
+            $drawing = new Drawing;
+            $drawing->setPath($image->getPathname());
+            $drawing->setCoordinates('B3');
+            $drawing->setWorksheet($sheet);
+        }
+
+        $filename = 'common_catalog_'.uniqid().'.xlsx';
+        $path = Storage::disk('upload')->path($filename);
+        (new Xlsx($spreadsheet))->save($path);
+
+        return Import::query()->create([
+            'type' => 'common_catalog',
+            'filename' => $filename,
+            'status' => 'new',
+            'user_id' => $user->id,
+        ]);
+    }
+}