SparePartController.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Requests\StoreSparePartRequest;
  4. use App\Jobs\Export\ExportSparePartsJob;
  5. use App\Jobs\Import\ImportSparePartsJob;
  6. use App\Models\Import;
  7. use App\Models\SparePart;
  8. use Illuminate\Http\RedirectResponse;
  9. use Illuminate\Http\Request;
  10. use Illuminate\Support\Facades\File;
  11. use Illuminate\Support\Facades\Log;
  12. use Illuminate\Support\Facades\Storage;
  13. use Illuminate\Support\Str;
  14. use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
  15. class SparePartController extends Controller
  16. {
  17. protected array $data = [
  18. 'active' => 'spare_parts',
  19. 'title' => 'Каталог запчастей',
  20. 'id' => 'spare_parts',
  21. 'header' => [
  22. 'image' => 'Картинка',
  23. 'id' => 'ID',
  24. 'article' => 'Артикул',
  25. 'used_in_maf' => 'Где используется',
  26. 'quantity_without_docs' => 'Кол-во без док',
  27. 'quantity_with_docs' => 'Кол-во с док',
  28. 'total_quantity' => 'Кол-во общее',
  29. 'note' => 'Примечание',
  30. 'customer_price_txt' => 'Цена для заказчика',
  31. 'expertise_price_txt' => 'Цена экспертизы',
  32. 'tsn_number' => '№ по ТСН',
  33. 'pricing_codes_list' => 'Шифр расценки и коды ресурсов',
  34. 'min_stock' => 'Минимальный остаток',
  35. ],
  36. 'searchFields' => [
  37. 'article',
  38. 'used_in_maf',
  39. 'note',
  40. 'tsn_number',
  41. ],
  42. 'routeName' => 'spare_parts.show',
  43. ];
  44. public function index(Request $request)
  45. {
  46. session(['gp_spare_parts' => $request->query()]);
  47. $model = new SparePart();
  48. // Для админа добавляем колонку цены закупки
  49. if (hasRole('admin')) {
  50. $this->data['header'] = array_merge(
  51. array_slice($this->data['header'], 0, 8, true),
  52. ['purchase_price_txt' => 'Цена закупки'],
  53. array_slice($this->data['header'], 8, null, true)
  54. );
  55. }
  56. // Фильтры
  57. $this->createFilters($model, 'used_in_maf');
  58. // Для range фильтров нужно использовать реальные поля БД (без _txt)
  59. // но заголовки брать из header с _txt
  60. $this->createRangeFiltersForPrices($model, 'customer_price', 'expertise_price', 'min_stock');
  61. if (hasRole('admin')) {
  62. $this->createRangeFiltersForPrices($model, 'purchase_price');
  63. }
  64. // Запрос
  65. $q = $model::query()->with('pricingCodes');
  66. $this->acceptFilters($q, $request);
  67. $this->acceptSearch($q, $request);
  68. $this->setSortAndOrderBy($model, $request);
  69. $q->orderBy($this->data['sortBy'], $this->data['orderBy']);
  70. $this->data['spare_parts'] = $q->paginate(session('per_page', config('pagination.per_page')))->withQueryString();
  71. $this->data['strings'] = $this->data['spare_parts'];
  72. $this->data['tab'] = 'catalog';
  73. return view('spare_parts.index', $this->data);
  74. }
  75. public function show(Request $request, SparePart $sparePart)
  76. {
  77. $this->data['previous_url'] = $request->get('previous_url');
  78. $this->data['spare_part'] = $sparePart;
  79. return view('spare_parts.edit', $this->data);
  80. }
  81. public function help()
  82. {
  83. $markdownPath = base_path('docs/spare-parts.md');
  84. $markdown = File::exists($markdownPath) ? File::get($markdownPath) : '# Справка не найдена';
  85. $this->data['helpContent'] = Str::markdown($markdown, [
  86. 'heading_permalink' => [
  87. 'html_class' => 'heading-permalink',
  88. 'id_prefix' => '',
  89. 'fragment_prefix' => '',
  90. 'insert' => 'none',
  91. 'apply_id_to_heading' => true,
  92. 'min_heading_level' => 1,
  93. 'max_heading_level' => 6,
  94. 'symbol' => '',
  95. ],
  96. ], [
  97. new HeadingPermalinkExtension(),
  98. ]);
  99. $this->data['tab'] = 'help';
  100. return view('spare_parts.index', $this->data);
  101. }
  102. public function create()
  103. {
  104. $this->data['spare_part'] = null;
  105. return view('spare_parts.edit', $this->data);
  106. }
  107. public function store(StoreSparePartRequest $request): RedirectResponse
  108. {
  109. $sparePart = SparePart::create($request->validated());
  110. $this->syncPricingCodes($sparePart, $request);
  111. $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts'));
  112. return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно создана!']);
  113. }
  114. public function update(StoreSparePartRequest $request, SparePart $sparePart): RedirectResponse
  115. {
  116. $sparePart->update($request->validated());
  117. $this->syncPricingCodes($sparePart, $request);
  118. $previous_url = $request->get('previous_url') ?? route('spare_parts.index', session('gp_spare_parts'));
  119. return redirect()->to($previous_url)->with(['success' => 'Запчасть успешно обновлена!']);
  120. }
  121. protected function syncPricingCodes(SparePart $sparePart, StoreSparePartRequest $request): void
  122. {
  123. $codes = $request->input('pricing_codes', []);
  124. $descriptions = $request->input('pricing_codes_descriptions', []);
  125. $pricingCodeIds = [];
  126. foreach ($codes as $index => $code) {
  127. $code = trim($code);
  128. if (empty($code)) {
  129. continue;
  130. }
  131. $description = trim($descriptions[$index] ?? '');
  132. // Находим или создаём PricingCode
  133. $pricingCode = \App\Models\PricingCode::where('type', \App\Models\PricingCode::TYPE_PRICING_CODE)
  134. ->where('code', $code)
  135. ->first();
  136. if ($pricingCode) {
  137. // Обновляем расшифровку, если предоставлена
  138. if ($description && $description !== $pricingCode->description) {
  139. $pricingCode->update(['description' => $description]);
  140. }
  141. } else {
  142. // Создаём новый код
  143. $pricingCode = \App\Models\PricingCode::create([
  144. 'type' => \App\Models\PricingCode::TYPE_PRICING_CODE,
  145. 'code' => $code,
  146. 'description' => $description ?: null,
  147. ]);
  148. }
  149. $pricingCodeIds[] = $pricingCode->id;
  150. }
  151. // Синхронизируем связи
  152. $sparePart->pricingCodes()->sync($pricingCodeIds);
  153. }
  154. public function destroy(SparePart $sparePart): RedirectResponse
  155. {
  156. // Проверка на наличие заказов
  157. if ($sparePart->orders()->count() > 0) {
  158. return redirect()->route('spare_parts.index', session('gp_spare_parts'))
  159. ->with(['error' => 'Невозможно удалить запчасть, т.к. для неё есть заказы!']);
  160. }
  161. $sparePart->delete();
  162. return redirect()->route('spare_parts.index', session('gp_spare_parts'))
  163. ->with(['success' => 'Запчасть успешно удалена!']);
  164. }
  165. public function export(Request $request): RedirectResponse
  166. {
  167. // Запускаем Job для экспорта
  168. ExportSparePartsJob::dispatch(auth()->id());
  169. Log::info('ExportSparePartsJob created!');
  170. return redirect()->route('spare_parts.index', session('gp_spare_parts'))
  171. ->with(['success' => 'Задача экспорта успешно создана!']);
  172. }
  173. public function import(Request $request): RedirectResponse
  174. {
  175. $request->validate([
  176. 'file' => 'required|mimes:xlsx,xls|max:10240',
  177. ]);
  178. try {
  179. // Сохраняем временный файл
  180. $file = $request->file('file');
  181. $tempPath = $file->storeAs('temp/imports', 'spare_parts_import_' . time() . '.xlsx');
  182. $fullPath = Storage::path($tempPath);
  183. // Создаём запись импорта
  184. $import = Import::create([
  185. 'user_id' => auth()->id(),
  186. 'type' => 'spare_parts',
  187. 'status' => Import::STATUS_PENDING,
  188. 'file_path' => $tempPath,
  189. 'original_filename' => $file->getClientOriginalName(),
  190. ]);
  191. // Запускаем Job для импорта
  192. ImportSparePartsJob::dispatch($fullPath, auth()->id(), $import->id);
  193. Log::info('ImportSparePartsJob created!', ['import_id' => $import->id]);
  194. return redirect()->route('spare_parts.index', session('gp_spare_parts'))
  195. ->with(['success' => 'Задача импорта успешно создана! Следите за статусом в разделе импорта.']);
  196. } catch (\Exception $e) {
  197. Log::error('Ошибка создания задачи импорта: ' . $e->getMessage());
  198. return redirect()->route('spare_parts.index', session('gp_spare_parts'))
  199. ->with(['error' => 'Ошибка импорта: ' . $e->getMessage()]);
  200. }
  201. }
  202. public function uploadImage(Request $request, SparePart $sparePart): RedirectResponse
  203. {
  204. $request->validate([
  205. 'image' => 'required|image|mimes:jpeg,jpg,png|max:2048',
  206. ]);
  207. if ($request->hasFile('image')) {
  208. $image = $request->file('image');
  209. $filename = $sparePart->article . '.jpg';
  210. // Создаём директорию если её нет
  211. $directory = public_path('images/spare_parts');
  212. if (!file_exists($directory)) {
  213. mkdir($directory, 0755, true);
  214. }
  215. // Сохраняем изображение
  216. $image->move($directory, $filename);
  217. return redirect()->route('spare_parts.show', $sparePart)
  218. ->with(['success' => 'Изображение успешно загружено!']);
  219. }
  220. return redirect()->route('spare_parts.show', $sparePart)
  221. ->with(['error' => 'Ошибка загрузки изображения!']);
  222. }
  223. /**
  224. * API метод для поиска запчастей (autocomplete)
  225. */
  226. public function search(Request $request)
  227. {
  228. $query = $request->get('query', '');
  229. $spareParts = SparePart::query()
  230. ->where(function ($q) use ($query) {
  231. $q->where('article', 'LIKE', '%' . $query . '%')
  232. ->orWhere('used_in_maf', 'LIKE', '%' . $query . '%');
  233. })
  234. ->orderBy('article')
  235. ->limit(20)
  236. ->get(['id', 'article', 'used_in_maf']);
  237. return response()->json($spareParts);
  238. }
  239. /**
  240. * Создание range фильтров для полей с ценами
  241. * Использует правильные заголовки из header (_txt версии)
  242. */
  243. protected function createRangeFiltersForPrices(SparePart $model, string ...$columnNames): void
  244. {
  245. foreach ($columnNames as $columnName) {
  246. // Определяем ключ заголовка
  247. $headerKey = str_ends_with($columnName, '_price') ? $columnName . '_txt' : $columnName;
  248. // Проверяем, есть ли заголовок
  249. if (!isset($this->data['header'][$headerKey])) {
  250. continue;
  251. }
  252. if (str_ends_with($columnName, '_price')) {
  253. $min = $model::query()->min($columnName);
  254. $max = $model::query()->max($columnName);
  255. $this->data['ranges'][$columnName] = [
  256. 'title' => $this->data['header'][$headerKey],
  257. 'min' => $min ? $min / 100 : 0,
  258. 'max' => $max ? $max / 100 : 0,
  259. ];
  260. } else {
  261. $this->data['ranges'][$columnName] = [
  262. 'title' => $this->data['header'][$headerKey],
  263. 'min' => $model::query()->min($columnName) ?? 0,
  264. 'max' => $model::query()->max($columnName) ?? 0,
  265. ];
  266. }
  267. }
  268. }
  269. }