ImportCommonCatalogService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services\Import;
  4. use App\Models\CommonCatalogItem;
  5. use App\Models\File;
  6. use App\Models\Import;
  7. use App\Models\User;
  8. use App\Services\Access\AccessService;
  9. use App\Services\Export\ExportCommonCatalogService;
  10. use App\Services\FileService;
  11. use Illuminate\Support\Facades\Storage;
  12. use Illuminate\Support\Facades\Validator;
  13. use Illuminate\Support\Str;
  14. use PhpOffice\PhpSpreadsheet\Cell\Cell;
  15. use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
  16. use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing;
  17. use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
  18. use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing;
  19. use RuntimeException;
  20. use Throwable;
  21. class ImportCommonCatalogService
  22. {
  23. public function __construct(
  24. private readonly Import $import,
  25. private readonly int $userId,
  26. ) {}
  27. public function handle(): bool
  28. {
  29. try {
  30. $path = Storage::disk('upload')->path((string) $this->import->filename);
  31. $spreadsheet = (new Xlsx)->load($path);
  32. $sheet = $spreadsheet->getActiveSheet();
  33. $this->assertHeaders(
  34. $sheet->rangeToArray('A1:AD1', null, true, true, false)[0],
  35. $sheet->rangeToArray('A2:AD2', null, true, true, false)[0],
  36. );
  37. $drawings = $this->drawingsByRow($sheet->getDrawingCollection());
  38. $articleCounts = $this->articleCounts($sheet);
  39. $user = User::query()->findOrFail($this->userId);
  40. $access = app(AccessService::class);
  41. $created = 0;
  42. $updated = 0;
  43. $errors = 0;
  44. for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
  45. $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
  46. if ($article === null) {
  47. continue;
  48. }
  49. $data = [
  50. 'article' => $article,
  51. 'calculator_name' => $this->stringValue($sheet->getCell("B{$row}")->getValue()),
  52. 'kind' => $this->stringValue($sheet->getCell("C{$row}")->getValue()),
  53. 'dimension_length' => $this->dimensionValue($sheet->getCell("D{$row}")),
  54. 'dimension_width' => $this->dimensionValue($sheet->getCell("E{$row}")),
  55. 'dimension_height' => $this->dimensionValue($sheet->getCell("F{$row}")),
  56. 'site_length' => $this->dimensionValue($sheet->getCell("G{$row}")),
  57. 'site_width' => $this->dimensionValue($sheet->getCell("H{$row}")),
  58. 'fall_height' => $this->numericValue($sheet->getCell("I{$row}")->getCalculatedValue()),
  59. 'additional_info' => $this->stringValue($sheet->getCell("J{$row}")->getValue()),
  60. 'dimension_unit' => $this->stringValue($sheet->getCell("K{$row}")->getValue()),
  61. 'weight' => $this->numericValue($sheet->getCell("L{$row}")->getCalculatedValue()),
  62. 'volume' => $this->numericValue($sheet->getCell("M{$row}")->getCalculatedValue()),
  63. 'places' => $this->integerValue($sheet->getCell("N{$row}")->getCalculatedValue()),
  64. 'composition' => $this->stringValue($sheet->getCell("O{$row}")->getValue()),
  65. 'age_group' => $this->stringValue($sheet->getCell("P{$row}")->getFormattedValue()),
  66. 'max_users' => $this->integerValue($sheet->getCell("Q{$row}")->getCalculatedValue()),
  67. 'unit' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
  68. 'series' => $this->stringValue($sheet->getCell("T{$row}")->getValue()),
  69. 'trademark' => $this->stringValue($sheet->getCell("U{$row}")->getValue()),
  70. 'calculator_enabled' => $this->booleanValue($sheet->getCell("V{$row}")->getValue()),
  71. 'builders_price' => $this->numericValue($sheet->getCell("W{$row}")->getCalculatedValue()),
  72. 'wholesale_price' => $this->numericValue($sheet->getCell("X{$row}")->getCalculatedValue()),
  73. 'recommended_price' => $this->numericValue($sheet->getCell("Y{$row}")->getCalculatedValue()),
  74. 'retail_price' => $this->numericValue($sheet->getCell("Z{$row}")->getCalculatedValue()),
  75. 'project_price' => $this->numericValue($sheet->getCell("AA{$row}")->getCalculatedValue()),
  76. 'project_with_installation_price' => $this->numericValue($sheet->getCell("AB{$row}")->getCalculatedValue()),
  77. 'pik_price' => $this->numericValue($sheet->getCell("AC{$row}")->getCalculatedValue()),
  78. 'recommended_plus_10_price' => $this->numericValue($sheet->getCell("AD{$row}")->getCalculatedValue()),
  79. ];
  80. $data = $access->filterWritableData($user, 'common-catalog', $data);
  81. $validator = Validator::make($data, $this->rules());
  82. if ($validator->fails()) {
  83. $errors++;
  84. $this->import->log(
  85. "Строка {$row}: ".implode(' ', $validator->errors()->all()),
  86. 'WARNING',
  87. );
  88. continue;
  89. }
  90. $validated = $validator->validated();
  91. $item = $this->findExistingItem(
  92. $article,
  93. $validated['calculator_name'],
  94. $articleCounts[$article] ?? 1,
  95. );
  96. if ($item) {
  97. $item->update($validated);
  98. $updated++;
  99. } else {
  100. $item = CommonCatalogItem::query()->create($validated);
  101. $created++;
  102. }
  103. if (isset($drawings[$row])) {
  104. try {
  105. $this->replaceImage($item, $drawings[$row]);
  106. } catch (Throwable $exception) {
  107. $errors++;
  108. $this->import->log(
  109. "Строка {$row}: изображение не импортировано ({$exception->getMessage()}).",
  110. 'WARNING',
  111. );
  112. }
  113. }
  114. }
  115. [$technicalDescriptionsUpdated, $technicalDescriptionsErrors] = $this->importTechnicalDescriptions(
  116. $spreadsheet,
  117. $user,
  118. $access,
  119. );
  120. $errors += $technicalDescriptionsErrors;
  121. $this->import->log(
  122. "Создано: {$created}; обновлено: {$updated}; техописаний обновлено: {$technicalDescriptionsUpdated}; ошибок: {$errors}.",
  123. );
  124. $this->import->status = 'DONE';
  125. $this->import->save();
  126. $spreadsheet->disconnectWorksheets();
  127. return true;
  128. } catch (Throwable $exception) {
  129. $this->import->log($exception->getMessage(), 'ERROR');
  130. $this->import->status = 'ERROR';
  131. $this->import->save();
  132. throw $exception;
  133. }
  134. }
  135. private function assertHeaders(array $headers, array $subheaders): void
  136. {
  137. $actual = array_map($this->normalizeHeader(...), $headers);
  138. $expected = array_map($this->normalizeHeader(...), ExportCommonCatalogService::HEADERS);
  139. $actualSubheaders = array_map($this->normalizeHeader(...), $subheaders);
  140. $expectedSubheaders = array_map($this->normalizeHeader(...), ExportCommonCatalogService::SUBHEADERS);
  141. if ($actual !== $expected || $actualSubheaders !== $expectedSubheaders) {
  142. throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый формат.');
  143. }
  144. }
  145. private function importTechnicalDescriptions(
  146. \PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet,
  147. User $user,
  148. AccessService $access,
  149. ): array {
  150. $sheet = $spreadsheet->getSheetByName(ExportCommonCatalogService::TECHNICAL_DESCRIPTIONS_SHEET);
  151. if ($sheet === null) {
  152. return [0, 0];
  153. }
  154. $headers = $sheet->rangeToArray('A1:G1', null, true, true, false)[0];
  155. $actualHeaders = array_map($this->normalizeHeader(...), $headers);
  156. $expectedHeaders = array_map(
  157. $this->normalizeHeader(...),
  158. ExportCommonCatalogService::TECHNICAL_DESCRIPTION_HEADERS,
  159. );
  160. if ($actualHeaders !== $expectedHeaders) {
  161. throw new RuntimeException('Некорректные заголовки листа «Техописания». Используйте экспорт общего каталога.');
  162. }
  163. $updated = 0;
  164. $errors = 0;
  165. for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
  166. $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
  167. if ($article === null) {
  168. continue;
  169. }
  170. $calculatorName = $this->stringValue($sheet->getCell("B{$row}")->getValue());
  171. $item = $this->findTechnicalDescriptionItem($article, $calculatorName);
  172. if ($item === null) {
  173. $errors++;
  174. $this->import->log(
  175. "Лист «Техописания», строка {$row}: позиция {$article} не найдена или неоднозначна.",
  176. 'WARNING',
  177. );
  178. continue;
  179. }
  180. $data = $access->filterWritableData($user, 'common-catalog', [
  181. 'print_name' => $this->stringValue($sheet->getCell("C{$row}")->getValue()),
  182. 'product_group' => $this->stringValue($sheet->getCell("D{$row}")->getValue()),
  183. 'characteristics' => $this->stringValue($sheet->getCell("E{$row}")->getValue()),
  184. 'technical_description' => $this->stringValue($sheet->getCell("F{$row}")->getValue()),
  185. 'technical_description_short' => $this->stringValue($sheet->getCell("G{$row}")->getValue()),
  186. ]);
  187. $validator = Validator::make($data, $this->technicalDescriptionRules());
  188. if ($validator->fails()) {
  189. $errors++;
  190. $this->import->log(
  191. "Лист «Техописания», строка {$row}: ".implode(' ', $validator->errors()->all()),
  192. 'WARNING',
  193. );
  194. continue;
  195. }
  196. $item->update($validator->validated());
  197. $updated++;
  198. }
  199. return [$updated, $errors];
  200. }
  201. private function findTechnicalDescriptionItem(string $article, ?string $calculatorName): ?CommonCatalogItem
  202. {
  203. $exact = CommonCatalogItem::query()
  204. ->where('article', $article)
  205. ->where('calculator_name', $calculatorName)
  206. ->first();
  207. if ($exact) {
  208. return $exact;
  209. }
  210. $items = CommonCatalogItem::query()
  211. ->where('article', $article)
  212. ->limit(2)
  213. ->get();
  214. return $items->count() === 1 ? $items->first() : null;
  215. }
  216. private function articleCounts(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): array
  217. {
  218. $counts = [];
  219. for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
  220. $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
  221. if ($article !== null) {
  222. $counts[$article] = ($counts[$article] ?? 0) + 1;
  223. }
  224. }
  225. return $counts;
  226. }
  227. private function findExistingItem(
  228. string $article,
  229. ?string $name,
  230. int $sourceOccurrences,
  231. ): ?CommonCatalogItem {
  232. $exact = CommonCatalogItem::query()
  233. ->where('article', $article)
  234. ->where('calculator_name', $name)
  235. ->first();
  236. if ($exact) {
  237. return $exact;
  238. }
  239. if ($sourceOccurrences !== 1) {
  240. return null;
  241. }
  242. $items = CommonCatalogItem::query()
  243. ->where('article', $article)
  244. ->limit(2)
  245. ->get();
  246. return $items->count() === 1 ? $items->first() : null;
  247. }
  248. /** @param iterable<BaseDrawing> $drawings */
  249. private function drawingsByRow(iterable $drawings): array
  250. {
  251. $result = [];
  252. foreach ($drawings as $drawing) {
  253. if (preg_match('/^S(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
  254. $result[(int) $matches[1]] = $drawing;
  255. }
  256. }
  257. return $result;
  258. }
  259. private function replaceImage(CommonCatalogItem $item, BaseDrawing $drawing): void
  260. {
  261. [$contents, $mimeType, $extension] = $this->drawingContents($drawing);
  262. $relativePath = "common_catalog/items/{$item->id}/image/".Str::uuid().".{$extension}";
  263. Storage::disk('public')->put($relativePath, $contents);
  264. $newImage = File::query()->create([
  265. 'user_id' => $this->userId,
  266. 'original_name' => "{$item->article}.{$extension}",
  267. 'mime_type' => $mimeType,
  268. 'path' => $relativePath,
  269. 'link' => url('/storage/'.$relativePath),
  270. ]);
  271. app(FileService::class)->ensureThumbnail($newImage);
  272. $oldImage = $item->imageFile;
  273. $item->update(['image_file_id' => $newImage->id]);
  274. if ($oldImage) {
  275. app(FileService::class)->deleteFileWithThumbnail($oldImage);
  276. $oldImage->delete();
  277. }
  278. }
  279. private function drawingContents(BaseDrawing $drawing): array
  280. {
  281. if ($drawing instanceof MemoryDrawing) {
  282. ob_start();
  283. ($drawing->getRenderingFunction())($drawing->getImageResource());
  284. $contents = ob_get_clean();
  285. $mimeType = $drawing->getMimeType();
  286. } elseif ($drawing instanceof Drawing) {
  287. $contents = file_get_contents($drawing->getPath());
  288. $mimeType = $this->mimeType($contents ?: '');
  289. } else {
  290. throw new RuntimeException('Неподдерживаемый формат изображения.');
  291. }
  292. if (! is_string($contents) || $contents === '') {
  293. throw new RuntimeException('Пустое изображение.');
  294. }
  295. $extension = match ($mimeType) {
  296. 'image/jpeg' => 'jpg',
  297. 'image/png' => 'png',
  298. 'image/webp' => 'webp',
  299. default => throw new RuntimeException("Неподдерживаемый MIME-тип {$mimeType}"),
  300. };
  301. return [$contents, $mimeType, $extension];
  302. }
  303. private function mimeType(string $contents): string
  304. {
  305. $info = new \finfo(FILEINFO_MIME_TYPE);
  306. return (string) $info->buffer($contents);
  307. }
  308. private function normalizeHeader(mixed $value): string
  309. {
  310. return trim((string) preg_replace('/\s+/u', ' ', (string) $value));
  311. }
  312. private function stringValue(mixed $value): ?string
  313. {
  314. $value = trim((string) $value);
  315. return $value === '' ? null : $value;
  316. }
  317. private function numericValue(mixed $value): int|float|string|null
  318. {
  319. $value = $this->stringValue($value);
  320. if ($value === null) {
  321. return null;
  322. }
  323. return str_replace([' ', ','], ['', '.'], $value);
  324. }
  325. private function dimensionValue(Cell $cell): ?string
  326. {
  327. $calculated = $cell->getCalculatedValue();
  328. if (is_int($calculated) || is_float($calculated)) {
  329. return rtrim(rtrim(number_format((float) $calculated, 10, '.', ''), '0'), '.');
  330. }
  331. return $this->stringValue($cell->getFormattedValue());
  332. }
  333. private function integerValue(mixed $value): int|string|null
  334. {
  335. $value = $this->numericValue($value);
  336. if ($value === null) {
  337. return null;
  338. }
  339. return is_numeric($value) ? (int) $value : (string) $value;
  340. }
  341. private function booleanValue(mixed $value): bool|string|null
  342. {
  343. $value = $this->stringValue($value);
  344. if ($value === null) {
  345. return null;
  346. }
  347. return match (mb_strtolower($value)) {
  348. 'да', 'yes', '1' => true,
  349. 'нет', 'no', '0' => false,
  350. default => $value,
  351. };
  352. }
  353. private function rules(): array
  354. {
  355. return [
  356. 'article' => ['required', 'string', 'max:100'],
  357. 'calculator_name' => ['nullable', 'string', 'max:255'],
  358. 'kind' => ['nullable', 'string', 'max:255'],
  359. 'dimension_length' => ['nullable', 'string', 'max:255'],
  360. 'dimension_width' => ['nullable', 'string', 'max:255'],
  361. 'dimension_height' => ['nullable', 'string', 'max:255'],
  362. 'site_length' => ['nullable', 'string', 'max:255'],
  363. 'site_width' => ['nullable', 'string', 'max:255'],
  364. 'fall_height' => ['nullable', 'numeric', 'min:0'],
  365. 'additional_info' => ['nullable', 'string'],
  366. 'dimension_unit' => ['nullable', 'string', 'max:50'],
  367. 'weight' => ['nullable', 'numeric', 'min:0'],
  368. 'volume' => ['nullable', 'numeric', 'min:0'],
  369. 'places' => ['nullable', 'integer', 'min:0'],
  370. 'composition' => ['nullable', 'string'],
  371. 'age_group' => ['nullable', 'string', 'max:100'],
  372. 'max_users' => ['nullable', 'integer', 'min:0'],
  373. 'unit' => ['nullable', 'string', 'max:50'],
  374. 'series' => ['nullable', 'string', 'max:255'],
  375. 'trademark' => ['nullable', 'string', 'max:255'],
  376. 'calculator_enabled' => ['nullable', 'boolean'],
  377. 'builders_price' => ['nullable', 'numeric', 'min:0'],
  378. 'wholesale_price' => ['nullable', 'numeric', 'min:0'],
  379. 'recommended_price' => ['nullable', 'numeric', 'min:0'],
  380. 'retail_price' => ['nullable', 'numeric', 'min:0'],
  381. 'project_price' => ['nullable', 'numeric', 'min:0'],
  382. 'project_with_installation_price' => ['nullable', 'numeric', 'min:0'],
  383. 'pik_price' => ['nullable', 'numeric', 'min:0'],
  384. 'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
  385. ];
  386. }
  387. private function technicalDescriptionRules(): array
  388. {
  389. return [
  390. 'print_name' => ['nullable', 'string', 'max:255'],
  391. 'product_group' => ['nullable', 'string', 'max:255'],
  392. 'characteristics' => ['nullable', 'string'],
  393. 'technical_description' => ['nullable', 'string'],
  394. 'technical_description_short' => ['nullable', 'string'],
  395. ];
  396. }
  397. }