CommonCatalogController.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http\Controllers;
  4. use App\Http\Requests\StoreCommonCatalogItemRequest;
  5. use App\Jobs\Export\ExportCommonCatalogJob;
  6. use App\Jobs\Export\ExportTechnicalDescriptionsJob;
  7. use App\Jobs\Import\ImportJob;
  8. use App\Models\CommonCatalogItem;
  9. use App\Models\File;
  10. use App\Models\Import;
  11. use App\Models\Role;
  12. use App\Models\Setting;
  13. use App\Services\Access\AccessService;
  14. use App\Services\Access\FieldAccessService;
  15. use App\Services\FileService;
  16. use App\Services\TechnicalDescriptionService;
  17. use Illuminate\Contracts\View\View;
  18. use Illuminate\Http\RedirectResponse;
  19. use Illuminate\Http\Request;
  20. use Illuminate\Support\Facades\Storage;
  21. use Illuminate\Support\Str;
  22. use Illuminate\Validation\Rule;
  23. use Symfony\Component\HttpFoundation\StreamedResponse;
  24. use Throwable;
  25. class CommonCatalogController extends Controller
  26. {
  27. protected array $data = [
  28. 'active' => 'common_catalog',
  29. 'title' => 'Каталог общий',
  30. 'id' => 'common_catalog_items',
  31. 'header' => [
  32. 'id' => 'ID',
  33. 'image' => 'Внешний вид',
  34. 'article' => 'Артикул',
  35. 'calculator_name' => 'Наименование',
  36. 'kind' => 'Вид',
  37. 'dimension_length' => 'Габариты: длина',
  38. 'dimension_width' => 'Габариты: ширина',
  39. 'dimension_height' => 'Габариты: высота',
  40. 'site_length' => 'Участок: длина',
  41. 'site_width' => 'Участок: ширина',
  42. 'fall_height' => 'Высота падения',
  43. 'additional_info' => 'Дополнительные сведения',
  44. 'dimension_unit' => 'Единица измерения габаритов',
  45. 'weight' => 'Вес, кг.',
  46. 'volume' => 'Объем, м3',
  47. 'places' => 'Места',
  48. 'composition' => 'Состав',
  49. 'age_group' => 'Возрастная группа',
  50. 'max_users' => 'Макс. кол-во пользователей',
  51. 'unit' => 'Ед.',
  52. 'series' => 'Серия',
  53. 'trademark' => 'ТМ',
  54. 'calculator_enabled_txt' => 'Калькулятор',
  55. 'builders_price_txt' => 'строители',
  56. 'wholesale_price_txt' => 'опт',
  57. 'recommended_price_txt' => 'рек',
  58. 'retail_price_txt' => 'розница',
  59. 'project_price_txt' => 'проект',
  60. 'project_with_installation_price_txt' => 'проект+м',
  61. 'pik_price_txt' => 'пик',
  62. 'recommended_plus_10_price_txt' => 'рек+10',
  63. ],
  64. 'searchFields' => [
  65. 'article',
  66. 'calculator_name',
  67. 'kind',
  68. 'dimension_length',
  69. 'dimension_width',
  70. 'dimension_height',
  71. 'site_length',
  72. 'site_width',
  73. 'additional_info',
  74. 'composition',
  75. 'series',
  76. 'trademark',
  77. ],
  78. ];
  79. public function index(Request $request, FieldAccessService $fieldAccess): View
  80. {
  81. session(['gp_common_catalog' => $request->query()]);
  82. $nav = $this->startNavigationContext($request);
  83. $model = new CommonCatalogItem;
  84. $readableFields = $this->readableFields($request, $fieldAccess);
  85. $request = $this->sanitizeListRequest($request, $readableFields);
  86. $this->data['header'] = $fieldAccess->visibleHeaders(
  87. $request->user(),
  88. 'common-catalog',
  89. $this->data['header'],
  90. );
  91. $this->data['searchFields'] = array_values(array_intersect(
  92. $this->data['searchFields'],
  93. $readableFields,
  94. ));
  95. $this->createFilters(
  96. $model,
  97. ...array_values(array_intersect([
  98. 'kind',
  99. 'dimension_unit',
  100. 'age_group',
  101. 'unit',
  102. 'series',
  103. 'trademark',
  104. ], $readableFields)),
  105. );
  106. $this->createRangeFilters(
  107. $model,
  108. ...array_values(array_intersect([
  109. 'fall_height',
  110. 'weight',
  111. 'volume',
  112. 'places',
  113. 'max_users',
  114. 'builders_price',
  115. 'wholesale_price',
  116. 'recommended_price',
  117. 'retail_price',
  118. 'project_price',
  119. 'project_with_installation_price',
  120. 'pik_price',
  121. 'recommended_plus_10_price',
  122. ], $readableFields)),
  123. );
  124. $query = CommonCatalogItem::query()->with('imageFile');
  125. $this->acceptFilters($query, $request);
  126. $this->acceptSearch($query, $request);
  127. $this->setSortAndOrderBy($model, $request);
  128. $this->applyStableSorting($query);
  129. $this->data['items'] = $query->paginate($this->data['per_page'])->withQueryString();
  130. $this->data['nav'] = $nav;
  131. $priceField = Setting::technicalDescriptionPriceField();
  132. $this->data['technicalDescriptionModes'] = TechnicalDescriptionService::DESCRIPTION_MODES;
  133. $this->data['canExportTechnicalDescriptions'] = $request->user()->canViewField(
  134. 'common-catalog',
  135. $priceField,
  136. );
  137. return view('common_catalog.index', $this->data);
  138. }
  139. public function create(Request $request, FieldAccessService $fieldAccess): View
  140. {
  141. return $this->itemView($request, $fieldAccess);
  142. }
  143. public function show(
  144. Request $request,
  145. CommonCatalogItem $commonCatalogItem,
  146. FieldAccessService $fieldAccess,
  147. ): View
  148. {
  149. return $this->itemView(
  150. $request,
  151. $fieldAccess,
  152. $commonCatalogItem->load(['imageFile', 'documents']),
  153. );
  154. }
  155. public function store(
  156. StoreCommonCatalogItemRequest $request,
  157. FieldAccessService $fieldAccess,
  158. ): RedirectResponse
  159. {
  160. $item = CommonCatalogItem::query()->create(
  161. $fieldAccess->filterValidatedPayload(
  162. $request->user(),
  163. 'common-catalog',
  164. $request->validated(),
  165. ),
  166. );
  167. return redirect()
  168. ->route('common-catalog.show', $this->withNav(
  169. ['commonCatalogItem' => $item],
  170. $this->resolveNavToken($request),
  171. ))
  172. ->with('success', 'Позиция общего каталога создана.');
  173. }
  174. public function update(
  175. StoreCommonCatalogItemRequest $request,
  176. CommonCatalogItem $commonCatalogItem,
  177. FieldAccessService $fieldAccess,
  178. ): RedirectResponse {
  179. $commonCatalogItem->update($fieldAccess->filterValidatedPayload(
  180. $request->user(),
  181. 'common-catalog',
  182. $request->validated(),
  183. ));
  184. return $this->redirectToItem($request, $commonCatalogItem)
  185. ->with('success', 'Позиция общего каталога обновлена.');
  186. }
  187. public function destroy(
  188. Request $request,
  189. CommonCatalogItem $commonCatalogItem,
  190. FileService $fileService,
  191. ): RedirectResponse {
  192. $commonCatalogItem->load(['imageFile', 'documents']);
  193. $files = $commonCatalogItem->documents;
  194. if ($commonCatalogItem->imageFile) {
  195. $files->push($commonCatalogItem->imageFile);
  196. }
  197. $commonCatalogItem->documents()->detach();
  198. $commonCatalogItem->update(['image_file_id' => null]);
  199. $commonCatalogItem->delete();
  200. foreach ($files->unique('id') as $file) {
  201. $fileService->deleteFileWithThumbnail($file);
  202. $file->delete();
  203. }
  204. return redirect()
  205. ->route('common-catalog.index', session('gp_common_catalog'))
  206. ->with('success', 'Позиция общего каталога удалена.');
  207. }
  208. public function uploadImage(
  209. Request $request,
  210. CommonCatalogItem $commonCatalogItem,
  211. FileService $fileService,
  212. ): RedirectResponse {
  213. $data = $request->validate([
  214. 'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
  215. ]);
  216. try {
  217. $oldImage = $commonCatalogItem->imageFile;
  218. $image = $fileService->saveUploadedFile(
  219. "common_catalog/items/{$commonCatalogItem->id}/image",
  220. $data['image'],
  221. );
  222. $commonCatalogItem->update(['image_file_id' => $image->id]);
  223. if ($oldImage) {
  224. $fileService->deleteFileWithThumbnail($oldImage);
  225. $oldImage->delete();
  226. }
  227. } catch (Throwable $exception) {
  228. report($exception);
  229. return $this->redirectToItem($request, $commonCatalogItem)
  230. ->with('error', 'Не удалось загрузить изображение.');
  231. }
  232. return $this->redirectToItem($request, $commonCatalogItem)
  233. ->with('success', 'Изображение загружено.');
  234. }
  235. public function deleteImage(
  236. Request $request,
  237. CommonCatalogItem $commonCatalogItem,
  238. FileService $fileService,
  239. ): RedirectResponse {
  240. $image = $commonCatalogItem->imageFile;
  241. if ($image) {
  242. $commonCatalogItem->update(['image_file_id' => null]);
  243. $fileService->deleteFileWithThumbnail($image);
  244. $image->delete();
  245. }
  246. return $this->redirectToItem($request, $commonCatalogItem)
  247. ->with('success', 'Изображение удалено.');
  248. }
  249. public function uploadDocument(
  250. Request $request,
  251. CommonCatalogItem $commonCatalogItem,
  252. FileService $fileService,
  253. ): RedirectResponse {
  254. $data = $request->validate([
  255. 'document' => ['required', 'file', 'max:20480'],
  256. ]);
  257. try {
  258. $document = $fileService->saveUploadedFile(
  259. "common_catalog/items/{$commonCatalogItem->id}/documents",
  260. $data['document'],
  261. );
  262. $commonCatalogItem->documents()->attach($document);
  263. } catch (Throwable $exception) {
  264. report($exception);
  265. return $this->redirectToItem($request, $commonCatalogItem)
  266. ->with('error', 'Не удалось загрузить документ.');
  267. }
  268. return $this->redirectToItem($request, $commonCatalogItem)
  269. ->with('success', 'Документ загружен.');
  270. }
  271. public function deleteDocument(
  272. Request $request,
  273. CommonCatalogItem $commonCatalogItem,
  274. File $file,
  275. FileService $fileService,
  276. ): RedirectResponse {
  277. abort_unless($commonCatalogItem->documents()->whereKey($file->getKey())->exists(), 404);
  278. $commonCatalogItem->documents()->detach($file);
  279. $fileService->deleteFileWithThumbnail($file);
  280. $file->delete();
  281. return $this->redirectToItem($request, $commonCatalogItem)
  282. ->with('success', 'Документ удалён.');
  283. }
  284. public function export(Request $request): RedirectResponse
  285. {
  286. ExportCommonCatalogJob::dispatch((int) $request->user()->getKey());
  287. return redirect()
  288. ->route('common-catalog.index', session('gp_common_catalog'))
  289. ->with('success', 'Задача экспорта общего каталога создана.');
  290. }
  291. public function import(Request $request): RedirectResponse
  292. {
  293. $data = $request->validate([
  294. 'import_file' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
  295. ]);
  296. $path = Str::random(2).'/'.Str::uuid().'.xlsx';
  297. Storage::disk('upload')->put($path, $data['import_file']->getContent());
  298. $import = Import::query()->create([
  299. 'type' => 'common_catalog',
  300. 'year' => null,
  301. 'filename' => $path,
  302. 'status' => 'new',
  303. 'user_id' => $request->user()->getKey(),
  304. 'original_filename' => $data['import_file']->getClientOriginalName(),
  305. ]);
  306. ImportJob::dispatch($import, (int) $request->user()->getKey());
  307. return redirect()
  308. ->route('common-catalog.index', session('gp_common_catalog'))
  309. ->with('success', 'Задача импорта общего каталога создана.');
  310. }
  311. public function exportTechnicalDescriptions(
  312. Request $request,
  313. AccessService $accessService,
  314. ): RedirectResponse {
  315. $data = $request->validate([
  316. 'item_ids' => ['required', 'array', 'min:1', 'max:1000'],
  317. 'item_ids.*' => ['required', 'integer', 'distinct', 'exists:common_catalog_items,id'],
  318. 'description_mode' => [
  319. 'required',
  320. 'string',
  321. Rule::in(array_keys(TechnicalDescriptionService::DESCRIPTION_MODES)),
  322. ],
  323. 'separate_documents' => ['nullable', 'boolean'],
  324. ]);
  325. $priceField = Setting::technicalDescriptionPriceField();
  326. abort_unless(
  327. $accessService->canViewField($request->user(), 'common-catalog', $priceField),
  328. 403,
  329. 'Нет доступа к выбранному для технических описаний виду цены.',
  330. );
  331. ExportTechnicalDescriptionsJob::dispatch(
  332. array_values(array_map('intval', $data['item_ids'])),
  333. $data['description_mode'],
  334. (bool) ($data['separate_documents'] ?? false),
  335. (int) $request->user()->getKey(),
  336. );
  337. return back()->with('success', 'Задача экспорта технических описаний создана.');
  338. }
  339. public function downloadTechnicalDescription(Request $request, File $file): StreamedResponse
  340. {
  341. abort_unless($file->is_generated, 404);
  342. abort_unless(
  343. Str::startsWith((string) $file->path, 'generated/technical-descriptions/'),
  344. 404,
  345. );
  346. abort_unless(
  347. (int) $file->user_id === (int) $request->user()->getKey()
  348. || $request->user()->hasRole(Role::ADMIN),
  349. 403,
  350. );
  351. abort_unless(Storage::disk('local')->exists((string) $file->path), 404);
  352. return Storage::disk('local')->download(
  353. (string) $file->path,
  354. (string) $file->original_name,
  355. );
  356. }
  357. private function itemView(
  358. Request $request,
  359. FieldAccessService $fieldAccess,
  360. ?CommonCatalogItem $item = null,
  361. ): View
  362. {
  363. $nav = $this->resolveNavToken($request);
  364. $this->rememberNavigation($request, $nav);
  365. $this->data['nav'] = $nav;
  366. $this->data['back_url'] = $this->navigationBackUrl(
  367. $request,
  368. $nav,
  369. route('common-catalog.index', session('gp_common_catalog')),
  370. );
  371. $this->data['item'] = $item;
  372. $fields = array_keys(config('access.common-catalog.fields', []));
  373. $this->data['commonCatalogReadableFields'] = array_fill_keys(
  374. $fieldAccess->filterReadableFields($request->user(), 'common-catalog', $fields),
  375. true,
  376. );
  377. $this->data['commonCatalogWritableFields'] = [];
  378. foreach ($fields as $field) {
  379. $this->data['commonCatalogWritableFields'][$field] = $request->user()
  380. ->canUpdateField('common-catalog', $field);
  381. }
  382. $priceField = Setting::technicalDescriptionPriceField();
  383. $this->data['technicalDescriptionPriceField'] = $priceField;
  384. $this->data['technicalDescriptionPriceLabel'] = CommonCatalogItem::PRICE_FIELDS[$priceField];
  385. $this->data['canViewTechnicalDescriptionPrice'] = $request->user()->canViewField(
  386. 'common-catalog',
  387. $priceField,
  388. );
  389. $this->data['technicalDescriptionModes'] = TechnicalDescriptionService::DESCRIPTION_MODES;
  390. return view('common_catalog.edit', $this->data);
  391. }
  392. private function readableFields(Request $request, FieldAccessService $fieldAccess): array
  393. {
  394. return $fieldAccess->filterReadableFields(
  395. $request->user(),
  396. 'common-catalog',
  397. array_keys(config('access.common-catalog.fields', [])),
  398. );
  399. }
  400. private function sanitizeListRequest(Request $request, array $readableFields): Request
  401. {
  402. if (
  403. $request->filled('sortBy')
  404. && ! in_array($this->normalizeField($request->string('sortBy')->toString()), $readableFields, true)
  405. ) {
  406. $request->merge(['sortBy' => CommonCatalogItem::DEFAULT_SORT_BY]);
  407. }
  408. if ($request->has('filters') && is_array($request->input('filters'))) {
  409. $request->merge([
  410. 'filters' => array_filter(
  411. $request->input('filters'),
  412. fn (mixed $value, string $field): bool => in_array(
  413. $this->normalizeField($field),
  414. $readableFields,
  415. true,
  416. ),
  417. ARRAY_FILTER_USE_BOTH,
  418. ),
  419. ]);
  420. }
  421. return $request;
  422. }
  423. private function normalizeField(string $field): string
  424. {
  425. $field = preg_replace('/_(from|to)$/', '', $field) ?: $field;
  426. return str_ends_with($field, '_txt') ? substr($field, 0, -4) : $field;
  427. }
  428. private function redirectToItem(
  429. Request $request,
  430. CommonCatalogItem $commonCatalogItem,
  431. ): RedirectResponse {
  432. return redirect()->route('common-catalog.show', $this->withNav(
  433. ['commonCatalogItem' => $commonCatalogItem],
  434. $this->resolveNavToken($request),
  435. ));
  436. }
  437. }