소스 검색

feat: :sparkles: feat(importação exames) Foi adcionado todo o fluxo de importação

Foi adcionado todo o fluxo de importação no backend, contendo as informações necessarias para o processamento dos dados da planilha em excel

fase:dev | origin:escopo
kayo henrique 2 주 전
부모
커밋
05e30aee27

+ 36 - 0
app/Http/Controllers/AssociadoImportController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Jobs\SyncAfastamentosJob;
 use App\Jobs\SyncAssociadosJob;
+use App\Jobs\SyncExamesJob;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Cache;
@@ -49,4 +50,39 @@ class AssociadoImportController extends Controller
             code: 202,
         );
     }
+
+    public function import(Request $request, int $partnerAgreementId): JsonResponse
+    {
+        $request->validate([
+            'file' => 'required|file|mimes:xlsx|max:10240',
+        ]);
+
+        $importId = Str::uuid()->toString();
+
+        $filePath = Storage::putFileAs(
+            'imports',
+            $request->file('file'),
+            $importId . '.xlsx'
+        );
+
+        Cache::put(
+            $importId,
+            ['status' => 'pending'],
+            now()->addDay()
+        );
+
+        SyncExamesJob::dispatch(
+            $filePath,
+            $importId,
+            auth()->id(),
+            $partnerAgreementId
+        );
+
+        return $this->successResponse(
+            payload: [
+                'import_id' => $importId,
+            ],
+            code: 202,
+        );
+    }
 }

+ 17 - 0
app/Imports/ExamesImport.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Imports;
+
+use Illuminate\Support\Collection;
+use Maatwebsite\Excel\Concerns\ToCollection;
+use Maatwebsite\Excel\Concerns\WithCalculatedFormulas;
+
+class ExamesImport implements ToCollection, WithCalculatedFormulas
+{
+    public Collection $rows;
+
+    public function collection(Collection $rows): void
+    {
+        $this->rows = $rows;
+    }
+}

+ 34 - 0
app/Jobs/SyncExamesJob.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\ExamesImportService;
+
+class SyncExamesJob extends BaseImportJob
+{
+    public function __construct(
+        string $filePath,
+        string $importId,
+        int $userId,
+        protected int $partnerAgreementId,
+    ) {
+        parent::__construct(
+            $filePath,
+            $importId,
+            $userId,
+        );
+    }
+
+    protected function getImportType(): string
+    {
+        return 'exame';
+    }
+
+    protected function runSync(string $filePath): array
+    {
+        return app(ExamesImportService::class)->syncFromExcel(
+            $filePath,
+            $this->partnerAgreementId,
+        );
+    }
+}

+ 224 - 0
app/Services/ExamesImportService.php

