ExamesImportService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\PartnerAgreementServiceStatusEnum;
  4. use App\Enums\PartnerAgreementServiceTypeEnum;
  5. use App\Imports\ExamesImport;
  6. use App\Models\Category;
  7. use App\Models\PartnerAgreementService;
  8. use Maatwebsite\Excel\Facades\Excel;
  9. class ExamesImportService
  10. {
  11. public function syncFromExcel(
  12. string $filePath,
  13. int $partnerAgreementId
  14. ): array {
  15. $import = new ExamesImport();
  16. Excel::import($import, $filePath);
  17. $rows = $import->rows ?? collect();
  18. $created = 0;
  19. $updated = 0;
  20. /*
  21. * Guarda o último bloco encontrado.
  22. *
  23. * Exemplo:
  24. *
  25. * BLOCO:
  26. * "EXAMES DE IMAGEM"
  27. *
  28. * Os próximos exames receberão essa informação
  29. * na descrição até que outro bloco seja encontrado.
  30. */
  31. $currentBlockDescription = null;
  32. /*
  33. * Localiza a categoria EXAME.
  34. */
  35. $examCategory = Category::whereRaw(
  36. 'LOWER(TRIM(name)) = ?',
  37. ['exame']
  38. )->first();
  39. /*
  40. * A categoria é obrigatória para a importação.
  41. */
  42. if (!$examCategory) {
  43. throw new \RuntimeException(
  44. 'A categoria "exame" não foi encontrada.'
  45. );
  46. }
  47. foreach ($rows as $row) {
  48. /*
  49. * Normaliza as células da linha.
  50. */
  51. $cells = collect($row)
  52. ->map(function ($value) {
  53. return trim((string) $value);
  54. })
  55. ->values();
  56. /*
  57. * Ignora linha completamente vazia.
  58. */
  59. if (
  60. $cells
  61. ->filter(fn ($value) => $value !== '')
  62. ->isEmpty()
  63. ) {
  64. continue;
  65. }
  66. /*
  67. * Estrutura esperada:
  68. *
  69. * Coluna 0 = Código do exame
  70. * Coluna 1 = Nome do exame
  71. * Coluna 2 = Valor / informação adicional
  72. */
  73. $codeExams = $cells->get(0, '');
  74. $name = $cells->get(1, '');
  75. $price = $cells->get(2, '');
  76. /*
  77. * Ignora linhas sem nome.
  78. */
  79. if ($name === '') {
  80. continue;
  81. }
  82. /*
  83. * Normalização para identificação de cabeçalhos.
  84. */
  85. $codeHeader = mb_strtoupper($codeExams);
  86. $nameHeader = mb_strtoupper($name);
  87. $priceHeader = mb_strtoupper($price);
  88. /*
  89. * Identifica cabeçalhos.
  90. */
  91. $isHeader =
  92. str_contains($codeHeader, 'CÓDIGO') ||
  93. str_contains($codeHeader, 'CODIGO') ||
  94. $nameHeader === 'EXAME' ||
  95. $nameHeader === 'NOME' ||
  96. (
  97. str_contains($nameHeader, 'EXAME') &&
  98. (
  99. str_contains($priceHeader, 'VALOR') ||
  100. str_contains($priceHeader, 'PREÇO') ||
  101. str_contains($priceHeader, 'PRECO')
  102. )
  103. );
  104. if ($isHeader) {
  105. continue;
  106. }
  107. /*
  108. * Processa a terceira coluna.
  109. *
  110. * Retorna:
  111. *
  112. * [
  113. * 'price' => valor numérico,
  114. * 'description' => texto adicional
  115. * ]
  116. */
  117. $priceData = $this->parsePrice($price);
  118. $associatePrice = $priceData['price'];
  119. $priceDescription = $priceData['description'];
  120. /*
  121. * Define se realmente existe um preço.
  122. *
  123. * Importante:
  124. * "0" também é considerado um valor válido.
  125. */
  126. $isPrice = $associatePrice !== null;
  127. /*
  128. * REGRA DOS BLOCOS
  129. *
  130. * Se:
  131. *
  132. * - não existe código
  133. * - e a terceira coluna NÃO possui valor
  134. *
  135. * então a linha é um bloco.
  136. *
  137. * Exemplo:
  138. *
  139. * Código: vazio
  140. * Nome: "EXAMES LABORATORIAIS"
  141. * Valor: vazio
  142. *
  143. * Essa linha NÃO será cadastrada.
  144. *
  145. * Ela será utilizada como descrição dos exames
  146. * seguintes até que outro bloco seja encontrado.
  147. */
  148. if ($codeExams === '' && !$isPrice) {
  149. $currentBlockDescription = $name;
  150. continue;
  151. }
  152. /*
  153. * Monta a descrição final do exame.
  154. *
  155. * Primeiro adiciona o bloco.
  156. *
  157. * Depois adiciona qualquer texto encontrado junto
  158. * ao valor.
  159. *
  160. * Exemplo:
  161. *
  162. * Bloco:
  163. * "PACOTE CONTRASTE"
  164. *
  165. * Terceira coluna:
  166. * "150,00 + contraste"
  167. *
  168. * Resultado:
  169. *
  170. * descrição:
  171. * "PACOTE CONTRASTE + contraste"
  172. *
  173. * preço:
  174. * 150.00
  175. */
  176. $descriptionParts = [];
  177. if (
  178. $currentBlockDescription !== null &&
  179. trim($currentBlockDescription) !== ''
  180. ) {
  181. $descriptionParts[] = trim($currentBlockDescription);
  182. }
  183. if (
  184. $priceDescription !== null &&
  185. trim($priceDescription) !== ''
  186. ) {
  187. $descriptionParts[] = trim($priceDescription);
  188. }
  189. $description = !empty($descriptionParts)
  190. ? implode(' ', $descriptionParts)
  191. : null;
  192. /*
  193. * Procura exame existente.
  194. *
  195. * Primeiro pelo código.
  196. */
  197. $service = null;
  198. if ($codeExams !== '') {
  199. $service = PartnerAgreementService::withTrashed()
  200. ->where(
  201. 'partner_agreement_id',
  202. $partnerAgreementId
  203. )
  204. ->where(
  205. 'code_exams',
  206. $codeExams
  207. )
  208. ->first();
  209. }
  210. /*
  211. * Caso não tenha encontrado pelo código,
  212. * procura pelo nome.
  213. */
  214. if (!$service) {
  215. $service = PartnerAgreementService::withTrashed()
  216. ->where(
  217. 'partner_agreement_id',
  218. $partnerAgreementId
  219. )
  220. ->whereRaw(
  221. 'LOWER(TRIM(name)) = LOWER(TRIM(?))',
  222. [$name]
  223. )
  224. ->first();
  225. }
  226. /*
  227. * ==========================================================
  228. * EXAME JÁ EXISTE
  229. * ==========================================================
  230. */
  231. if ($service) {
  232. /*
  233. * Se estava excluído logicamente, restaura.
  234. */
  235. if ($service->trashed()) {
  236. $service->restore();
  237. }
  238. $changed = false;
  239. /*
  240. * Atualiza código.
  241. */
  242. if (
  243. $codeExams !== '' &&
  244. $service->code_exams !== $codeExams
  245. ) {
  246. $service->code_exams = $codeExams;
  247. $changed = true;
  248. }
  249. /*
  250. * Atualiza preço somente quando
  251. * existe valor válido na planilha.
  252. */
  253. if (
  254. $associatePrice !== null &&
  255. (
  256. (float) $service->associate_price !==
  257. (float) $associatePrice
  258. )
  259. ) {
  260. $service->associate_price = $associatePrice;
  261. $changed = true;
  262. }
  263. /*
  264. * Garante categoria EXAME.
  265. */
  266. if (
  267. (int) $service->category_id !==
  268. (int) $examCategory->id
  269. ) {
  270. $service->category_id = $examCategory->id;
  271. $changed = true;
  272. }
  273. /*
  274. * Garante tipo EXAME.
  275. */
  276. if (
  277. $service->type !==
  278. PartnerAgreementServiceTypeEnum::EXAME
  279. ) {
  280. $service->type =
  281. PartnerAgreementServiceTypeEnum::EXAME;
  282. $changed = true;
  283. }
  284. /*
  285. * Garante status ACTIVE.
  286. */
  287. if (
  288. $service->status !==
  289. PartnerAgreementServiceStatusEnum::ACTIVE
  290. ) {
  291. $service->status =
  292. PartnerAgreementServiceStatusEnum::ACTIVE;
  293. $changed = true;
  294. }
  295. /*
  296. * Atualiza descrição.
  297. *
  298. * Aqui usamos a descrição já processada,
  299. * incluindo:
  300. *
  301. * - bloco
  302. * - texto adicional do preço
  303. */
  304. if ($service->description !== $description) {
  305. $service->description = $description;
  306. $changed = true;
  307. }
  308. /*
  309. * Salva somente se houve alteração.
  310. */
  311. if ($changed) {
  312. $service->save();
  313. $updated++;
  314. }
  315. continue;
  316. }
  317. /*
  318. * ==========================================================
  319. * EXAME NÃO EXISTE
  320. * ==========================================================
  321. *
  322. * Cria um novo exame.
  323. *
  324. * Mesmo sem código, se existir preço na terceira coluna,
  325. * a linha será cadastrada.
  326. *
  327. * Isso resolve o caso do:
  328. *
  329. * PACOTE CONTRASTE
  330. *
  331. * quando ele possui valor.
  332. */
  333. PartnerAgreementService::create([
  334. 'partner_agreement_id' => $partnerAgreementId,
  335. 'code_exams' => $codeExams !== ''
  336. ? $codeExams
  337. : null,
  338. 'name' => $name,
  339. 'description' => $description,
  340. 'category_id' => $examCategory->id,
  341. 'associate_price' => $associatePrice,
  342. 'type' =>
  343. PartnerAgreementServiceTypeEnum::EXAME,
  344. 'status' =>
  345. PartnerAgreementServiceStatusEnum::ACTIVE,
  346. ]);
  347. $created++;
  348. }
  349. return [
  350. 'total' => $rows->count(),
  351. 'created' => $created,
  352. 'updated' => $updated,
  353. ];
  354. }
  355. /**
  356. * Processa a terceira coluna.
  357. *
  358. * Aceita exemplos como:
  359. *
  360. * 50
  361. * 50,00
  362. * 50.00
  363. * 1.250,50
  364. * R$ 50,00
  365. * 50,00 + contraste
  366. * R$ 50,00 + contraste
  367. *
  368. * Retorna:
  369. *
  370. * [
  371. * 'price' => ?float,
  372. * 'description' => ?string
  373. * ]
  374. */
  375. private function parsePrice(mixed $value): array
  376. {
  377. /*
  378. * Campo vazio.
  379. */
  380. if ($value === null || trim((string) $value) === '') {
  381. return [
  382. 'price' => null,
  383. 'description' => null,
  384. ];
  385. }
  386. $value = trim((string) $value);
  387. /*
  388. * Remove R$.
  389. */
  390. $value = str_replace(
  391. ['R$', 'r$'],
  392. '',
  393. $value
  394. );
  395. /*
  396. * Normaliza espaços.
  397. */
  398. $value = trim($value);
  399. /*
  400. * Procura o primeiro valor numérico.
  401. *
  402. * Exemplos encontrados:
  403. *
  404. * 50
  405. * 50,00
  406. * 50.00
  407. * 1.250,50
  408. *
  409. * O modificador u permite trabalhar corretamente
  410. * com caracteres UTF-8.
  411. */
  412. $pattern = '/\d+(?:\.\d{3})*(?:,\d{1,2})?|\d+(?:\.\d{1,2})?/u';
  413. if (!preg_match($pattern, $value, $matches)) {
  414. /*
  415. * Não existe valor.
  416. *
  417. * Nesse caso todo o conteúdo é considerado texto.
  418. */
  419. return [
  420. 'price' => null,
  421. 'description' => $value,
  422. ];
  423. }
  424. $numericValue = $matches[0];
  425. /*
  426. * Converte formato brasileiro.
  427. *
  428. * 1.250,50
  429. * =>
  430. * 1250.50
  431. */
  432. if (str_contains($numericValue, ',')) {
  433. $numericValue = str_replace(
  434. '.',
  435. '',
  436. $numericValue
  437. );
  438. $numericValue = str_replace(
  439. ',',
  440. '.',
  441. $numericValue
  442. );
  443. }
  444. /*
  445. * Caso seja algo como:
  446. *
  447. * 50.00
  448. *
  449. * mantém como decimal.
  450. */
  451. $price = is_numeric($numericValue)
  452. ? (float) $numericValue
  453. : null;
  454. /*
  455. * Remove o valor encontrado do texto original.
  456. *
  457. * Exemplo:
  458. *
  459. * "50,00 + contraste"
  460. *
  461. * vira:
  462. *
  463. * "+ contraste"
  464. */
  465. $description = preg_replace(
  466. '/' . preg_quote($matches[0], '/') . '/u',
  467. '',
  468. $value,
  469. 1
  470. );
  471. /*
  472. * Remove espaços extras.
  473. */
  474. $description = trim(
  475. preg_replace(
  476. '/\s+/u',
  477. ' ',
  478. (string) $description
  479. )
  480. );
  481. /*
  482. * Remove caracteres separadores sobrando
  483. * no começo/fim.
  484. *
  485. * Exemplo:
  486. *
  487. * "+ contraste"
  488. *
  489. * vira:
  490. *
  491. * "contraste"
  492. */
  493. $description = trim(
  494. $description,
  495. " \t\n\r\0\x0B+-–—"
  496. );
  497. return [
  498. 'price' => $price,
  499. 'description' => $description !== ''
  500. ? $description
  501. : null,
  502. ];
  503. }
  504. }