Quellcode durchsuchen

Merge branch 'feature-serprati-kay-importação-exames' of Softpar/sfp_api_laravel_serprati into development

zntt vor 1 Woche
Ursprung
Commit
5173c4fbd8

+ 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,
+        );
+    }
 }

+ 8 - 2
app/Http/Requests/AppointmentRequest.php

@@ -6,6 +6,7 @@ use App\Enums\AppointmentStatusEnum;
 use App\Enums\PartnerAgreementServiceTypeEnum;
 use App\Enums\PartnerAgreementTypeEnum;
 use App\Enums\UserDependentStatusEnum;
+use App\Enums\UserTypeEnum;
 use App\Models\Appointment;
 use App\Rules\ExactAdvanceDays;
 use Illuminate\Foundation\Http\FormRequest;
@@ -27,7 +28,7 @@ class AppointmentRequest extends FormRequest
             ->whereNull('deleted_at')
             ->when(
                 $this->filled('partner_agreement_id'),
-                fn ($rule) => $rule->where('partner_agreement_id', $this->input('partner_agreement_id')),
+                fn($rule) => $rule->where('partner_agreement_id', $this->input('partner_agreement_id')),
             );
 
         $dependentExists = Rule::exists('user_dependents', 'id')
@@ -51,7 +52,12 @@ class AppointmentRequest extends FormRequest
             $rules['partner_agreement_id']         = ['required', 'integer', $partnerExists];
             $rules['partner_agreement_service_id'] = ['required', 'integer', $serviceExists];
 
-            $rules['date'] = ['required', 'date', new ExactAdvanceDays(self::CONSULTA_ADVANCE_DAYS)];
+            $rules['date'] = ['required', 'date'];
+
+            if (in_array(Auth::user()?->type, [UserTypeEnum::ASSOCIADO, UserTypeEnum::PARCEIRO,], true)) {
+                $rules['date'][] = new ExactAdvanceDays(self::CONSULTA_ADVANCE_DAYS);
+            }
+
             $rules['time'] = 'required|date_format:H:i';
         }
 

+ 1 - 0
app/Http/Requests/PartnerAgreementServiceRequest.php

@@ -14,6 +14,7 @@ class PartnerAgreementServiceRequest extends FormRequest
         $rules = [
             'partner_agreement_id' => 'sometimes|integer|exists:partner_agreements,id',
             'service_number'       => 'sometimes|nullable|string|max:50',
+            'code_exams'           => 'sometimes|nullable|string|max:50',
             'name'                 => 'sometimes|string|max:255',
             'description'          => 'sometimes|nullable|string',
             'type'                 => ['sometimes', 'nullable', Rule::enum(PartnerAgreementServiceTypeEnum::class)],

+ 1 - 0
app/Http/Resources/PartnerAgreementServiceResource.php

@@ -16,6 +16,7 @@ class PartnerAgreementServiceResource extends JsonResource
             'partner_agreement_id' => $this->partner_agreement_id,
             'partner_agreement'    => $this->whenLoaded('partnerAgreement', fn() => new PartnerAgreementResource($this->partnerAgreement)),
             'service_number'       => $this->service_number,
+            'code_exams'     => $this->code_exams,
             'name'                 => $this->name,
             'description'          => $this->description,
             'type'                 => $this->type,

+ 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,
+        );
+    }
+}

+ 573 - 0
app/Services/ExamesImportService.php