@@ -0,0 +1,224 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
+use App\Imports\ExamesImport;
+use App\Models\PartnerAgreementService;
+use Maatwebsite\Excel\Facades\Excel;
+
+class ExamesImportService
+{
+    public function syncFromExcel(
+        string $filePath,
+        int $partnerAgreementId
+    ): array {
+        $import = new ExamesImport();
+
+        Excel::import($import, $filePath);
+
+        $rows = $import->rows ?? collect();
+
+        $created = 0;
+        $updated = 0;
+
+        foreach ($rows as $row) {
+            /*
+             * Normaliza as células da linha.
+             */
+            $cells = collect($row)
+                ->map(fn ($value) => trim((string) $value))
+                ->values();
+
+            /*
+             * Ignora linha completamente vazia.
+             */
+            if ($cells->filter(fn ($value) => $value !== '')->isEmpty()) {
+                continue;
+            }
+
+            /*
+             * Estrutura esperada do Excel:
+             *
+             * Coluna 0 = Nome do exame
+             * Coluna 1 = Preço
+             */
+            $name = $cells->get(0, '');
+            $price = $cells->get(1, '');
+
+            /*
+             * Ignora linhas sem nome.
+             */
+            if ($name === '') {
+                continue;
+            }
+
+            /*
+             * Trata cabeçalhos do Excel.
+             *
+             * Exemplos:
+             * EXAME | VALOR
+             * NOME | PREÇO
+             * NOME DO EXAME | PREÇO
+             */
+            $nameHeader = mb_strtoupper($name);
+            $priceHeader = mb_strtoupper($price);
+
+            $isHeader =
+                $nameHeader === 'EXAME' ||
+                $nameHeader === 'NOME' ||
+                (
+                    str_contains($nameHeader, 'EXAME') &&
+                    (
+                        str_contains($priceHeader, 'VALOR') ||
+                        str_contains($priceHeader, 'PREÇO') ||
+                        str_contains($priceHeader, 'PRECO')
+                    )
+                );
+
+            if ($isHeader) {
+                continue;
+            }
+
+            /*
+             * Converte o preço para o formato numérico.
+             */
+            $associatePrice = $this->parsePrice($price);
+
+            /*
+             * Procura um exame já cadastrado para esse parceiro.
+             *
+             * O nome é comparado ignorando:
+             * - maiúsculas/minúsculas
+             * - espaços no início/fim
+             */
+            $service = PartnerAgreementService::withTrashed()
+                ->where('partner_agreement_id', $partnerAgreementId)
+                ->whereRaw(
+                    'LOWER(TRIM(name)) = LOWER(TRIM(?))',
+                    [$name]
+                )
+                ->first();
+
+            /*
+             * EXAME JÁ EXISTE
+             */
+            if ($service) {
+                /*
+                 * Se estava excluído logicamente, restaura.
+                 */
+                if ($service->trashed()) {
+                    $service->restore();
+                }
+
+                $changed = false;
+
+                /*
+                 * Atualiza o preço somente quando
+                 * o Excel trouxe um preço válido.
+                 */
+                if (
+                    $associatePrice !== null &&
+                    (float) $service->associate_price !== (float) $associatePrice
+                ) {
+                    $service->associate_price = $associatePrice;
+                    $changed = true;
+                }
+
+                /*
+                 * Garante que o serviço seja do tipo EXAME.
+                 */
+                if (
+                    $service->type !== PartnerAgreementServiceTypeEnum::EXAME
+                ) {
+                    $service->type = PartnerAgreementServiceTypeEnum::EXAME;
+                    $changed = true;
+                }
+
+                /*
+                 * Garante que o exame fique ativo.
+                 */
+                if (
+                    $service->status !== PartnerAgreementServiceStatusEnum::ACTIVE
+                ) {
+                    $service->status = PartnerAgreementServiceStatusEnum::ACTIVE;
+                    $changed = true;
+                }
+
+                if ($changed) {
+                    $service->save();
+                    $updated++;
+                }
+
+                continue;
+            }
+
+            /*
+             * EXAME NÃO EXISTE
+             *
+             * Cria um novo serviço vinculado
+             * ao parceiro informado.
+             */
+            PartnerAgreementService::create([
+                'partner_agreement_id' => $partnerAgreementId,
+                'name'                 => $name,
+                'associate_price'      => $associatePrice,
+                'type'                 => PartnerAgreementServiceTypeEnum::EXAME,
+                'status'               => PartnerAgreementServiceStatusEnum::ACTIVE,
+            ]);
+
+            $created++;
+        }
+
+        return [
+            'total'   => $rows->count(),
+            'created' => $created,
+            'updated' => $updated,
+        ];
+    }
+
+    /**
+     * Converte diferentes formatos de preço
+     * para float.
+     */
+    private function parsePrice(mixed $value): ?float
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        $value = trim((string) $value);
+
+        /*
+         * Remove moeda e espaços.
+         *
+         * Exemplos:
+         * R$ 50,00 -> 50,00
+         * R$50,00  -> 50,00
+         */
+        $value = str_replace(['R$', ' '], '', $value);
+
+        /*
+         * Formato brasileiro:
+         *
+         * 50,00
+         * 1.250,50
+         */
+        if (str_contains($value, ',')) {
+            $value = str_replace('.', '', $value);
+            $value = str_replace(',', '.', $value);
+        }
+
+        /*
+         * Formato decimal:
+         *
+         * 50
+         * 50.00
+         * 1250.50
+         */
+        return is_numeric($value)
+            ? (float) $value
+            : null;
+    }
+}

+ 3 - 0
routes/authRoutes/partner_agreement_service.php

@@ -1,6 +1,7 @@
 <?php
 
 use App\Http\Controllers\PartnerAgreementServiceController;
+use App\Http\Controllers\AssociadoImportController;
 use Illuminate\Support\Facades\Route;
 
 Route::controller(PartnerAgreementServiceController::class)->prefix('partner-agreement-service')->group(function () {
@@ -16,4 +17,6 @@ Route::controller(PartnerAgreementServiceController::class)->prefix('partner-agr
 
     Route::post('/{id}/media', 'uploadMedia')->middleware('permission:parceiro.servico,edit');
     Route::delete('/{id}/media/{mediaId}', 'deleteMedia')->middleware('permission:parceiro.servico,edit');
+
+    Route::post('/partner/{partnerAgreementId}/import', [AssociadoImportController::class, 'import'])->middleware('permission:parceiro.servico,add');
 });