'spare_parts', 'title' => 'Каталог запчастей', 'id' => 'spare_parts', 'header' => [ 'image' => 'Картинка', 'id' => 'ID', 'article' => 'Артикул', 'used_in_maf' => 'Где используется', 'quantity_without_docs' => 'Кол-во без док', 'quantity_with_docs' => 'Кол-во с док', 'total_quantity' => 'Кол-во общее', 'note' => 'Примечание', 'customer_price_txt' => 'Цена для заказчика', 'expertise_price_txt' => 'Цена экспертизы', 'tsn_number' => '№ по ТСН', 'pricing_codes_list' => 'Шифр расценки и коды ресурсов', 'min_stock' => 'Минимальный остаток', ], 'searchFields' => [ 'article', 'used_in_maf', 'note', 'tsn_number', ], 'routeName' => 'spare_parts.show', ]; public function index(Request $request) { session(['gp_spare_parts' => $request->query()]); $model = new SparePart(); // Для админа добавляем колонку цены закупки if (hasRole('admin')) { $this->data['header'] = array_merge( array_slice($this->data['header'], 0, 8, true), ['purchase_price_txt' => 'Цена закупки'], array_slice($this->data['header'], 8, null, true) ); } // Фильтры $this->createFilters($model, 'used_in_maf'); // Для range фильтров нужно использовать реальные поля БД (без _txt) // но заголовки брать из header с _txt $this->createRangeFiltersForPrices($model, 'customer_price', 'expertise_price', 'min_stock'); if (hasRole('admin')) { $this->createRangeFiltersForPrices($model, 'purchase_price'); } // Запрос $q = $model::query(); $this->acceptFilters($q, $request); $this->acceptSearch($q, $request); $this->setSortAndOrderBy($model, $request); $q->orderBy($this->data['sortBy'], $this->data['orderBy']); $this->data['spare_parts'] = $q->paginate(session('per_page', config('pagination.per_page')))->withQueryString(); $this->data['strings'] = $this->data['spare_parts']; $this->data['tab'] = 'catalog'; return view('spare_parts.index', $this->data); } public function show(Request $request, SparePart $sparePart) { $this->data['previous_url'] = $request->get('previous_url'); $this->data['spare_part'] = $sparePart; return view('spare_parts.edit', $this->data); } public function help() { $markdownPath = base_path('docs/spare-parts.md'); $markdown = File::exists($markdownPath) ? File::get($markdownPath) : '# Справка не найдена'; $this->data['helpContent'] = Str::markdown($markdown); $this->data['tab'] = 'help'; return view('spare_parts.index', $this->data); } public function create() { $this->data['spare_part'] = null; return view('spare_parts.edit', $this->data); } public function store(StoreSparePartRequest $request): RedirectResponse { $sparePart = SparePart::create($request->validated()); $this->syncPricingCodes($sparePart, $request); $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts')); return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно создана!']); } public function update(StoreSparePartRequest $request, SparePart $sparePart): RedirectResponse { $sparePart->update($request->validated()); $this->syncPricingCodes($sparePart, $request); $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts')); return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно обновлена!']); } protected function syncPricingCodes(SparePart $sparePart, StoreSparePartRequest $request): void { $codes = $request->input('pricing_codes', []); $descriptions = $request->input('pricing_codes_descriptions', []); $pricingCodeIds = []; foreach ($codes as $index => $code) { $code = trim($code); if (empty($code)) { continue; } $description = trim($descriptions[$index] ?? ''); // Находим или создаём PricingCode $pricingCode = \App\Models\PricingCode::where('type', \App\Models\PricingCode::TYPE_PRICING_CODE) ->where('code', $code) ->first(); if ($pricingCode) { // Обновляем расшифровку, если предоставлена if ($description && $description !== $pricingCode->description) { $pricingCode->update(['description' => $description]); } } else { // Создаём новый код $pricingCode = \App\Models\PricingCode::create([ 'type' => \App\Models\PricingCode::TYPE_PRICING_CODE, 'code' => $code, 'description' => $description ?: null, ]); } $pricingCodeIds[] = $pricingCode->id; } // Синхронизируем связи $sparePart->pricingCodes()->sync($pricingCodeIds); } public function destroy(SparePart $sparePart): RedirectResponse { // Проверка на наличие заказов if ($sparePart->orders()->count() > 0) { return redirect()->route('spare_parts.index', session('gp_spare_parts')) ->with(['error' => 'Невозможно удалить запчасть, т.к. для неё есть заказы!']); } $sparePart->delete(); return redirect()->route('spare_parts.index', session('gp_spare_parts')) ->with(['success' => 'Запчасть успешно удалена!']); } public function export(Request $request): RedirectResponse { // Запускаем Job для экспорта ExportSparePartsJob::dispatch(auth()->id()); Log::info('ExportSparePartsJob created!'); return redirect()->route('spare_parts.index', session('gp_spare_parts')) ->with(['success' => 'Задача экспорта успешно создана!']); } public function import(Request $request): RedirectResponse { $request->validate([ 'file' => 'required|mimes:xlsx,xls|max:10240', ]); try { // Сохраняем временный файл $file = $request->file('file'); $tempPath = $file->storeAs('temp/imports', 'spare_parts_import_' . time() . '.xlsx'); $fullPath = Storage::path($tempPath); // Создаём запись импорта $import = Import::create([ 'user_id' => auth()->id(), 'type' => 'spare_parts', 'status' => Import::STATUS_PENDING, 'file_path' => $tempPath, 'original_filename' => $file->getClientOriginalName(), ]); // Запускаем Job для импорта ImportSparePartsJob::dispatch($fullPath, auth()->id(), $import->id); Log::info('ImportSparePartsJob created!', ['import_id' => $import->id]); return redirect()->route('spare_parts.index', session('gp_spare_parts')) ->with(['success' => 'Задача импорта успешно создана! Следите за статусом в разделе импорта.']); } catch (\Exception $e) { Log::error('Ошибка создания задачи импорта: ' . $e->getMessage()); return redirect()->route('spare_parts.index', session('gp_spare_parts')) ->with(['error' => 'Ошибка импорта: ' . $e->getMessage()]); } } public function uploadImage(Request $request, SparePart $sparePart): RedirectResponse { $request->validate([ 'image' => 'required|image|mimes:jpeg,jpg,png|max:2048', ]); if ($request->hasFile('image')) { $image = $request->file('image'); $filename = $sparePart->article . '.jpg'; // Создаём директорию если её нет $directory = public_path('images/spare_parts'); if (!file_exists($directory)) { mkdir($directory, 0755, true); } // Сохраняем изображение $image->move($directory, $filename); return redirect()->route('spare_parts.show', $sparePart) ->with(['success' => 'Изображение успешно загружено!']); } return redirect()->route('spare_parts.show', $sparePart) ->with(['error' => 'Ошибка загрузки изображения!']); } /** * API метод для поиска запчастей (autocomplete) */ public function search(Request $request) { $query = $request->get('query', ''); $spareParts = SparePart::query() ->where(function ($q) use ($query) { $q->where('article', 'LIKE', '%' . $query . '%') ->orWhere('used_in_maf', 'LIKE', '%' . $query . '%'); }) ->orderBy('article') ->limit(20) ->get(['id', 'article', 'used_in_maf']); return response()->json($spareParts); } /** * Создание range фильтров для полей с ценами * Использует правильные заголовки из header (_txt версии) */ protected function createRangeFiltersForPrices(SparePart $model, string ...$columnNames): void { foreach ($columnNames as $columnName) { // Определяем ключ заголовка $headerKey = str_ends_with($columnName, '_price') ? $columnName . '_txt' : $columnName; // Проверяем, есть ли заголовок if (!isset($this->data['header'][$headerKey])) { continue; } if (str_ends_with($columnName, '_price')) { $min = $model::query()->min($columnName); $max = $model::query()->max($columnName); $this->data['ranges'][$columnName] = [ 'title' => $this->data['header'][$headerKey], 'min' => $min ? $min / 100 : 0, 'max' => $max ? $max / 100 : 0, ]; } else { $this->data['ranges'][$columnName] = [ 'title' => $this->data['header'][$headerKey], 'min' => $model::query()->min($columnName) ?? 0, 'max' => $model::query()->max($columnName) ?? 0, ]; } } } }