Files
tehnobox/app/Http/Controllers/Admin/ProductController.php
ssww23 93a655235a
Some checks failed
Deploy / deploy (push) Has been cancelled
Initial commit
2026-03-10 00:55:37 +03:00

817 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ProductController extends Controller
{
public function index()
{
return view('admin.products.index', [
'products' => 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;
}
}