@@ -0,0 +1,573 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
+use App\Imports\ExamesImport;
+use App\Models\Category;
+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;
+
+        /*
+         * Guarda o último bloco encontrado.
+         *
+         * Exemplo:
+         *
+         * BLOCO:
+         * "EXAMES DE IMAGEM"
+         *
+         * Os próximos exames receberão essa informação
+         * na descrição até que outro bloco seja encontrado.
+         */
+        $currentBlockDescription = null;
+
+        /*
+         * Localiza a categoria EXAME.
+         */
+        $examCategory = Category::whereRaw(
+            'LOWER(TRIM(name)) = ?',
+            ['exame']
+        )->first();
+
+        /*
+         * A categoria é obrigatória para a importação.
+         */
+        if (!$examCategory) {
+            throw new \RuntimeException(
+                'A categoria "exame" não foi encontrada.'
+            );
+        }
+
+        foreach ($rows as $row) {
+            /*
+             * Normaliza as células da linha.
+             */
+            $cells = collect($row)
+                ->map(function ($value) {
+                    return trim((string) $value);
+                })
+                ->values();
+
+            /*
+             * Ignora linha completamente vazia.
+             */
+            if (
+                $cells
+                    ->filter(fn ($value) => $value !== '')
+                    ->isEmpty()
+            ) {
+                continue;
+            }
+
+            /*
+             * Estrutura esperada:
+             *
+             * Coluna 0 = Código do exame
+             * Coluna 1 = Nome do exame
+             * Coluna 2 = Valor / informação adicional
+             */
+            $codeExams = $cells->get(0, '');
+            $name = $cells->get(1, '');
+            $price = $cells->get(2, '');
+
+            /*
+             * Ignora linhas sem nome.
+             */
+            if ($name === '') {
+                continue;
+            }
+
+            /*
+             * Normalização para identificação de cabeçalhos.
+             */
+            $codeHeader = mb_strtoupper($codeExams);
+            $nameHeader = mb_strtoupper($name);
+            $priceHeader = mb_strtoupper($price);
+
+            /*
+             * Identifica cabeçalhos.
+             */
+            $isHeader =
+                str_contains($codeHeader, 'CÓDIGO') ||
+                str_contains($codeHeader, 'CODIGO') ||
+                $nameHeader === 'EXAME' ||
+                $nameHeader === 'NOME' ||
+                (
+                    str_contains($nameHeader, 'EXAME') &&
+                    (
+                        str_contains($priceHeader, 'VALOR') ||
+                        str_contains($priceHeader, 'PREÇO') ||
+                        str_contains($priceHeader, 'PRECO')
+                    )
+                );
+
+            if ($isHeader) {
+                continue;
+            }
+
+            /*
+             * Processa a terceira coluna.
+             *
+             * Retorna:
+             *
+             * [
+             *     'price' => valor numérico,
+             *     'description' => texto adicional
+             * ]
+             */
+            $priceData = $this->parsePrice($price);
+
+            $associatePrice = $priceData['price'];
+            $priceDescription = $priceData['description'];
+
+            /*
+             * Define se realmente existe um preço.
+             *
+             * Importante:
+             * "0" também é considerado um valor válido.
+             */
+            $isPrice = $associatePrice !== null;
+
+            /*
+             * REGRA DOS BLOCOS
+             *
+             * Se:
+             *
+             * - não existe código
+             * - e a terceira coluna NÃO possui valor
+             *
+             * então a linha é um bloco.
+             *
+             * Exemplo:
+             *
+             * Código: vazio
+             * Nome: "EXAMES LABORATORIAIS"
+             * Valor: vazio
+             *
+             * Essa linha NÃO será cadastrada.
+             *
+             * Ela será utilizada como descrição dos exames
+             * seguintes até que outro bloco seja encontrado.
+             */
+            if ($codeExams === '' && !$isPrice) {
+                $currentBlockDescription = $name;
+
+                continue;
+            }
+
+            /*
+             * Monta a descrição final do exame.
+             *
+             * Primeiro adiciona o bloco.
+             *
+             * Depois adiciona qualquer texto encontrado junto
+             * ao valor.
+             *
+             * Exemplo:
+             *
+             * Bloco:
+             * "PACOTE CONTRASTE"
+             *
+             * Terceira coluna:
+             * "150,00 + contraste"
+             *
+             * Resultado:
+             *
+             * descrição:
+             * "PACOTE CONTRASTE + contraste"
+             *
+             * preço:
+             * 150.00
+             */
+            $descriptionParts = [];
+
+            if (
+                $currentBlockDescription !== null &&
+                trim($currentBlockDescription) !== ''
+            ) {
+                $descriptionParts[] = trim($currentBlockDescription);
+            }
+
+            if (
+                $priceDescription !== null &&
+                trim($priceDescription) !== ''
+            ) {
+                $descriptionParts[] = trim($priceDescription);
+            }
+
+            $description = !empty($descriptionParts)
+                ? implode(' ', $descriptionParts)
+                : null;
+
+            /*
+             * Procura exame existente.
+             *
+             * Primeiro pelo código.
+             */
+            $service = null;
+
+            if ($codeExams !== '') {
+                $service = PartnerAgreementService::withTrashed()
+                    ->where(
+                        'partner_agreement_id',
+                        $partnerAgreementId
+                    )
+                    ->where(
+                        'code_exams',
+                        $codeExams
+                    )
+                    ->first();
+            }
+
+            /*
+             * Caso não tenha encontrado pelo código,
+             * procura pelo nome.
+             */
+            if (!$service) {
+                $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 código.
+                 */
+                if (
+                    $codeExams !== '' &&
+                    $service->code_exams !== $codeExams
+                ) {
+                    $service->code_exams = $codeExams;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Atualiza preço somente quando
+                 * existe valor válido na planilha.
+                 */
+                if (
+                    $associatePrice !== null &&
+                    (
+                        (float) $service->associate_price !==
+                        (float) $associatePrice
+                    )
+                ) {
+                    $service->associate_price = $associatePrice;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Garante categoria EXAME.
+                 */
+                if (
+                    (int) $service->category_id !==
+                    (int) $examCategory->id
+                ) {
+                    $service->category_id = $examCategory->id;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Garante tipo EXAME.
+                 */
+                if (
+                    $service->type !==
+                    PartnerAgreementServiceTypeEnum::EXAME
+                ) {
+                    $service->type =
+                        PartnerAgreementServiceTypeEnum::EXAME;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Garante status ACTIVE.
+                 */
+                if (
+                    $service->status !==
+                    PartnerAgreementServiceStatusEnum::ACTIVE
+                ) {
+                    $service->status =
+                        PartnerAgreementServiceStatusEnum::ACTIVE;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Atualiza descrição.
+                 *
+                 * Aqui usamos a descrição já processada,
+                 * incluindo:
+                 *
+                 * - bloco
+                 * - texto adicional do preço
+                 */
+                if ($service->description !== $description) {
+                    $service->description = $description;
+
+                    $changed = true;
+                }
+
+                /*
+                 * Salva somente se houve alteração.
+                 */
+                if ($changed) {
+                    $service->save();
+
+                    $updated++;
+                }
+
+                continue;
+            }
+
+            /*
+             * ==========================================================
+             * EXAME NÃO EXISTE
+             * ==========================================================
+             *
+             * Cria um novo exame.
+             *
+             * Mesmo sem código, se existir preço na terceira coluna,
+             * a linha será cadastrada.
+             *
+             * Isso resolve o caso do:
+             *
+             * PACOTE CONTRASTE
+             *
+             * quando ele possui valor.
+             */
+            PartnerAgreementService::create([
+                'partner_agreement_id' => $partnerAgreementId,
+
+                'code_exams' => $codeExams !== ''
+                    ? $codeExams
+                    : null,
+
+                'name' => $name,
+
+                'description' => $description,
+
+                'category_id' => $examCategory->id,
+
+                'associate_price' => $associatePrice,
+
+                'type' =>
+                    PartnerAgreementServiceTypeEnum::EXAME,
+
+                'status' =>
+                    PartnerAgreementServiceStatusEnum::ACTIVE,
+            ]);
+
+            $created++;
+        }
+
+        return [
+            'total' => $rows->count(),
+            'created' => $created,
+            'updated' => $updated,
+        ];
+    }
+
+    /**
+     * Processa a terceira coluna.
+     *
+     * Aceita exemplos como:
+     *
+     * 50
+     * 50,00
+     * 50.00
+     * 1.250,50
+     * R$ 50,00
+     * 50,00 + contraste
+     * R$ 50,00 + contraste
+     *
+     * Retorna:
+     *
+     * [
+     *     'price' => ?float,
+     *     'description' => ?string
+     * ]
+     */
+    private function parsePrice(mixed $value): array
+    {
+        /*
+         * Campo vazio.
+         */
+        if ($value === null || trim((string) $value) === '') {
+            return [
+                'price' => null,
+                'description' => null,
+            ];
+        }
+
+        $value = trim((string) $value);
+
+        /*
+         * Remove R$.
+         */
+        $value = str_replace(
+            ['R$', 'r$'],
+            '',
+            $value
+        );
+
+        /*
+         * Normaliza espaços.
+         */
+        $value = trim($value);
+
+        /*
+         * Procura o primeiro valor numérico.
+         *
+         * Exemplos encontrados:
+         *
+         * 50
+         * 50,00
+         * 50.00
+         * 1.250,50
+         *
+         * O modificador u permite trabalhar corretamente
+         * com caracteres UTF-8.
+         */
+        $pattern = '/\d+(?:\.\d{3})*(?:,\d{1,2})?|\d+(?:\.\d{1,2})?/u';
+
+        if (!preg_match($pattern, $value, $matches)) {
+            /*
+             * Não existe valor.
+             *
+             * Nesse caso todo o conteúdo é considerado texto.
+             */
+            return [
+                'price' => null,
+                'description' => $value,
+            ];
+        }
+
+        $numericValue = $matches[0];
+
+        /*
+         * Converte formato brasileiro.
+         *
+         * 1.250,50
+         * =>
+         * 1250.50
+         */
+        if (str_contains($numericValue, ',')) {
+            $numericValue = str_replace(
+                '.',
+                '',
+                $numericValue
+            );
+
+            $numericValue = str_replace(
+                ',',
+                '.',
+                $numericValue
+            );
+        }
+
+        /*
+         * Caso seja algo como:
+         *
+         * 50.00
+         *
+         * mantém como decimal.
+         */
+        $price = is_numeric($numericValue)
+            ? (float) $numericValue
+            : null;
+
+        /*
+         * Remove o valor encontrado do texto original.
+         *
+         * Exemplo:
+         *
+         * "50,00 + contraste"
+         *
+         * vira:
+         *
+         * "+ contraste"
+         */
+        $description = preg_replace(
+            '/' . preg_quote($matches[0], '/') . '/u',
+            '',
+            $value,
+            1
+        );
+
+        /*
+         * Remove espaços extras.
+         */
+        $description = trim(
+            preg_replace(
+                '/\s+/u',
+                ' ',
+                (string) $description
+            )
+        );
+
+        /*
+         * Remove caracteres separadores sobrando
+         * no começo/fim.
+         *
+         * Exemplo:
+         *
+         * "+ contraste"
+         *
+         * vira:
+         *
+         * "contraste"
+         */
+        $description = trim(
+            $description,
+            " \t\n\r\0\x0B+-–—"
+        );
+
+        return [
+            'price' => $price,
+            'description' => $description !== ''
+                ? $description
+                : null,
+        ];
+    }
+}

+ 28 - 0
database/migrations/2026_08_31_105856_add_code_exams_to_partner_agreement_services_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('partner_agreement_services', function (Blueprint $table) {
+            $table->string('code_exams')->nullable()->after('name');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('partner_agreement_services', function (Blueprint $table) {
+            $table->dropColumn('code_exams');
+        });
+    }
+};

+ 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');
 });