ImportTechnicalDescriptionsService.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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\Services\FileService;
  7. use Illuminate\Support\Facades\Storage;
  8. use Illuminate\Support\Str;
  9. use RuntimeException;
  10. class ImportTechnicalDescriptionsService
  11. {
  12. /**
  13. * @param list<array<string, mixed>> $sourceProducts
  14. * @return array<string, int|list<string>>
  15. */
  16. public function handle(
  17. array $sourceProducts,
  18. ?string $imagesDirectory,
  19. int $userId,
  20. bool $overwrite = false,
  21. bool $createMissing = false,
  22. bool $applyToVariants = false,
  23. bool $dryRun = false,
  24. ): array {
  25. $stats = [
  26. 'source' => count($sourceProducts),
  27. 'matched' => 0,
  28. 'updated' => 0,
  29. 'created' => 0,
  30. 'unchanged' => 0,
  31. 'missing' => 0,
  32. 'ambiguous' => 0,
  33. 'images_imported' => 0,
  34. 'images_missing' => 0,
  35. 'missing_articles' => [],
  36. 'ambiguous_articles' => [],
  37. ];
  38. foreach ($sourceProducts as $source) {
  39. $article = trim((string) ($source['article'] ?? ''));
  40. if ($article === '') {
  41. continue;
  42. }
  43. $items = CommonCatalogItem::query()->where('article', $article)->get();
  44. if ($items->isEmpty()) {
  45. if (! $createMissing) {
  46. $stats['missing']++;
  47. $stats['missing_articles'][] = $article;
  48. continue;
  49. }
  50. $items = collect([new CommonCatalogItem([
  51. 'article' => $article,
  52. 'calculator_name' => $this->nullableString($source['name'] ?? null),
  53. ])]);
  54. } elseif ($items->count() > 1 && ! $applyToVariants) {
  55. $stats['ambiguous']++;
  56. $stats['ambiguous_articles'][] = $article;
  57. continue;
  58. }
  59. foreach ($items as $item) {
  60. $stats['matched']++;
  61. $payload = $this->payload($source);
  62. $changes = $this->changes($item, $payload, $overwrite);
  63. $isNew = ! $item->exists;
  64. $imageSource = $this->imageSource($source, $imagesDirectory);
  65. $shouldImportImage = $item->image_file_id === null && $imageSource !== null;
  66. if ($dryRun) {
  67. if ($isNew) {
  68. $stats['created']++;
  69. } elseif ($changes !== [] || $shouldImportImage) {
  70. $stats['updated']++;
  71. } else {
  72. $stats['unchanged']++;
  73. }
  74. if ($shouldImportImage) {
  75. $stats['images_imported']++;
  76. } elseif ($item->image_file_id === null && ($source['image_path'] ?? '') !== '') {
  77. $stats['images_missing']++;
  78. }
  79. continue;
  80. }
  81. if ($changes !== []) {
  82. $item->fill($changes);
  83. }
  84. if ($isNew) {
  85. $item->save();
  86. $stats['created']++;
  87. } elseif ($item->isDirty()) {
  88. $item->save();
  89. $stats['updated']++;
  90. } else {
  91. $stats['unchanged']++;
  92. }
  93. if ($item->image_file_id === null && ($source['image_path'] ?? '') !== '') {
  94. if ($imageSource === null) {
  95. $stats['images_missing']++;
  96. } else {
  97. $this->importImage($item, $imageSource, $userId);
  98. $stats['images_imported']++;
  99. if (! $isNew && $changes === []) {
  100. $stats['unchanged']--;
  101. $stats['updated']++;
  102. }
  103. }
  104. }
  105. }
  106. }
  107. $stats['missing_articles'] = array_values(array_unique($stats['missing_articles']));
  108. $stats['ambiguous_articles'] = array_values(array_unique($stats['ambiguous_articles']));
  109. return $stats;
  110. }
  111. /** @param array<string, mixed> $source */
  112. private function payload(array $source): array
  113. {
  114. return [
  115. 'series' => $this->nullableString($source['series'] ?? null),
  116. 'calculator_name' => $this->nullableString($source['name'] ?? null),
  117. 'print_name' => $this->nullableString($source['name_for_form'] ?? null),
  118. 'product_group' => $this->nullableString($source['product_group'] ?? null),
  119. 'characteristics' => $this->nullableString($source['characteristics'] ?? null),
  120. 'technical_description' => $this->nullableString($source['tech_description'] ?? null),
  121. 'technical_description_short' => $this->nullableString($source['tech_description_short'] ?? null),
  122. ];
  123. }
  124. private function changes(CommonCatalogItem $item, array $payload, bool $overwrite): array
  125. {
  126. return array_filter(
  127. $payload,
  128. function (mixed $value, string $field) use ($item, $overwrite): bool {
  129. if ($value === null) {
  130. return false;
  131. }
  132. return $overwrite || trim((string) $item->getAttribute($field)) === '';
  133. },
  134. ARRAY_FILTER_USE_BOTH,
  135. );
  136. }
  137. /** @param array<string, mixed> $source */
  138. private function imageSource(array $source, ?string $imagesDirectory): ?string
  139. {
  140. $imageName = basename(str_replace('\\', '/', (string) ($source['image_path'] ?? '')));
  141. if ($imageName === '' || $imagesDirectory === null || $imagesDirectory === '') {
  142. return null;
  143. }
  144. $path = rtrim($imagesDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$imageName;
  145. return is_file($path) && is_readable($path) ? $path : null;
  146. }
  147. private function importImage(CommonCatalogItem $item, string $sourcePath, int $userId): void
  148. {
  149. $contents = file_get_contents($sourcePath);
  150. if (! is_string($contents) || $contents === '') {
  151. throw new RuntimeException("Не удалось прочитать изображение {$sourcePath}.");
  152. }
  153. $mimeType = (string) (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
  154. $extension = match ($mimeType) {
  155. 'image/jpeg' => 'jpg',
  156. 'image/png' => 'png',
  157. 'image/webp' => 'webp',
  158. default => throw new RuntimeException("Неподдерживаемый MIME-тип изображения {$mimeType}."),
  159. };
  160. $path = "common_catalog/items/{$item->id}/image/".Str::uuid().".{$extension}";
  161. if (! Storage::disk('public')->put($path, $contents)) {
  162. throw new RuntimeException("Не удалось сохранить изображение для артикула {$item->article}.");
  163. }
  164. $file = File::query()->create([
  165. 'link' => url('/storage/'.$path),
  166. 'path' => $path,
  167. 'user_id' => $userId,
  168. 'original_name' => basename($sourcePath),
  169. 'mime_type' => $mimeType,
  170. ]);
  171. app(FileService::class)->ensureThumbnail($file);
  172. $item->update(['image_file_id' => $file->id]);
  173. }
  174. private function nullableString(mixed $value): ?string
  175. {
  176. $value = trim((string) $value);
  177. return $value === '' ? null : $value;
  178. }
  179. }