Product::query()->with('category')->latest('id')->paginate(20), ]); } public function create() { $categories = Category::query()->where('is_active', true)->orderBy('name')->get(); return view('admin.products.create', [ 'categories' => $categories, 'categorySpecsById' => $this->categorySpecDefinitionsById($categories), ]); } public function store(Request $request): RedirectResponse { $data = $this->validateProduct($request); unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']); $data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name']); $category = Category::query()->find($data['category_id']); $data['specs'] = $this->parseSpecs( (array) $request->input('specs', []), $request->input('specs_text'), $category?->slug ); $imagePath = $request->hasFile('image') ? $this->storeImage($request) : null; $galleryPaths = $request->hasFile('gallery_images') ? $this->storeGalleryImages($request) : []; [$imagePath, $galleryPaths] = $this->normalizeProductImageSet($imagePath, $galleryPaths); $data['image_path'] = $imagePath; $data['gallery_paths'] = $galleryPaths; if ($data['image_path'] === null) { unset($data['image_path']); } if ($data['gallery_paths'] === []) { unset($data['gallery_paths']); } Product::create($data); return redirect()->route('admin.products.index')->with('status', 'Товар создан.'); } public function edit(Product $product) { $categories = Category::query()->where('is_active', true)->orderBy('name')->get(); return view('admin.products.edit', [ 'product' => $product, 'categories' => $categories, 'categorySpecsById' => $this->categorySpecDefinitionsById($categories), ]); } public function update(Request $request, Product $product): RedirectResponse { $data = $this->validateProduct($request, $product); unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']); $data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name'], $product->id); $category = Category::query()->find($data['category_id']); $data['specs'] = $this->parseSpecs( (array) $request->input('specs', []), $request->input('specs_text'), $category?->slug ); $oldImagePath = $product->image_path; $oldGalleryPaths = $this->normalizeImagePaths((array) ($product->gallery_paths ?? [])); $removeImage = $request->boolean('remove_image'); $removeGalleryPaths = $this->normalizeImagePaths((array) $request->input('remove_gallery_paths', [])); $nextImagePath = $oldImagePath; $nextGalleryPaths = $oldGalleryPaths; if ($request->hasFile('image')) { $nextImagePath = $this->storeImage($request); } elseif ($removeImage) { $nextImagePath = null; if ($oldImagePath) { $removeGalleryPaths[] = $oldImagePath; } } if ($request->hasFile('gallery_images')) { $nextGalleryPaths = $this->storeGalleryImages($request); } elseif ($removeGalleryPaths !== []) { $removablePaths = array_values(array_intersect($oldGalleryPaths, $removeGalleryPaths)); $nextGalleryPaths = array_values(array_filter( $oldGalleryPaths, fn (string $path) => !in_array($path, $removablePaths, true) )); } [$nextImagePath, $nextGalleryPaths] = $this->normalizeProductImageSet($nextImagePath, $nextGalleryPaths); $data['image_path'] = $nextImagePath; $data['gallery_paths'] = $nextGalleryPaths; $product->update($data); $activePaths = $this->normalizeImagePaths( array_merge( [$product->image_path], (array) ($product->gallery_paths ?? []) ) ); if ($oldImagePath && !in_array($oldImagePath, $activePaths, true)) { $this->deleteImage($oldImagePath); } foreach ($oldGalleryPaths as $oldPath) { if (!in_array($oldPath, $activePaths, true)) { $this->deleteImage($oldPath); } } return redirect()->route('admin.products.index')->with('status', 'Товар обновлен.'); } public function destroy(Product $product): RedirectResponse { $this->deleteImages(array_merge([$product->image_path], (array) ($product->gallery_paths ?? []))); $product->delete(); return redirect()->route('admin.products.index')->with('status', 'Товар удален.'); } public function exportCsv(): StreamedResponse { $specKeys = $this->allSpecKeys(); $headers = array_merge( [ 'id', 'category_slug', 'category_name', 'name', 'slug', 'sku', 'price', 'old_price', 'stock', 'short_description', 'description', 'is_active', ], $specKeys, ['specs_json'] ); return response()->streamDownload(function () use ($headers, $specKeys) { $output = fopen('php://output', 'wb'); if (!$output) { return; } fwrite($output, "\xEF\xBB\xBF"); fputcsv($output, $headers, ';'); Product::query() ->with('category') ->orderBy('id') ->chunk(200, function (EloquentCollection $products) use ($output, $specKeys) { foreach ($products as $product) { $specs = (array) ($product->specs ?? []); $row = [ $product->id, $product->category?->slug ?? '', $product->category?->name ?? '', $product->name, $product->slug, $product->sku, $product->price, $product->old_price, $product->stock, $product->short_description, $product->description, $product->is_active ? 1 : 0, ]; foreach ($specKeys as $key) { $row[] = $specs[$key] ?? ''; } $row[] = $specs === [] ? '' : json_encode($specs, JSON_UNESCAPED_UNICODE); fputcsv($output, $row, ';'); } }); fclose($output); }, 'products-' . now()->format('Ymd-His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function importCsv(Request $request): RedirectResponse { $request->validate([ 'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'], ]); $path = $request->file('csv_file')?->getRealPath(); if (!$path) { return back()->withErrors(['csv_file' => 'Не удалось прочитать файл.']); } [$handle, $delimiter] = $this->openCsv($path); if (!$handle) { return back()->withErrors(['csv_file' => 'Не удалось открыть CSV файл.']); } $headerRow = fgetcsv($handle, 0, $delimiter); if ($headerRow === false) { fclose($handle); return back()->withErrors(['csv_file' => 'CSV файл пустой.']); } $headers = array_map(fn ($header) => $this->normalizeCsvHeader((string) $header), $headerRow); if (!in_array('name', $headers, true) || !in_array('price', $headers, true)) { fclose($handle); return back()->withErrors(['csv_file' => 'В CSV должны быть колонки name и price.']); } if (!in_array('category_slug', $headers, true) && !in_array('category_name', $headers, true)) { fclose($handle); return back()->withErrors(['csv_file' => 'В CSV должна быть category_slug или category_name.']); } $categories = Category::query()->get(); $categoriesBySlug = $categories->keyBy(fn (Category $category) => mb_strtolower($category->slug)); $categoriesByName = $categories->keyBy(fn (Category $category) => mb_strtolower($category->name)); $allSpecKeys = $this->allSpecKeys(); $created = 0; $updated = 0; $skipped = 0; while (($row = fgetcsv($handle, 0, $delimiter)) !== false) { if ($this->rowIsEmpty($row)) { continue; } $rowData = $this->mapCsvRow($headers, $row); $category = $this->resolveCategory($rowData, $categoriesBySlug, $categoriesByName); $name = trim((string) ($rowData['name'] ?? '')); $price = $this->parseDecimal($rowData['price'] ?? null); $stock = $this->parseInteger($rowData['stock'] ?? 0); if (!$category || $name === '' || $price === null || $stock === null) { $skipped++; continue; } $sku = $this->trimToNull((string) ($rowData['sku'] ?? '')); $slugInput = trim((string) ($rowData['slug'] ?? '')); $existing = null; if ($slugInput !== '') { $existing = Product::query()->where('slug', Str::slug($slugInput))->first(); } if (!$existing && $sku !== null) { $existing = Product::query()->where('sku', $sku)->first(); } $slug = $this->makeUniqueSlug($slugInput !== '' ? $slugInput : $name, $existing?->id); $specs = $this->extractSpecsFromCsvRow($rowData, $allSpecKeys, $category->slug); $payload = [ 'category_id' => $category->id, 'name' => $name, 'slug' => $slug, 'sku' => $this->trimToNull((string) ($rowData['sku'] ?? '')), 'price' => $price, 'old_price' => $this->parseDecimal($rowData['old_price'] ?? null), 'stock' => $stock, 'short_description' => $this->trimToNull((string) ($rowData['short_description'] ?? '')), 'description' => $this->trimToNull((string) ($rowData['description'] ?? '')), 'is_active' => $this->parseBooleanValue($rowData['is_active'] ?? null, true), 'specs' => $specs, ]; if ($existing) { $existing->update($payload); $updated++; } else { Product::create($payload); $created++; } } fclose($handle); return redirect() ->route('admin.products.index') ->with('status', "Импорт завершен: создано {$created}, обновлено {$updated}, пропущено {$skipped}."); } private function validateProduct(Request $request, ?Product $product = null): array { return $request->validate([ 'category_id' => ['required', 'integer', 'exists:categories,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => [ 'nullable', 'string', 'max:255', Rule::unique('products', 'slug')->ignore($product?->id), ], 'sku' => ['nullable', 'string', 'max:255'], 'price' => ['required', 'numeric', 'min:0'], 'old_price' => ['nullable', 'numeric', 'min:0'], 'stock' => ['required', 'integer', 'min:0'], 'short_description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'gallery_images' => ['nullable', 'array', 'max:12'], 'gallery_images.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'remove_image' => ['nullable', 'boolean'], 'remove_gallery_paths' => ['nullable', 'array'], 'remove_gallery_paths.*' => ['string', 'max:255'], 'is_active' => ['nullable', 'boolean'], 'specs' => ['nullable', 'array'], 'specs.*' => ['nullable', 'string', 'max:255'], 'specs_text' => ['nullable', 'string'], ]) + ['is_active' => $request->boolean('is_active')]; } private function makeUniqueSlug(string $value, ?int $ignoreId = null): string { $base = Str::slug($value); if ($base === '') { $base = 'product'; } $slug = $base; $index = 2; while ( Product::query() ->where('slug', $slug) ->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId)) ->exists() ) { $slug = "{$base}-{$index}"; $index++; } return $slug; } private function parseSpecs(array $specsInput = [], ?string $specsText = null, ?string $categorySlug = null): array { $specs = []; foreach ($specsInput as $key => $value) { $normalizedKey = $this->normalizeSpecKey((string) $key); $normalizedValue = trim((string) $value); if ($normalizedKey === '' || $normalizedValue === '') { continue; } $specs[$normalizedKey] = $normalizedValue; } if ($specsText !== null && trim($specsText) !== '') { $specs = array_merge($this->parseSpecsFromText($specsText), $specs); } return $this->filterSpecsByCategory($specs, $categorySlug); } private function parseSpecsFromText(string $specsText): array { $rows = preg_split('/\r\n|\r|\n/', $specsText); $specs = []; foreach ($rows as $row) { $line = trim($row); if ($line === '' || !str_contains($line, ':')) { continue; } [$key, $value] = array_map('trim', explode(':', $line, 2)); $key = $this->normalizeSpecKey($key); if ($key === '' || $value === '') { continue; } $specs[$key] = $value; } return $specs; } private function normalizeSpecKey(string $key): string { return Str::of($key) ->lower() ->replace(' ', '_') ->replace('-', '_') ->trim() ->toString(); } private function categorySpecDefinitionsById(Collection $categories): array { $specDefinitions = $this->specDefinitions(); $savedValuesByCategory = $this->savedSpecValuesByCategory($categories); return $categories ->mapWithKeys(function (Category $category) use ($specDefinitions, $savedValuesByCategory) { $definitions = $specDefinitions[$category->slug] ?? []; $categoryValues = $savedValuesByCategory[$category->id] ?? []; $prepared = array_map(function (array $definition) use ($categoryValues) { $key = (string) ($definition['key'] ?? ''); if ($key === '') { return $definition; } $presetOptions = collect((array) ($definition['options'] ?? [])) ->map(fn ($value) => trim((string) $value)) ->filter() ->values(); $savedOptions = collect($categoryValues[$key] ?? []) ->map(fn ($value) => trim((string) $value)) ->filter() ->values(); $definition['options'] = $presetOptions ->concat($savedOptions) ->unique() ->values() ->all(); return $definition; }, $definitions); return [$category->id => $prepared]; }) ->all(); } private function specDefinitions(): array { return config('product_specs.categories', []); } private function allSpecKeys(): array { return collect($this->specDefinitions()) ->flatten(1) ->pluck('key') ->filter() ->unique() ->values() ->all(); } private function savedSpecValuesByCategory(Collection $categories): array { $categoryIds = $categories->pluck('id')->filter()->values(); if ($categoryIds->isEmpty()) { return []; } $values = []; Product::query() ->select(['id', 'category_id', 'specs']) ->whereIn('category_id', $categoryIds->all()) ->whereNotNull('specs') ->chunkById(200, function (EloquentCollection $products) use (&$values) { foreach ($products as $product) { $specs = (array) ($product->specs ?? []); if ($specs === []) { continue; } foreach ($specs as $key => $value) { if (!is_scalar($value)) { continue; } $normalizedKey = $this->normalizeSpecKey((string) $key); $normalizedValue = trim((string) $value); if ($normalizedKey === '' || $normalizedValue === '') { continue; } $values[$product->category_id][$normalizedKey][$normalizedValue] = true; } } }); foreach ($values as $categoryId => $specValues) { foreach ($specValues as $key => $valueSet) { $options = array_keys($valueSet); natcasesort($options); $values[$categoryId][$key] = array_values($options); } } return $values; } private function filterSpecsByCategory(array $specs, ?string $categorySlug): array { if (!$categorySlug) { return $specs; } $allowedKeys = collect($this->specDefinitions()[$categorySlug] ?? []) ->pluck('key') ->all(); if ($allowedKeys === []) { return $specs; } return collect($specs)->only($allowedKeys)->all(); } private function openCsv(string $path): array { $handle = fopen($path, 'rb'); if (!$handle) { return [null, ';']; } $sample = fgets($handle); rewind($handle); $delimiter = substr_count((string) $sample, ';') >= substr_count((string) $sample, ',') ? ';' : ','; return [$handle, $delimiter]; } private function normalizeCsvHeader(string $header): string { $value = str_replace("\xEF\xBB\xBF", '', $header); return Str::of($value) ->trim() ->lower() ->replace(' ', '_') ->replace('-', '_') ->toString(); } private function rowIsEmpty(array $row): bool { foreach ($row as $value) { if (trim((string) $value) !== '') { return false; } } return true; } private function mapCsvRow(array $headers, array $row): array { $row = array_pad($row, count($headers), ''); $mapped = []; foreach ($headers as $index => $header) { if ($header === '') { continue; } $mapped[$header] = trim((string) ($row[$index] ?? '')); } return $mapped; } private function resolveCategory(array $rowData, Collection $bySlug, Collection $byName): ?Category { $slug = mb_strtolower(trim((string) ($rowData['category_slug'] ?? ''))); if ($slug !== '' && $bySlug->has($slug)) { return $bySlug->get($slug); } $name = mb_strtolower(trim((string) ($rowData['category_name'] ?? ''))); if ($name !== '' && $byName->has($name)) { return $byName->get($name); } return null; } private function extractSpecsFromCsvRow(array $rowData, array $allSpecKeys, ?string $categorySlug): array { $specs = []; foreach ($allSpecKeys as $key) { $value = trim((string) ($rowData[$key] ?? '')); if ($value !== '') { $specs[$key] = $value; } } $specsJson = trim((string) ($rowData['specs_json'] ?? '')); if ($specsJson !== '') { $decoded = json_decode($specsJson, true); if (is_array($decoded)) { foreach ($decoded as $key => $value) { if (!is_scalar($value)) { continue; } $normalizedKey = $this->normalizeSpecKey((string) $key); $normalizedValue = trim((string) $value); if ($normalizedKey === '' || $normalizedValue === '') { continue; } $specs[$normalizedKey] = $normalizedValue; } } } return $this->filterSpecsByCategory($specs, $categorySlug); } private function parseDecimal(mixed $value): ?float { $string = trim((string) $value); if ($string === '') { return null; } $normalized = str_replace([' ', ','], ['', '.'], $string); if (!is_numeric($normalized)) { return null; } $decimal = (float) $normalized; return $decimal < 0 ? null : $decimal; } private function parseInteger(mixed $value): ?int { $string = trim((string) $value); if ($string === '') { return 0; } if (!preg_match('/^-?\d+$/', $string)) { return null; } $integer = (int) $string; return $integer < 0 ? null : $integer; } private function parseBooleanValue(mixed $value, bool $default = true): bool { $string = mb_strtolower(trim((string) $value)); if ($string === '') { return $default; } return match ($string) { '1', 'true', 'yes', 'y', 'да', 'active', 'on' => true, '0', 'false', 'no', 'n', 'нет', 'inactive', 'off' => false, default => $default, }; } private function storeImage(Request $request): string { $file = $request->file('image'); if (!$file instanceof UploadedFile) { return ''; } return $this->storeUploadedImage($file); } private function storeGalleryImages(Request $request): array { $files = $request->file('gallery_images', []); if (!is_array($files)) { return []; } $paths = []; foreach ($files as $file) { if (!$file instanceof UploadedFile) { continue; } $path = $this->storeUploadedImage($file); if ($path !== '') { $paths[] = $path; } } return $this->normalizeImagePaths($paths); } private function storeUploadedImage(UploadedFile $file): string { $extension = strtolower((string) $file->getClientOriginalExtension()); if ($extension === '') { $extension = 'jpg'; } $name = now()->format('YmdHis') . '-' . Str::uuid() . '.' . $extension; return (string) $file->storeAs('products', $name, 'public'); } private function deleteImage(?string $path): void { if (!$path) { return; } if (str_starts_with($path, 'uploads/')) { $filePath = public_path(ltrim($path, '/')); if (File::exists($filePath)) { File::delete($filePath); } return; } Storage::disk('public')->delete($path); } private function deleteImages(array $paths): void { foreach ($this->normalizeImagePaths($paths) as $path) { $this->deleteImage($path); } } private function normalizeImagePaths(array $paths): array { return collect($paths) ->filter(fn ($path) => is_string($path) && trim($path) !== '') ->map(fn (string $path) => trim($path)) ->unique() ->values() ->all(); } private function normalizeProductImageSet(?string $imagePath, array $galleryPaths): array { $normalizedImagePath = $this->trimToNull((string) $imagePath); $normalizedGalleryPaths = $this->normalizeImagePaths($galleryPaths); if ($normalizedImagePath === null && $normalizedGalleryPaths !== []) { $normalizedImagePath = array_shift($normalizedGalleryPaths); } if ($normalizedImagePath !== null) { $normalizedGalleryPaths = array_values(array_filter( $normalizedGalleryPaths, fn (string $path) => $path !== $normalizedImagePath )); } return [$normalizedImagePath, $normalizedGalleryPaths]; } private function trimToNull(string $value): ?string { $trimmed = trim($value); return $trimmed === '' ? null : $trimmed; } }