ExportTechnicalDescriptionsService.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services\Export;
  4. use App\Models\CommonCatalogItem;
  5. use App\Models\File;
  6. use App\Services\TechnicalDescriptionService;
  7. use Illuminate\Support\Collection;
  8. use Illuminate\Support\Facades\Storage;
  9. use Illuminate\Support\Str;
  10. use PhpOffice\PhpWord\Settings as PhpWordSettings;
  11. use PhpOffice\PhpWord\TemplateProcessor;
  12. use RuntimeException;
  13. use ZipArchive;
  14. class ExportTechnicalDescriptionsService
  15. {
  16. private const COMBINED_TEMPLATE = 'массовый.docx';
  17. private const SINGLE_TEMPLATE = 'одиночный.docx';
  18. public function __construct(
  19. private readonly TechnicalDescriptionService $technicalDescriptions,
  20. ) {}
  21. /** @param list<int> $itemIds */
  22. public function handle(
  23. array $itemIds,
  24. string $descriptionMode,
  25. bool $separateDocuments,
  26. int $userId,
  27. ): File {
  28. $items = CommonCatalogItem::query()
  29. ->with('imageFile')
  30. ->whereIn('id', $itemIds)
  31. ->orderBy('series')
  32. ->orderBy('article')
  33. ->get();
  34. if ($items->isEmpty()) {
  35. throw new RuntimeException('Не выбраны позиции для экспорта технических описаний.');
  36. }
  37. if (! array_key_exists($descriptionMode, TechnicalDescriptionService::DESCRIPTION_MODES)) {
  38. throw new RuntimeException('Выбран неизвестный вариант технического описания.');
  39. }
  40. PhpWordSettings::setOutputEscapingEnabled(true);
  41. return $separateDocuments
  42. ? $this->separateDocuments($items, $descriptionMode, $userId)
  43. : $this->combinedDocument($items, $descriptionMode, $userId);
  44. }
  45. /** @param Collection<int, CommonCatalogItem> $items */
  46. private function combinedDocument(Collection $items, string $descriptionMode, int $userId): File
  47. {
  48. $filename = 'Технические_описания_'.now()->format('Ymd_His').'.docx';
  49. $path = $this->generatedPath($userId, $filename);
  50. Storage::disk('local')->makeDirectory(dirname($path));
  51. $processor = new TemplateProcessor($this->templatePath(self::COMBINED_TEMPLATE));
  52. $processor->cloneBlock('product_block', $items->count(), true, true);
  53. foreach ($items->values() as $index => $item) {
  54. $this->fillProduct($processor, $item, $descriptionMode, $index + 1, $index + 1);
  55. }
  56. $processor->saveAs(Storage::disk('local')->path($path));
  57. return $this->registerFile($path, $filename, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', $userId);
  58. }
  59. /** @param Collection<int, CommonCatalogItem> $items */
  60. private function separateDocuments(Collection $items, string $descriptionMode, int $userId): File
  61. {
  62. $filename = 'Технические_описания_'.now()->format('Ymd_His').'.zip';
  63. $path = $this->generatedPath($userId, $filename);
  64. $temporaryDirectory = 'generated/technical-descriptions/tmp/'.Str::uuid();
  65. Storage::disk('local')->makeDirectory(dirname($path));
  66. Storage::disk('local')->makeDirectory($temporaryDirectory);
  67. $zip = new ZipArchive;
  68. if ($zip->open(Storage::disk('local')->path($path), ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
  69. throw new RuntimeException('Не удалось создать ZIP-архив технических описаний.');
  70. }
  71. try {
  72. foreach ($items->values() as $index => $item) {
  73. $documentName = sprintf(
  74. '%s_%04d.docx',
  75. $this->safeFilename((string) $item->article),
  76. $index + 1,
  77. );
  78. $documentPath = $temporaryDirectory.'/'.$documentName;
  79. $processor = new TemplateProcessor($this->templatePath(self::SINGLE_TEMPLATE));
  80. $processor->cloneBlock('product_block', 1, true, true);
  81. $this->fillProduct($processor, $item, $descriptionMode, 1, 1);
  82. $processor->setValue('page_br#1', '');
  83. $processor->saveAs(Storage::disk('local')->path($documentPath));
  84. $zip->addFile(Storage::disk('local')->path($documentPath), $documentName);
  85. }
  86. } finally {
  87. $zip->close();
  88. Storage::disk('local')->deleteDirectory($temporaryDirectory);
  89. }
  90. return $this->registerFile($path, $filename, 'application/zip', $userId);
  91. }
  92. private function fillProduct(
  93. TemplateProcessor $processor,
  94. CommonCatalogItem $item,
  95. string $descriptionMode,
  96. int $variableIndex,
  97. int $number,
  98. ): void {
  99. $suffix = '#'.$variableIndex;
  100. $processor->setValue('product_group'.$suffix, $item->product_group ?: $item->kind ?: '');
  101. $processor->setValue('num'.$suffix, (string) $number);
  102. $processor->setValue('name_for_form'.$suffix, $item->print_name ?: $item->calculator_name ?: '');
  103. $processor->setValue('price'.$suffix, $this->technicalDescriptions->formattedPrice($item));
  104. $processor->setValue(
  105. 'description'.$suffix,
  106. $this->technicalDescriptions->description($item, $descriptionMode),
  107. );
  108. $imagePath = $item->imageFile?->path;
  109. if ($imagePath && Storage::disk('public')->exists($imagePath)) {
  110. $processor->setImageValue('image'.$suffix, [
  111. 'path' => Storage::disk('public')->path($imagePath),
  112. 'width' => 270,
  113. 'height' => 180,
  114. 'ratio' => true,
  115. ]);
  116. } else {
  117. $processor->setValue('image'.$suffix, '');
  118. }
  119. }
  120. private function registerFile(string $path, string $filename, string $mimeType, int $userId): File
  121. {
  122. $file = File::query()->create([
  123. 'link' => '',
  124. 'path' => $path,
  125. 'user_id' => $userId,
  126. 'original_name' => $filename,
  127. 'mime_type' => $mimeType,
  128. 'is_generated' => true,
  129. ]);
  130. $file->update([
  131. 'link' => route('common-catalog.technical-description.download', $file),
  132. ]);
  133. return $file;
  134. }
  135. private function generatedPath(int $userId, string $filename): string
  136. {
  137. return "generated/technical-descriptions/{$userId}/".Str::uuid()."/{$filename}";
  138. }
  139. private function templatePath(string $filename): string
  140. {
  141. $path = base_path('templates/technical-descriptions/'.$filename);
  142. if (! is_file($path)) {
  143. throw new RuntimeException("Шаблон технического описания {$filename} не найден.");
  144. }
  145. return $path;
  146. }
  147. private function safeFilename(string $value): string
  148. {
  149. $value = preg_replace('/[^\pL\pN._-]+/u', '_', trim($value)) ?? '';
  150. return $value === '' ? 'позиция' : mb_substr($value, 0, 100);
  151. }
  152. }