ImportSparePartsService.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. <?php
  2. namespace App\Services\Import;
  3. use App\Models\PricingCode;
  4. use App\Models\SparePart;
  5. use Exception;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. use PhpOffice\PhpSpreadsheet\IOFactory;
  9. class ImportSparePartsService
  10. {
  11. private array $logs = [];
  12. public function __construct(
  13. private readonly string $filePath,
  14. private readonly int $userId,
  15. ) {}
  16. public function handle(): array
  17. {
  18. try {
  19. $this->log("Начало импорта каталога запчастей и справочника расшифровок");
  20. DB::beginTransaction();
  21. try {
  22. // Загружаем файл Excel
  23. $spreadsheet = IOFactory::load($this->filePath);
  24. // Импорт каталога запчастей (первая вкладка)
  25. $this->importSpareParts($spreadsheet->getSheet(0));
  26. // Импорт справочника расшифровок (вторая вкладка, если есть)
  27. if ($spreadsheet->getSheetCount() > 1) {
  28. $this->importPricingCodes($spreadsheet->getSheet(1));
  29. }
  30. DB::commit();
  31. $this->log("Импорт успешно завершён");
  32. return [
  33. 'success' => true,
  34. 'logs' => $this->logs,
  35. ];
  36. } catch (Exception $e) {
  37. DB::rollBack();
  38. throw $e;
  39. }
  40. } catch (Exception $e) {
  41. $this->log("ОШИБКА: " . $e->getMessage());
  42. Log::error("Ошибка импорта запчастей: " . $e->getMessage(), [
  43. 'trace' => $e->getTraceAsString(),
  44. ]);
  45. return [
  46. 'success' => false,
  47. 'error' => $e->getMessage(),
  48. 'logs' => $this->logs,
  49. ];
  50. }
  51. }
  52. private function importSpareParts($sheet): void
  53. {
  54. $this->log("Импорт каталога запчастей...");
  55. $highestRow = $sheet->getHighestRow();
  56. $imported = 0;
  57. $updated = 0;
  58. $skipped = 0;
  59. // Начинаем со 2-й строки (пропускаем заголовок)
  60. for ($row = 2; $row <= $highestRow; $row++) {
  61. $article = trim($sheet->getCell('B' . $row)->getValue());
  62. // Пропускаем пустые строки
  63. if (empty($article)) {
  64. $skipped++;
  65. continue;
  66. }
  67. // Парсим множественные шифры расценки (через запятую, точку с запятой или перенос строки)
  68. $pricingCodesRaw = $sheet->getCell('L' . $row)->getValue();
  69. $pricingCodeStrings = [];
  70. if (!empty($pricingCodesRaw)) {
  71. $pricingCodeStrings = preg_split('/[,;\n]+/', $pricingCodesRaw);
  72. $pricingCodeStrings = array_map('trim', $pricingCodeStrings);
  73. $pricingCodeStrings = array_filter($pricingCodeStrings);
  74. }
  75. $data = [
  76. 'article' => $article,
  77. 'used_in_maf' => $sheet->getCell('C' . $row)->getValue(),
  78. 'note' => $sheet->getCell('G' . $row)->getValue(),
  79. 'purchase_price' => $this->parsePrice($sheet->getCell('H' . $row)->getValue()),
  80. 'customer_price' => $this->parsePrice($sheet->getCell('I' . $row)->getValue()),
  81. 'expertise_price' => $this->parsePrice($sheet->getCell('J' . $row)->getValue()),
  82. 'tsn_number' => $sheet->getCell('K' . $row)->getValue(),
  83. 'min_stock' => (int)$sheet->getCell('M' . $row)->getValue() ?: 0,
  84. ];
  85. // Создаём или обновляем запчасть (включая удалённые)
  86. $sparePart = SparePart::withTrashed()->where('article', $article)->first();
  87. if ($sparePart) {
  88. // Восстанавливаем, если была удалена
  89. if ($sparePart->trashed()) {
  90. $sparePart->restore();
  91. }
  92. $sparePart->update($data);
  93. $updated++;
  94. } else {
  95. $sparePart = SparePart::create($data);
  96. $imported++;
  97. }
  98. // Синхронизируем связи с pricing_codes
  99. $this->syncPricingCodes($sparePart, $pricingCodeStrings);
  100. }
  101. $this->log("Каталог запчастей: импортировано {$imported}, обновлено {$updated}, пропущено {$skipped}");
  102. }
  103. private function importPricingCodes($sheet): void
  104. {
  105. $this->log("Импорт справочника расшифровок...");
  106. $highestRow = $sheet->getHighestRow();
  107. $imported = 0;
  108. $updated = 0;
  109. $skipped = 0;
  110. // Начинаем со 2-й строки (пропускаем заголовок)
  111. for ($row = 2; $row <= $highestRow; $row++) {
  112. $typeText = trim($sheet->getCell('B' . $row)->getValue());
  113. $code = trim($sheet->getCell('C' . $row)->getValue());
  114. $description = trim($sheet->getCell('D' . $row)->getValue());
  115. // Пропускаем пустые строки
  116. if (empty($code)) {
  117. $skipped++;
  118. continue;
  119. }
  120. // Определяем тип
  121. $type = null;
  122. if (strpos($typeText, 'ТСН') !== false) {
  123. $type = PricingCode::TYPE_TSN_NUMBER;
  124. } elseif (strpos($typeText, 'Шифр') !== false || strpos($typeText, 'расценк') !== false) {
  125. $type = PricingCode::TYPE_PRICING_CODE;
  126. }
  127. if (!$type) {
  128. $this->log("Предупреждение: неизвестный тип '{$typeText}' для кода '{$code}' в строке {$row}");
  129. $skipped++;
  130. continue;
  131. }
  132. // Создаём или обновляем запись
  133. $pricingCode = PricingCode::where('type', $type)->where('code', $code)->first();
  134. if ($pricingCode) {
  135. $pricingCode->update(['description' => $description]);
  136. $updated++;
  137. } else {
  138. PricingCode::create([
  139. 'type' => $type,
  140. 'code' => $code,
  141. 'description' => $description,
  142. ]);
  143. $imported++;
  144. }
  145. }
  146. $this->log("Справочник расшифровок: импортировано {$imported}, обновлено {$updated}, пропущено {$skipped}");
  147. }
  148. /**
  149. * Парсинг цены
  150. * Если цена уже в копейках (целое число > 1000), оставляем как есть
  151. * Если цена в рублях (дробное или < 1000), конвертируем в копейки
  152. */
  153. private function parsePrice($value): ?int
  154. {
  155. if (empty($value)) {
  156. return null;
  157. }
  158. $price = (float)$value;
  159. // Если число целое и большое (скорее всего уже в копейках)
  160. if ($price == (int)$price && $price >= 1000) {
  161. return (int)$price;
  162. }
  163. // Иначе конвертируем из рублей в копейки
  164. return round($price * 100);
  165. }
  166. private function syncPricingCodes(SparePart $sparePart, array $codeStrings): void
  167. {
  168. $pricingCodeIds = [];
  169. foreach ($codeStrings as $code) {
  170. if (empty($code)) {
  171. continue;
  172. }
  173. // Находим или создаём PricingCode
  174. $pricingCode = PricingCode::where('type', PricingCode::TYPE_PRICING_CODE)
  175. ->where('code', $code)
  176. ->first();
  177. if (!$pricingCode) {
  178. $pricingCode = PricingCode::create([
  179. 'type' => PricingCode::TYPE_PRICING_CODE,
  180. 'code' => $code,
  181. 'description' => null,
  182. ]);
  183. }
  184. $pricingCodeIds[] = $pricingCode->id;
  185. }
  186. $sparePart->pricingCodes()->sync($pricingCodeIds);
  187. }
  188. private function log(string $message): void
  189. {
  190. $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
  191. Log::info($message);
  192. }
  193. public function getLogs(): array
  194. {
  195. return $this->logs;
  196. }
  197. }