orderBy('created_at', 'desc') ->paginate(15); return view('admin.products.products', compact('products')); } public function create() { $categories = Category::getFlatTree(); $brands = Brand::active()->orderBy('name')->get(); return view('admin.products.create', compact('categories', 'brands')); } public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'category_id' => 'required|exists:categories,id', 'brand_id' => 'nullable|exists:brands,id', 'description' => 'nullable|string', 'is_active' => 'nullable|boolean', 'meta_title' => 'nullable|string|max:255', 'meta_description' => 'nullable|string', 'meta_keywords' => 'nullable|string', 'attributes' => 'nullable|array', 'attributes.*.key' => 'nullable|string', 'attributes.*.value' => 'nullable|string', 'variations' => 'required|array|min:1', 'variations.*.name' => 'required|string|max:255', 'variations.*.sku' => 'required|string|max:100|unique:product_variations,sku', 'variations.*.price' => 'required|numeric|min:0', 'variations.*.old_price' => 'nullable|numeric|min:0', 'variations.*.stock' => 'required|integer|min:0', 'variations.*.is_default' => 'nullable|boolean', 'variations.*.attributes' => 'nullable|array', 'variations.*.attributes.flavor' => 'nullable|string|max:100', 'variations.*.attributes.color' => 'nullable|string|max:100', 'variations.*.attributes.size' => 'nullable|string|max:50', 'variations.*.attributes.weight' => 'nullable|numeric|min:0', 'variations.*.images' => 'nullable|array', 'variations.*.images.*' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048', ], [ 'name.required' => 'Название товара обязательно', 'category_id.required' => 'Выберите категорию', 'variations.required' => 'Добавьте хотя бы одну вариацию', 'variations.*.name.required' => 'Название вариации обязательно', 'variations.*.sku.required' => 'Артикул обязателен', 'variations.*.sku.unique' => 'Артикул "{value}" уже существует', 'variations.*.price.required' => 'Цена обязательна', 'variations.*.stock.required' => 'Количество обязательно', 'variations.*.images.*.image' => 'Файл должен быть изображением', 'variations.*.images.*.mimes' => 'Допустимые форматы: jpg, jpeg, png, webp', 'variations.*.images.*.max' => 'Размер изображения не должен превышать 2 МБ', ]); DB::beginTransaction(); try { $slug = Str::slug($request->name); $baseSlug = $slug; $counter = 1; while (Product::where('slug', $slug)->exists()) { $slug = $baseSlug . '-' . $counter; $counter++; } $product = Product::create([ 'name' => $validated['name'], 'slug' => $slug, 'category_id' => $validated['category_id'], 'brand_id' => $validated['brand_id'] ?? null, 'description' => $validated['description'] ?? null, 'is_active' => $request->boolean('is_active'), 'meta_title' => $validated['meta_title'] ?? null, 'meta_description' => $validated['meta_description'] ?? null, 'meta_keywords' => $validated['meta_keywords'] ?? null, ]); // Сохраняем атрибуты товара if (!empty($validated['attributes'])) { foreach ($validated['attributes'] as $attr) { if (!empty($attr['key']) && !empty($attr['value'])) { ProductAttribute::create([ 'product_id' => $product->id, 'key' => $attr['key'], 'value' => $attr['value'], ]); } } } // Создаем вариации foreach ($validated['variations'] as $index => $variationData) { $variation = ProductVariation::create([ 'product_id' => $product->id, 'name' => $variationData['name'], 'sku' => $variationData['sku'], 'price' => $variationData['price'], 'old_price' => $variationData['old_price'] ?? null, 'stock' => $variationData['stock'], 'is_default' => $variationData['is_default'] ?? ($index == 0), 'is_active' => true, ]); // Сохраняем атрибуты вариации if (!empty($variationData['attributes'])) { foreach ($variationData['attributes'] as $key => $value) { if (!empty($value) || $value === 0 || $value === '0') { VariationAttribute::create([ 'variation_id' => $variation->id, 'key' => $key, 'value' => $value, ]); } } } // Сохраняем изображения if ($request->hasFile("variations.{$index}.images")) { $files = $request->file("variations.{$index}.images"); $validFiles = array_filter($files, function ($file) { return $file !== null && $file->isValid(); }); if (!empty($validFiles)) { $this->saveVariationImages($validFiles, $product->slug, $variation->id); } } } DB::commit(); return redirect()->route('admin.products.create') ->with('success', 'Товар "' . $product->name . '" успешно создан'); } catch (\Exception $e) { DB::rollBack(); Log::error('Product create error: ' . $e->getMessage()); return redirect()->back() ->withInput() ->with('error', 'Ошибка при создании товара: ' . $e->getMessage()); } } public function edit($id) { $product = Product::with(['variations.attributes', 'variations.images', 'attributes', 'brand', 'category'])->findOrFail($id); $categories = Category::getFlatTree(); $brands = Brand::active()->orderBy('name')->get(); return view('admin.products.edit', compact('product', 'categories', 'brands')); } public function update(Request $request, $id) { $product = Product::findOrFail($id); $validated = $request->validate([ 'name' => 'required|string|max:255', 'category_id' => 'required|exists:categories,id', 'brand_id' => 'nullable|exists:brands,id', 'description' => 'nullable|string', 'is_active' => 'nullable|boolean', 'meta_title' => 'nullable|string|max:255', 'meta_description' => 'nullable|string', 'meta_keywords' => 'nullable|string', 'attributes' => 'nullable|array', 'attributes.*.key' => 'nullable|string', 'attributes.*.value' => 'nullable|string', 'variations' => 'required|array|min:1', 'variations.*.id' => 'nullable|exists:product_variations,id', 'variations.*.name' => 'required|string|max:255', 'variations.*.sku' => 'required|string|max:100', 'variations.*.price' => 'required|numeric|min:0', 'variations.*.old_price' => 'nullable|numeric|min:0', 'variations.*.stock' => 'required|integer|min:0', 'variations.*.is_default' => 'nullable|boolean', 'variations.*.attributes' => 'nullable|array', 'variations.*.attributes.flavor' => 'nullable|string|max:100', 'variations.*.attributes.color' => 'nullable|string|max:100', 'variations.*.attributes.size' => 'nullable|string|max:50', 'variations.*.attributes.weight' => 'nullable|numeric|min:0', 'variations.*.images' => 'nullable|array', 'variations.*.images.*' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048', 'variations.*.removed_images' => 'nullable|array', 'variations.*.removed_images.*' => 'nullable|string', 'variations.*.existing_images' => 'nullable|array', 'variations.*.existing_images.*' => 'nullable|string', ], [ // Основные поля 'name.required' => 'Название товара обязательно', 'name.string' => 'Название товара должно быть строкой', 'name.max' => 'Название товара не может превышать 255 символов', 'category_id.required' => 'Выберите категорию', 'category_id.exists' => 'Выбранная категория не существует', 'brand_id.exists' => 'Выбранный бренд не существует', 'meta_title.max' => 'Meta Title не может превышать 255 символов', // Вариации 'variations.required' => 'Добавьте хотя бы одну вариацию', 'variations.array' => 'Некорректный формат вариаций', 'variations.min' => 'Добавьте хотя бы одну вариацию', 'variations.*.id.exists' => 'Вариация с ID :input не найдена', 'variations.*.name.required' => 'Название вариации обязательно', 'variations.*.name.string' => 'Название вариации должно быть строкой', 'variations.*.name.max' => 'Название вариации не может превышать 255 символов', 'variations.*.sku.required' => 'Артикул (SKU) обязателен', 'variations.*.sku.string' => 'Артикул должен быть строкой', 'variations.*.sku.max' => 'Артикул не может превышать 100 символов', 'variations.*.price.required' => 'Цена обязательна', 'variations.*.price.numeric' => 'Цена должна быть числом', 'variations.*.price.min' => 'Цена не может быть отрицательной', 'variations.*.old_price.numeric' => 'Старая цена должна быть числом', 'variations.*.old_price.min' => 'Старая цена не может быть отрицательной', 'variations.*.stock.required' => 'Количество обязательно', 'variations.*.stock.integer' => 'Количество должно быть целым числом', 'variations.*.stock.min' => 'Количество не может быть отрицательным', 'variations.*.is_default.boolean' => 'Поле "Основная вариация" должно быть true или false', 'variations.*.attributes.array' => 'Некорректный формат атрибутов вариации', 'variations.*.attributes.flavor.string' => 'Вкус должен быть строкой', 'variations.*.attributes.flavor.max' => 'Вкус не может превышать 100 символов', 'variations.*.attributes.color.string' => 'Цвет должен быть строкой', 'variations.*.attributes.color.max' => 'Цвет не может превышать 100 символов', 'variations.*.attributes.size.string' => 'Размер должен быть строкой', 'variations.*.attributes.size.max' => 'Размер не может превышать 50 символов', 'variations.*.attributes.weight.numeric' => 'Вес должен быть числом', 'variations.*.attributes.weight.min' => 'Вес не может быть отрицательным', // Изображения 'variations.*.images.array' => 'Некорректный формат изображений', 'variations.*.images.*.image' => 'Файл должен быть изображением', 'variations.*.images.*.mimes' => 'Допустимые форматы изображений: jpg, jpeg, png, webp', 'variations.*.images.*.max' => 'Размер изображения не должен превышать 2 МБ', 'variations.*.removed_images.array' => 'Некорректный формат списка удаляемых изображений', 'variations.*.removed_images.*.string' => 'Путь к удаляемому изображению должен быть строкой', 'variations.*.existing_images.array' => 'Некорректный формат списка существующих изображений', 'variations.*.existing_images.*.string' => 'Путь к существующему изображению должен быть строкой', // Атрибуты товара 'attributes.array' => 'Некорректный формат характеристик', 'attributes.*.key.string' => 'Название характеристики должно быть строкой', 'attributes.*.value.string' => 'Значение характеристики должно быть строкой', ]); DB::beginTransaction(); try { // Обновляем slug если изменилось название $newSlug = Str::slug($validated['name']); $oldSlug = $product->slug; if ($oldSlug != $newSlug) { $slug = $newSlug; $counter = 1; while (Product::where('slug', $slug)->where('id', '!=', $id)->exists()) { $slug = $newSlug . '-' . $counter; $counter++; } $product->slug = $slug; $this->renameProductFolder($oldSlug, $slug, $product); } // Обновляем товар $product->update([ 'name' => $validated['name'], 'description' => $validated['description'], 'brand_id' => $validated['brand_id'], 'category_id' => $validated['category_id'], 'is_active' => $request->boolean('is_active'), 'meta_title' => $validated['meta_title'], 'meta_description' => $validated['meta_description'], 'meta_keywords' => $validated['meta_keywords'], ]); // Обновляем атрибуты товара ProductAttribute::where('product_id', $product->id)->delete(); if (!empty($validated['attributes'])) { foreach ($validated['attributes'] as $attr) { if (!empty($attr['key']) && !empty($attr['value'])) { ProductAttribute::create([ 'product_id' => $product->id, 'key' => $attr['key'], 'value' => $attr['value'], ]); } } } $existingVariationIds = []; foreach ($validated['variations'] as $index => $variationData) { if (isset($variationData['id']) && $variationData['id']) { $variation = ProductVariation::find($variationData['id']); if ($variation && $variation->product_id == $product->id) { $variation->update([ 'name' => $variationData['name'], 'sku' => $variationData['sku'], 'price' => $variationData['price'], 'old_price' => $variationData['old_price'] ?? null, 'stock' => $variationData['stock'], 'is_default' => $variationData['is_default'] ?? false, ]); // Обновляем атрибуты вариации VariationAttribute::where('variation_id', $variation->id)->delete(); if (!empty($variationData['attributes'])) { foreach ($variationData['attributes'] as $key => $value) { if (!empty($value) || $value === 0 || $value === '0') { VariationAttribute::create([ 'variation_id' => $variation->id, 'key' => $key, 'value' => $value, ]); } } } // ========== ОБРАБОТКА ИЗОБРАЖЕНИЙ ========== // 1. Удаляем отмеченные изображения if (!empty($variationData['removed_images']) && is_array($variationData['removed_images'])) { foreach ($variationData['removed_images'] as $imagePath) { $image = VariationImage::where('variation_id', $variation->id) ->where('path', $imagePath) ->first(); if ($image) { $fullPath = public_path($imagePath); if (file_exists($fullPath) && is_file($fullPath)) { @unlink($fullPath); } $image->delete(); } } } // 2. Обновляем порядок существующих изображений if (!empty($variationData['existing_images']) && is_array($variationData['existing_images'])) { foreach ($variationData['existing_images'] as $sortOrder => $imagePath) { $image = VariationImage::where('variation_id', $variation->id) ->where('path', $imagePath) ->first(); if ($image) { $image->sort_order = $sortOrder; $image->save(); } } } // 3. Сохраняем новые изображения if ($request->hasFile("variations.{$index}.images")) { $files = $request->file("variations.{$index}.images"); $validFiles = array_filter($files, function ($file) { return $file !== null && $file->isValid(); }); if (!empty($validFiles)) { $this->saveVariationImages($validFiles, $product->slug, $variation->id); } } $existingVariationIds[] = $variation->id; } } else { // Создаем новую вариацию $variation = ProductVariation::create([ 'product_id' => $product->id, 'name' => $variationData['name'], 'sku' => $variationData['sku'], 'price' => $variationData['price'], 'old_price' => $variationData['old_price'] ?? null, 'stock' => $variationData['stock'], 'is_default' => $variationData['is_default'] ?? false, 'is_active' => true, ]); // Сохраняем атрибуты if (!empty($variationData['attributes'])) { foreach ($variationData['attributes'] as $key => $value) { if (!empty($value) || $value === 0 || $value === '0') { VariationAttribute::create([ 'variation_id' => $variation->id, 'key' => $key, 'value' => $value, ]); } } } // Сохраняем изображения if ($request->hasFile("variations.{$index}.images")) { $files = $request->file("variations.{$index}.images"); $validFiles = array_filter($files, function ($file) { return $file !== null && $file->isValid(); }); if (!empty($validFiles)) { $this->saveVariationImages($validFiles, $product->slug, $variation->id); } } $existingVariationIds[] = $variation->id; } } // Удаляем вариации, которых нет в форме $variationsToDelete = ProductVariation::where('product_id', $product->id) ->whereNotIn('id', $existingVariationIds) ->get(); foreach ($variationsToDelete as $variation) { // Удаляем изображения из БД и файловой системы $images = VariationImage::where('variation_id', $variation->id)->get(); foreach ($images as $image) { $fullPath = public_path($image->path); if (file_exists($fullPath) && is_file($fullPath)) { @unlink($fullPath); } $image->delete(); } // Удаляем атрибуты VariationAttribute::where('variation_id', $variation->id)->delete(); // Удаляем папку $folderPath = public_path('assets/images/products/' . $product->slug . '/variation_' . $variation->id); if (file_exists($folderPath) && is_dir($folderPath)) { $this->deleteDirectory($folderPath); } $variation->delete(); } DB::commit(); return redirect()->route('admin.products') ->with('success', 'Товар "' . $product->name . '" успешно обновлен'); } catch (\Exception $e) { DB::rollBack(); Log::error('Product update error: ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]); return redirect()->back() ->withInput() ->with('error', 'Ошибка при обновлении товара: ' . $e->getMessage()); } } private function saveVariationImages($images, $productSlug, $variationId) { $folder = public_path('assets/images/products/' . $productSlug . '/variation_' . $variationId); if (!file_exists($folder)) { mkdir($folder, 0755, true); } // Получаем текущий максимальный sort_order $maxSortOrder = VariationImage::where('variation_id', $variationId)->max('sort_order'); $nextIndex = ($maxSortOrder !== null) ? $maxSortOrder + 1 : 0; foreach ($images as $index => $image) { if ($image && $image->isValid()) { $extension = $image->getClientOriginalExtension(); $filename = ($nextIndex + $index) . '.' . $extension; $destinationPath = $folder . '/' . $filename; $image->move($folder, $filename); if (file_exists($destinationPath)) { VariationImage::create([ 'variation_id' => $variationId, 'path' => 'assets/images/products/' . $productSlug . '/variation_' . $variationId . '/' . $filename, 'sort_order' => $nextIndex + $index, ]); } } } } private function renameProductFolder($oldSlug, $newSlug, $product) { $oldFolder = public_path('assets/images/products/' . $oldSlug); $newFolder = public_path('assets/images/products/' . $newSlug); if (file_exists($oldFolder) && is_dir($oldFolder)) { if (file_exists($newFolder)) { $this->mergeDirectories($oldFolder, $newFolder); $this->deleteDirectory($oldFolder); } else { rename($oldFolder, $newFolder); } // Обновляем пути в БД $variations = ProductVariation::where('product_id', $product->id)->get(); foreach ($variations as $variation) { $images = VariationImage::where('variation_id', $variation->id)->get(); foreach ($images as $image) { $image->path = str_replace('assets/images/products/' . $oldSlug, 'assets/images/products/' . $newSlug, $image->path); $image->save(); } } } } private function mergeDirectories($source, $destination) { if (!is_dir($destination)) { mkdir($destination, 0777, true); } $files = scandir($source); foreach ($files as $file) { if ($file == '.' || $file == '..') continue; $sourcePath = $source . '/' . $file; $destPath = $destination . '/' . $file; if (is_dir($sourcePath)) { $this->mergeDirectories($sourcePath, $destPath); rmdir($sourcePath); } else { copy($sourcePath, $destPath); unlink($sourcePath); } } } private function deleteDirectory($dir) { if (!file_exists($dir)) { return true; } if (!is_dir($dir)) { return unlink($dir); } foreach (scandir($dir) as $item) { if ($item == '.' || $item == '..') { continue; } if (!$this->deleteDirectory($dir . DIRECTORY_SEPARATOR . $item)) { return false; } } return rmdir($dir); } public function checkSku(Request $request) { $request->validate([ 'sku' => 'required|string', 'variation_id' => 'nullable|exists:product_variations,id', 'product_id' => 'nullable|exists:products,id' ]); $sku = $request->sku; $variationId = $request->variation_id; $productId = $request->product_id; $isUnique = ProductVariation::isSkuUnique($sku, $variationId, $productId); return response()->json([ 'unique' => $isUnique, 'message' => $isUnique ? null : 'Артикул "' . $sku . '" уже используется' ]); } public function destroy($id) { $product = Product::findOrFail($id); $productName = $product->name; foreach ($product->variations as $variation) { $images = VariationImage::where('variation_id', $variation->id)->get(); foreach ($images as $image) { $fullPath = public_path($image->path); if (file_exists($fullPath) && is_file($fullPath)) { @unlink($fullPath); } } $folderPath = public_path('assets/images/products/' . $product->slug . '/variation_' . $variation->id); if (file_exists($folderPath) && is_dir($folderPath)) { $this->deleteDirectory($folderPath); } } $productFolder = public_path('assets/images/products/' . $product->slug); if (file_exists($productFolder) && is_dir($productFolder)) { $this->deleteDirectory($productFolder); } $product->delete(); return redirect()->route('admin.products') ->with('success', 'Товар "' . $productName . '" успешно удален'); } public function duplicate($id) { DB::beginTransaction(); try { $original = Product::with(['variations.attributes', 'variations.images'])->findOrFail($id); $newProduct = $original->replicate(); $newProduct->name = $original->name . ' (копия)'; $newProduct->slug = Str::slug($newProduct->name) . '-' . uniqid(); $newProduct->created_at = now(); $newProduct->updated_at = now(); $newProduct->save(); // Копируем атрибуты товара foreach ($original->attributes as $attr) { ProductAttribute::create([ 'product_id' => $newProduct->id, 'key' => $attr->key, 'value' => $attr->value, ]); } // Копируем вариации foreach ($original->variations as $variation) { $newVariation = $variation->replicate(); $newVariation->product_id = $newProduct->id; $newVariation->sku = $variation->sku . '-copy-' . uniqid(); $newVariation->created_at = now(); $newVariation->updated_at = now(); $newVariation->save(); // Копируем атрибуты вариации foreach ($variation->attributes as $attr) { VariationAttribute::create([ 'variation_id' => $newVariation->id, 'key' => $attr->key, 'value' => $attr->value, ]); } // Копируем изображения $newFolder = public_path('assets/images/products/' . $newProduct->slug . '/variation_' . $newVariation->id); if (!file_exists($newFolder)) { mkdir($newFolder, 0755, true); } foreach ($variation->images as $image) { $oldPath = public_path($image->path); $filename = basename($image->path); $newPath = $newFolder . '/' . $filename; if (file_exists($oldPath)) { copy($oldPath, $newPath); VariationImage::create([ 'variation_id' => $newVariation->id, 'path' => 'assets/images/products/' . $newProduct->slug . '/variation_' . $newVariation->id . '/' . $filename, 'sort_order' => $image->sort_order, ]); } } } DB::commit(); return redirect()->route('admin.products') ->with('success', 'Товар "' . $original->name . '" успешно скопирован. Новая версия: "' . $newProduct->name . '"'); } catch (\Exception $e) { DB::rollBack(); Log::error('Product duplicate error: ' . $e->getMessage()); return redirect()->back() ->with('error', 'Ошибка при копировании товара: ' . $e->getMessage()); } } }