ImportCommonCatalogService.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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\Services\Export\ExportCommonCatalogService;
  8. use App\Services\FileService;
  9. use Illuminate\Support\Facades\Storage;
  10. use Illuminate\Support\Facades\Validator;
  11. use Illuminate\Support\Str;
  12. use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
  13. use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing;
  14. use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
  15. use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing;
  16. use RuntimeException;
  17. use Throwable;
  18. class ImportCommonCatalogService
  19. {
  20. public function __construct(
  21. private readonly Import $import,
  22. private readonly int $userId,
  23. ) {}
  24. public function handle(): bool
  25. {
  26. try {
  27. $path = Storage::disk('upload')->path((string) $this->import->filename);
  28. $spreadsheet = (new Xlsx)->load($path);
  29. $sheet = $spreadsheet->getActiveSheet();
  30. $this->assertHeaders($sheet->rangeToArray('A1:S1', null, true, true, false)[0]);
  31. $drawings = $this->drawingsByRow($sheet->getDrawingCollection());
  32. $created = 0;
  33. $updated = 0;
  34. $errors = 0;
  35. for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
  36. $article = $this->stringValue($sheet->getCell("C{$row}")->getFormattedValue());
  37. if ($article === null) {
  38. continue;
  39. }
  40. $data = [
  41. 'article' => $article,
  42. 'calculator_name' => $this->stringValue($sheet->getCell("D{$row}")->getValue()),
  43. 'kind' => $this->stringValue($sheet->getCell("E{$row}")->getValue()),
  44. 'dimensions' => $this->stringValue($sheet->getCell("F{$row}")->getValue()),
  45. 'fall_height' => $this->numericValue($sheet->getCell("G{$row}")->getCalculatedValue()),
  46. 'additional_info' => $this->stringValue($sheet->getCell("H{$row}")->getValue()),
  47. 'dimension_unit' => $this->stringValue($sheet->getCell("I{$row}")->getValue()),
  48. 'weight' => $this->numericValue($sheet->getCell("J{$row}")->getCalculatedValue()),
  49. 'volume' => $this->numericValue($sheet->getCell("K{$row}")->getCalculatedValue()),
  50. 'places' => $this->integerValue($sheet->getCell("L{$row}")->getCalculatedValue()),
  51. 'composition' => $this->stringValue($sheet->getCell("M{$row}")->getValue()),
  52. 'age_group' => $this->stringValue($sheet->getCell("N{$row}")->getFormattedValue()),
  53. 'max_users' => $this->integerValue($sheet->getCell("O{$row}")->getCalculatedValue()),
  54. 'unit' => $this->stringValue($sheet->getCell("P{$row}")->getValue()),
  55. 'series' => $this->stringValue($sheet->getCell("Q{$row}")->getValue()),
  56. 'trademark' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
  57. 'note' => $this->stringValue($sheet->getCell("S{$row}")->getValue()),
  58. ];
  59. $validator = Validator::make($data, $this->rules());
  60. if ($validator->fails()) {
  61. $errors++;
  62. $this->import->log(
  63. "Строка {$row}: ".implode(' ', $validator->errors()->all()),
  64. 'WARNING',
  65. );
  66. continue;
  67. }
  68. $item = CommonCatalogItem::query()->where('article', $article)->first();
  69. if ($item) {
  70. $item->update($validator->validated());
  71. $updated++;
  72. } else {
  73. $item = CommonCatalogItem::query()->create($validator->validated());
  74. $created++;
  75. }
  76. if (isset($drawings[$row])) {
  77. try {
  78. $this->replaceImage($item, $drawings[$row]);
  79. } catch (Throwable $exception) {
  80. $errors++;
  81. $this->import->log(
  82. "Строка {$row}: изображение не импортировано ({$exception->getMessage()}).",
  83. 'WARNING',
  84. );
  85. }
  86. }
  87. }
  88. $this->import->log("Создано: {$created}; обновлено: {$updated}; ошибок: {$errors}.");
  89. $this->import->status = 'DONE';
  90. $this->import->save();
  91. $spreadsheet->disconnectWorksheets();
  92. return true;
  93. } catch (Throwable $exception) {
  94. $this->import->log($exception->getMessage(), 'ERROR');
  95. $this->import->status = 'ERROR';
  96. $this->import->save();
  97. throw $exception;
  98. }
  99. }
  100. private function assertHeaders(array $headers): void
  101. {
  102. $actual = array_map($this->normalizeHeader(...), $headers);
  103. $expected = array_map($this->normalizeHeader(...), ExportCommonCatalogService::HEADERS);
  104. if ($actual !== $expected) {
  105. throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый шаблон.');
  106. }
  107. }
  108. /** @param iterable<BaseDrawing> $drawings */
  109. private function drawingsByRow(iterable $drawings): array
  110. {
  111. $result = [];
  112. foreach ($drawings as $drawing) {
  113. if (preg_match('/^B(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
  114. $result[(int) $matches[1]] = $drawing;
  115. }
  116. }
  117. return $result;
  118. }
  119. private function replaceImage(CommonCatalogItem $item, BaseDrawing $drawing): void
  120. {
  121. [$contents, $mimeType, $extension] = $this->drawingContents($drawing);
  122. $relativePath = "common_catalog/items/{$item->id}/image/".Str::uuid().".{$extension}";
  123. Storage::disk('public')->put($relativePath, $contents);
  124. $newImage = File::query()->create([
  125. 'user_id' => $this->userId,
  126. 'original_name' => "{$item->article}.{$extension}",
  127. 'mime_type' => $mimeType,
  128. 'path' => $relativePath,
  129. 'link' => url('/storage/'.$relativePath),
  130. ]);
  131. app(FileService::class)->ensureThumbnail($newImage);
  132. $oldImage = $item->imageFile;
  133. $item->update(['image_file_id' => $newImage->id]);
  134. if ($oldImage) {
  135. app(FileService::class)->deleteFileWithThumbnail($oldImage);
  136. $oldImage->delete();
  137. }
  138. }
  139. private function drawingContents(BaseDrawing $drawing): array
  140. {
  141. if ($drawing instanceof MemoryDrawing) {
  142. ob_start();
  143. ($drawing->getRenderingFunction())($drawing->getImageResource());
  144. $contents = ob_get_clean();
  145. $mimeType = $drawing->getMimeType();
  146. } elseif ($drawing instanceof Drawing) {
  147. $contents = file_get_contents($drawing->getPath());
  148. $mimeType = $this->mimeType($contents ?: '');
  149. } else {
  150. throw new RuntimeException('Неподдерживаемый формат изображения.');
  151. }
  152. if (! is_string($contents) || $contents === '') {
  153. throw new RuntimeException('Пустое изображение.');
  154. }
  155. $extension = match ($mimeType) {
  156. 'image/jpeg' => 'jpg',
  157. 'image/png' => 'png',
  158. 'image/webp' => 'webp',
  159. default => throw new RuntimeException("Неподдерживаемый MIME-тип {$mimeType}"),
  160. };
  161. return [$contents, $mimeType, $extension];
  162. }
  163. private function mimeType(string $contents): string
  164. {
  165. $info = new \finfo(FILEINFO_MIME_TYPE);
  166. return (string) $info->buffer($contents);
  167. }
  168. private function normalizeHeader(mixed $value): string
  169. {
  170. return trim((string) preg_replace('/\s+/u', ' ', (string) $value));
  171. }
  172. private function stringValue(mixed $value): ?string
  173. {
  174. $value = trim((string) $value);
  175. return $value === '' ? null : $value;
  176. }
  177. private function numericValue(mixed $value): int|float|string|null
  178. {
  179. $value = $this->stringValue($value);
  180. if ($value === null) {
  181. return null;
  182. }
  183. return str_replace([' ', ','], ['', '.'], $value);
  184. }
  185. private function integerValue(mixed $value): int|string|null
  186. {
  187. $value = $this->numericValue($value);
  188. if ($value === null) {
  189. return null;
  190. }
  191. return is_numeric($value) ? (int) $value : (string) $value;
  192. }
  193. private function rules(): array
  194. {
  195. return [
  196. 'article' => ['required', 'string', 'max:100'],
  197. 'calculator_name' => ['required', 'string'],
  198. 'kind' => ['nullable', 'string', 'max:255'],
  199. 'dimensions' => ['nullable', 'string'],
  200. 'fall_height' => ['nullable', 'numeric', 'min:0'],
  201. 'additional_info' => ['nullable', 'string'],
  202. 'dimension_unit' => ['nullable', 'string', 'max:50'],
  203. 'weight' => ['nullable', 'numeric', 'min:0'],
  204. 'volume' => ['nullable', 'numeric', 'min:0'],
  205. 'places' => ['nullable', 'integer', 'min:0'],
  206. 'composition' => ['nullable', 'string'],
  207. 'age_group' => ['nullable', 'string', 'max:100'],
  208. 'max_users' => ['nullable', 'integer', 'min:0'],
  209. 'unit' => ['nullable', 'string', 'max:50'],
  210. 'series' => ['nullable', 'string', 'max:255'],
  211. 'trademark' => ['nullable', 'string', 'max:255'],
  212. 'note' => ['nullable', 'string'],
  213. ];
  214. }
  215. }