3 Commit-ok 6bd6f6f4bb ... 3634674614

Szerző SHA1 Üzenet Dátum
  Gustavo Zanatta 3634674614 feat: :sparkles: feat (queue-jobs import) adicionado queue e jobs nos imports 1 hete
  Gustavo Zanatta b2eab4f330 feat: :sparkles: feat (importacoes) criada importacoes do sistema 1 hete
  Gustavo Zanatta 33c8c8030b feat: :sparkles: feat (log acessos) criada tabela de log de acessos 2 hete

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

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Jobs\SyncAssociadosJob;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class AssociadoImportController extends Controller
+{
+    public function importAndSync(Request $request): 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());
+
+        SyncAssociadosJob::dispatch($filePath, $importId)->onQueue('imports');
+
+        return $this->successResponse(
+            payload: ['import_id' => $importId],
+            code: 202,
+        );
+    }
+}

+ 20 - 0
app/Http/Controllers/ImportStatusController.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Cache;
+
+class ImportStatusController extends Controller
+{
+    public function status(string $importId): JsonResponse
+    {
+        $entry = Cache::get($importId);
+
+        if ($entry === null) {
+            return $this->errorResponse('Import not found or expired.', 404);
+        }
+
+        return $this->successResponse(payload: $entry);
+    }
+}

+ 52 - 0
app/Http/Controllers/PartnerImportController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Jobs\SyncConveniosMedicosJob;
+use App\Jobs\SyncParceirosJob;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class PartnerImportController extends Controller
+{
+    public function importParceiros(Request $request): 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());
+
+        SyncParceirosJob::dispatch($filePath, $importId)->onQueue('imports');
+
+        return $this->successResponse(
+            payload: ['import_id' => $importId],
+            code: 202,
+        );
+    }
+
+    public function importConveniosMedicos(Request $request): 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());
+
+        SyncConveniosMedicosJob::dispatch($filePath, $importId)->onQueue('imports');
+
+        return $this->successResponse(
+            payload: ['import_id' => $importId],
+            code: 202,
+        );
+    }
+}

+ 27 - 0
app/Http/Controllers/UserAccessLogController.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Resources\UserAccessLogResource;
+use App\Services\UserAccessLogService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class UserAccessLogController extends Controller
+{
+    public function __construct(protected UserAccessLogService $service) {}
+
+    public function index(Request $request): JsonResponse
+    {
+        $filters = $request->only(['type', 'date_from', 'date_to']);
+        $perPage = min((int) $request->get('per_page', 10), 100);
+        $paginator = $this->service->getAllPaginated($filters, $perPage);
+
+        return $this->successResponse(payload: [
+            'data'  => UserAccessLogResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem() ?? 0,
+            'to'    => $paginator->lastItem() ?? 0,
+        ]);
+    }
+}

+ 20 - 0
app/Http/Resources/UserAccessLogResource.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class UserAccessLogResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'          => $this->id,
+            'user_id'     => $this->user_id,
+            'user_name'   => $this->user?->name,
+            'user_type'   => $this->user?->type?->value,
+            'accessed_at' => $this->accessed_at?->toIso8601String(),
+        ];
+    }
+}

+ 6 - 5
app/Http/Resources/UserResource.php

@@ -33,11 +33,12 @@ class UserResource extends JsonResource
             'position'       => $this->whenLoaded('position', fn() => ['id' => $this->position->id, 'name' => $this->position->name]),
             'sector_id'      => $this->sector_id,
             'sector'         => $this->whenLoaded('sector', fn() => ['id' => $this->sector->id, 'name' => $this->sector->name]),
-            'photo_url'      => $this->photo_path
-                                    ? Storage::disk('s3')->temporaryUrl($this->photo_path, now()->addHours(24))
-                                    : null,
-            'created_at'     => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
-            'updated_at'     => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
+            'photo_url'                  => $this->photo_path
+                                                ? Storage::disk('s3')->temporaryUrl($this->photo_path, now()->addHours(24))
+                                                : null,
+            'unread_notifications_count' => (int) ($this->unread_notifications_count ?? 0),
+            'created_at'                 => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
+            'updated_at'                 => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
         ];
     }
 

+ 24 - 0
app/Imports/AssociadoImport.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Imports;
+
+use Illuminate\Support\Collection;
+use Maatwebsite\Excel\Concerns\ToCollection;
+
+class AssociadoImport implements ToCollection
+{
+    public Collection $rows;
+
+    public function collection(Collection $rows): void
+    {
+        $this->rows = $rows->skip(1)->values()->filter(function ($row) {
+            return !empty(trim((string) ($row[0] ?? '')))
+                && !empty(trim((string) ($row[1] ?? '')));
+        })->map(function ($row) {
+            return [
+                'registration' => trim((string) $row[0]),
+                'name'         => trim((string) $row[1]),
+            ];
+        })->values();
+    }
+}

+ 16 - 0
app/Imports/ParceirosImport.php

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

+ 59 - 0
app/Jobs/BaseImportJob.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Throwable;
+
+abstract class BaseImportJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 1;
+
+    public int $timeout = 600;
+
+    public function __construct(
+        protected string $filePath,
+        protected string $importId,
+    ) {}
+
+    public function handle(): void
+    {
+        Cache::put($this->importId, ['status' => 'processing'], now()->addDay());
+
+        try {
+            $stats = $this->runSync($this->filePath);
+
+            Cache::put($this->importId, [
+                'status' => 'completed',
+                'stats'  => $stats,
+            ], now()->addDay());
+        } finally {
+            $this->cleanupFile();
+        }
+    }
+
+    public function failed(Throwable $e): void
+    {
+        Cache::put($this->importId, [
+            'status'  => 'failed',
+            'message' => $e->getMessage(),
+        ], now()->addDay());
+
+        $this->cleanupFile();
+    }
+
+    abstract protected function runSync(string $filePath): array;
+
+    private function cleanupFile(): void
+    {
+        Storage::delete($this->filePath);
+    }
+}

+ 13 - 0
app/Jobs/SyncAssociadosJob.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\AssociadoImportService;
+
+class SyncAssociadosJob extends BaseImportJob
+{
+    protected function runSync(string $filePath): array
+    {
+        return app(AssociadoImportService::class)->syncFromExcel($filePath);
+    }
+}

+ 13 - 0
app/Jobs/SyncConveniosMedicosJob.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\ConveniosMedicosImportService;
+
+class SyncConveniosMedicosJob extends BaseImportJob
+{
+    protected function runSync(string $filePath): array
+    {
+        return app(ConveniosMedicosImportService::class)->syncFromExcel($filePath);
+    }
+}

+ 13 - 0
app/Jobs/SyncParceirosJob.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\ParceirosImportService;
+
+class SyncParceirosJob extends BaseImportJob
+{
+    protected function runSync(string $filePath): array
+    {
+        return app(ParceirosImportService::class)->syncFromExcel($filePath);
+    }
+}

+ 5 - 0
app/Models/User.php

@@ -161,6 +161,11 @@ class User extends Authenticatable
         return $this->hasMany(StoreItemInterest::class);
     }
 
+    public function accessLogs(): HasMany
+    {
+        return $this->hasMany(UserAccessLog::class);
+    }
+
     /**
      * @return BelongsToMany
      */

+ 34 - 0
app/Models/UserAccessLog.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $user_id
+ * @property \Illuminate\Support\Carbon $accessed_at
+ * @property-read \App\Models\User $user
+ * @mixin \Eloquent
+ */
+class UserAccessLog extends Model
+{
+    public $timestamps = false;
+
+    protected $table = 'users_access_logs';
+
+    protected $fillable = [
+        'user_id',
+        'accessed_at',
+    ];
+
+    protected $casts = [
+        'accessed_at' => 'datetime',
+    ];
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 104 - 0
app/Services/AssociadoImportService.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\UserStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Imports\AssociadoImport;
+use App\Models\User;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use Maatwebsite\Excel\Facades\Excel;
+
+class AssociadoImportService
+{
+    public function syncFromExcel(string $filePath): array
+    {
+        $import = new AssociadoImport();
+        Excel::import($import, $filePath);
+        $rows = $import->rows ?? collect();
+
+        $today = Carbon::today()->toDateString();
+
+        $existing = User::where('type', UserTypeEnum::ASSOCIADO)
+            ->whereNotNull('registration')
+            ->get()
+            ->keyBy('registration');
+
+        $importedRegistrations = [];
+        $seenRegistrations     = [];
+        $created    = 0;
+        $updated    = 0;
+
+        foreach ($rows as $row) {
+            $registration = $row['registration'];
+            $name         = $row['name'];
+
+            if (isset($seenRegistrations[$registration])) {
+                continue;
+            }
+            $seenRegistrations[$registration] = true;
+
+            $importedRegistrations[] = $registration;
+
+            if ($existing->has($registration)) {
+                $user = $existing->get($registration);
+                $changed = false;
+
+                if ($user->name !== $name) {
+                    $user->name = $name;
+                    $changed = true;
+                }
+
+                if ($user->status !== UserStatusEnum::ACTIVE) {
+                    $user->status      = UserStatusEnum::ACTIVE;
+                    $user->excluded_at = null;
+                    $changed = true;
+                }
+
+                if ($changed) {
+                    $user->save();
+                    $updated++;
+                }
+            } else {
+                $firstName = $this->extractFirstName($name);
+                $email     = "{$firstName}_{$registration}@serprati.com";
+                $password  = "{$firstName}2026";
+
+                User::create([
+                    'name'           => $name,
+                    'email'          => $email,
+                    'password'       => Hash::make($password),
+                    'type'           => UserTypeEnum::ASSOCIADO,
+                    'status'         => UserStatusEnum::ACTIVE,
+                    'registration'   => $registration,
+                    'admission_date' => $today,
+                ]);
+
+                $created++;
+            }
+        }
+
+        $inactivated = User::where('type', UserTypeEnum::ASSOCIADO)
+            ->where('status', UserStatusEnum::ACTIVE)
+            ->whereNotNull('registration')
+            ->whereNotIn('registration', $importedRegistrations)
+            ->update([
+                'status'      => UserStatusEnum::INACTIVE,
+                'excluded_at' => now(),
+            ]);
+
+        return [
+            'created'     => $created,
+            'updated'     => $updated,
+            'inactivated' => $inactivated,
+        ];
+    }
+
+    private function extractFirstName(string $fullName): string
+    {
+        $firstName = explode(' ', trim($fullName))[0];
+        return strtolower(Str::ascii($firstName));
+    }
+}

+ 9 - 0
app/Services/AuthService.php

@@ -4,6 +4,8 @@ namespace App\Services;
 
 use App\Models\User;
 use App\Models\PersonalAccessToken;
+use App\Models\UserAccessLog;
+use App\Enums\UserTypeEnum;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
@@ -24,6 +26,13 @@ class AuthService
             return ["error" => "wrong_type"];
         }
 
+        if (in_array($user->type, [UserTypeEnum::ASSOCIADO, UserTypeEnum::PARCEIRO])) {
+            UserAccessLog::create([
+                'user_id'     => $user->id,
+                'accessed_at' => now(),
+            ]);
+        }
+
         $deviceId = Str::uuid()->toString();
 
         $accessToken = $user->createAccessToken($deviceId);

+ 211 - 0
app/Services/ConveniosMedicosImportService.php

@@ -0,0 +1,211 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementStatusEnum;
+use App\Imports\ParceirosImport;
+use App\Models\Category;
+use App\Models\PartnerAgreement;
+use App\Models\PartnerAgreementService;
+use App\Traits\ImportsPartners;
+use Maatwebsite\Excel\Facades\Excel;
+
+class ConveniosMedicosImportService
+{
+    use ImportsPartners;
+
+    private const MODE_CONSULTAS   = 'consultas';
+    private const MODE_LABORATORIO = 'laboratorio';
+
+    public function syncFromExcel(string $filePath): array
+    {
+        $import = new ParceirosImport();
+        Excel::import($import, $filePath);
+        $rows = $import->rows ?? collect();
+
+        $mode         = null;
+        $clinicGroups = [];
+        $labRows      = [];
+
+        foreach ($rows as $row) {
+            $col0 = $this->cell($row[0] ?? '');
+            $col1 = $this->cell($row[1] ?? '');
+            $col2 = $this->cell($row[2] ?? '');
+
+            if ($col0 === '' && $col1 === '' && $col2 === '') {
+                continue;
+            }
+
+            $upper0 = mb_strtoupper($col0);
+            $upper1 = mb_strtoupper($col1);
+
+            if ($upper0 === 'ESPECIALIDADE') {
+                $mode = self::MODE_CONSULTAS;
+                continue;
+            }
+
+            if (str_starts_with($upper0, 'LABORAT') && $upper1 === 'TELEFONE') {
+                $mode = self::MODE_LABORATORIO;
+                continue;
+            }
+
+            if ($mode === null) {
+                continue;
+            }
+
+            if ($mode === self::MODE_CONSULTAS) {
+                $specialty  = $col0;
+                $clinicName = $col1;
+                $doctor     = $col2;
+                $phone      = $this->cell($row[3] ?? '');
+                $address    = $this->cell($row[4] ?? '');
+                $priceRaw   = $this->cell($row[5] ?? '');
+
+                if ($clinicName === '' || $specialty === '') {
+                    continue;
+                }
+
+                if (!isset($clinicGroups[$clinicName])) {
+                    $clinicGroups[$clinicName] = [
+                        'phone'    => null,
+                        'address'  => null,
+                        'services' => [],
+                    ];
+                }
+
+                if ($phone && !$clinicGroups[$clinicName]['phone']) {
+                    $clinicGroups[$clinicName]['phone'] = $phone;
+                }
+                if ($address && !$clinicGroups[$clinicName]['address']) {
+                    $clinicGroups[$clinicName]['address'] = $address;
+                }
+
+                $serviceName = $doctor
+                    ? "{$specialty} - {$doctor}"
+                    : $specialty;
+
+                $clinicGroups[$clinicName]['services'][] = [
+                    'name'  => $serviceName,
+                    'price' => $this->parsePrice($priceRaw),
+                ];
+            } elseif ($mode === self::MODE_LABORATORIO) {
+                if ($col0 === '') {
+                    continue;
+                }
+
+                $labRows[] = [
+                    'name'    => $col0,
+                    'phone'   => $col1 ?: null,
+                    'address' => $col2 ?: null,
+                ];
+            }
+        }
+
+        $convMedicaCategory = Category::firstOrCreate(
+            ['name' => 'Convênios Médicos', 'type' => 'partner'],
+            ['active' => true]
+        );
+
+        $labCategory = Category::firstOrCreate(
+            ['name' => 'Laboratório', 'type' => 'partner'],
+            ['active' => true]
+        );
+
+        $stats = [
+            'partners_created'    => 0,
+            'partners_updated'    => 0,
+            'partners_inactivated'=> 0,
+            'services_created'    => 0,
+            'services_updated'    => 0,
+            'services_inactivated'=> 0,
+        ];
+
+        $importedPartnerIds = [];
+
+        foreach ($clinicGroups as $clinicName => $clinicData) {
+            [$partner, $isNew] = $this->upsertPartner($clinicName, [
+                'phone'       => $clinicData['phone'],
+                'address'     => $clinicData['address'],
+                'category_id' => $convMedicaCategory->id,
+            ]);
+
+            $importedPartnerIds[] = $partner->id;
+            $isNew ? $stats['partners_created']++ : $stats['partners_updated']++;
+
+            $processedServiceIds = [];
+
+            foreach ($clinicData['services'] as $svcData) {
+                $service = PartnerAgreementService::withTrashed()
+                    ->where('partner_agreement_id', $partner->id)
+                    ->whereRaw('LOWER(TRIM(name)) = LOWER(TRIM(?))', [$svcData['name']])
+                    ->first();
+
+                if ($service) {
+                    if ($service->trashed()) {
+                        $service->restore();
+                    }
+
+                    $changed = false;
+
+                    if ($svcData['price'] !== null && (float) $service->associate_price !== (float) $svcData['price']) {
+                        $service->associate_price = $svcData['price'];
+                        $changed = true;
+                    }
+
+                    if ($service->status !== PartnerAgreementServiceStatusEnum::ACTIVE) {
+                        $service->status = PartnerAgreementServiceStatusEnum::ACTIVE;
+                        $changed = true;
+                    }
+
+                    if ($changed) {
+                        $service->save();
+                        $stats['services_updated']++;
+                    }
+
+                    $processedServiceIds[] = $service->id;
+                } else {
+                    $created = PartnerAgreementService::create([
+                        'partner_agreement_id' => $partner->id,
+                        'name'                 => $svcData['name'],
+                        'associate_price'      => $svcData['price'],
+                        'status'               => PartnerAgreementServiceStatusEnum::ACTIVE,
+                    ]);
+
+                    $processedServiceIds[] = $created->id;
+                    $stats['services_created']++;
+                }
+            }
+
+            if (!empty($processedServiceIds)) {
+                $stats['services_inactivated'] += PartnerAgreementService::where('partner_agreement_id', $partner->id)
+                    ->where('status', PartnerAgreementServiceStatusEnum::ACTIVE)
+                    ->whereNotIn('id', $processedServiceIds)
+                    ->update(['status' => PartnerAgreementServiceStatusEnum::INACTIVE]);
+            }
+        }
+
+        foreach ($labRows as $labData) {
+            [$partner, $isNew] = $this->upsertPartner($labData['name'], [
+                'phone'       => $labData['phone'],
+                'address'     => $labData['address'],
+                'category_id' => $labCategory->id,
+            ]);
+
+            $importedPartnerIds[] = $partner->id;
+            $isNew ? $stats['partners_created']++ : $stats['partners_updated']++;
+        }
+
+        if (!empty($importedPartnerIds)) {
+            $stats['partners_inactivated'] = PartnerAgreement::whereIn('category_id', [
+                $convMedicaCategory->id,
+                $labCategory->id,
+            ])
+                ->where('status', PartnerAgreementStatusEnum::ACTIVE)
+                ->whereNotIn('id', $importedPartnerIds)
+                ->update(['status' => PartnerAgreementStatusEnum::INACTIVE]);
+        }
+
+        return $stats;
+    }
+}

+ 98 - 0
app/Services/ParceirosImportService.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\PartnerAgreementStatusEnum;
+use App\Imports\ParceirosImport;
+use App\Models\Category;
+use App\Models\PartnerAgreement;
+use App\Traits\ImportsPartners;
+use Maatwebsite\Excel\Facades\Excel;
+
+class ParceirosImportService
+{
+    use ImportsPartners;
+
+    public function syncFromExcel(string $filePath): array
+    {
+        $import = new ParceirosImport();
+        Excel::import($import, $filePath);
+        $rows = $import->rows ?? collect();
+
+        $created             = 0;
+        $updated             = 0;
+        $importedPartnerIds  = [];
+        $processedCategoryIds = [];
+        $currentCategoryId   = null;
+
+        foreach ($rows as $row) {
+            $col0 = $this->cell($row[0] ?? '');
+            $col1 = $this->cell($row[1] ?? '');
+            $col2 = $this->cell($row[2] ?? '');
+
+            if ($col0 === '' && $col1 === '' && $col2 === '') {
+                continue;
+            }
+
+            $upper0 = mb_strtoupper($col0);
+
+            if (str_contains($upper0, 'LISTA DE PARCEIROS')) {
+                continue;
+            }
+
+            if ($upper0 === 'EMPRESA') {
+                if ($col1 === '') {
+                    continue;
+                }
+
+                $category = $this->findOrCreateCategory($col1);
+                $currentCategoryId = $category->id;
+                $processedCategoryIds[$currentCategoryId] = true;
+                continue;
+            }
+
+            if ($currentCategoryId === null || $col0 === '') {
+                continue;
+            }
+
+            [$partner, $isNew] = $this->upsertPartner($col0, [
+                'description' => $col1 ?: null,
+                'phone'       => $col2 ?: null,
+                'category_id' => $currentCategoryId,
+            ]);
+
+            $importedPartnerIds[] = $partner->id;
+            $isNew ? $created++ : $updated++;
+        }
+
+        $inactivated = 0;
+        if (!empty($processedCategoryIds) && !empty($importedPartnerIds)) {
+            $inactivated = PartnerAgreement::whereIn('category_id', array_keys($processedCategoryIds))
+                ->where('status', PartnerAgreementStatusEnum::ACTIVE)
+                ->whereNotIn('id', $importedPartnerIds)
+                ->update(['status' => PartnerAgreementStatusEnum::INACTIVE]);
+        }
+
+        return [
+            'created'     => $created,
+            'updated'     => $updated,
+            'inactivated' => $inactivated,
+        ];
+    }
+
+    private function findOrCreateCategory(string $rawName): Category
+    {
+        $name = mb_convert_case(mb_strtolower($rawName), MB_CASE_TITLE, 'UTF-8');
+
+        $category = Category::whereRaw(
+            'LOWER(TRIM(name)) = LOWER(TRIM(?)) AND type = ?',
+            [$name, 'partner']
+        )->first();
+
+        return $category ?? Category::create([
+            'name'   => $name,
+            'type'   => 'partner',
+            'active' => true,
+        ]);
+    }
+}

+ 29 - 0
app/Services/UserAccessLogService.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\UserAccessLog;
+use Illuminate\Pagination\LengthAwarePaginator;
+
+class UserAccessLogService
+{
+    public function getAllPaginated(array $filters = [], int $perPage = 10): LengthAwarePaginator
+    {
+        $query = UserAccessLog::with(['user:id,name,type'])
+            ->orderBy('accessed_at', 'desc');
+
+        if (!empty($filters['type'])) {
+            $query->whereHas('user', fn($q) => $q->where('type', $filters['type']));
+        }
+
+        if (!empty($filters['date_from'])) {
+            $query->whereDate('accessed_at', '>=', $filters['date_from']);
+        }
+
+        if (!empty($filters['date_to'])) {
+            $query->whereDate('accessed_at', '<=', $filters['date_to']);
+        }
+
+        return $query->paginate($perPage);
+    }
+}

+ 3 - 2
app/Services/UserService.php

@@ -13,7 +13,8 @@ class UserService
     public function authUser(): ?User
     {
         $user = Auth::user();
-        return $user?->load(['position', 'sector']);
+        return $user?->load(['position', 'sector'])
+                     ->loadCount(['notificationSends as unread_notifications_count' => fn($q) => $q->where('read', false)]);
     }
 
     public function getAll(): Collection
@@ -23,7 +24,7 @@ class UserService
 
     public function getAllPaginated(array $filters = [], int $perPage = 10): \Illuminate\Pagination\LengthAwarePaginator
     {
-        $query = User::with(['position', 'sector'])->orderBy('created_at', 'desc');
+        $query = User::with(['position', 'sector'])->orderBy('name', 'asc');
 
         if (!empty($filters['type'])) {
             $query->where('type', $filters['type']);

+ 85 - 0
app/Traits/ImportsPartners.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Traits;
+
+use App\Enums\PartnerAgreementStatusEnum;
+use App\Enums\UserStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\PartnerAgreement;
+use App\Models\User;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+
+trait ImportsPartners
+{
+    private function upsertPartner(string $companyName, array $data): array
+    {
+        $partner = PartnerAgreement::withTrashed()
+            ->whereRaw('LOWER(TRIM(company_name)) = LOWER(TRIM(?))', [$companyName])
+            ->first();
+
+        if ($partner) {
+            if ($partner->trashed()) {
+                $partner->restore();
+            }
+
+            $partner->update(array_merge($data, [
+                'status' => PartnerAgreementStatusEnum::ACTIVE,
+            ]));
+
+            return [$partner->fresh(), false];
+        }
+
+        $user = $this->findOrCreateUser($companyName);
+
+        $partner = PartnerAgreement::create(array_merge($data, [
+            'company_name' => $companyName,
+            'user_id'      => $user->id,
+            'status'       => PartnerAgreementStatusEnum::ACTIVE,
+        ]));
+
+        return [$partner, true];
+    }
+
+    private function findOrCreateUser(string $companyName): User
+    {
+        $slug    = Str::slug($companyName);
+        $email   = "{$slug}@serprati.com";
+        $counter = 2;
+
+        while (User::where('email', $email)->exists()) {
+            $email = "{$slug}{$counter}@serprati.com";
+            $counter++;
+        }
+
+        return User::create([
+            'name'     => $companyName,
+            'email'    => $email,
+            'password' => Hash::make('Serprati2026'),
+            'type'     => UserTypeEnum::PARCEIRO,
+            'status'   => UserStatusEnum::ACTIVE,
+        ]);
+    }
+
+    private function parsePrice(?string $value): ?float
+    {
+        if (empty($value)) {
+            return null;
+        }
+
+        $cleaned = preg_replace('/[^\d,]/', '', (string) $value);
+        $cleaned = str_replace(',', '.', $cleaned);
+
+        return is_numeric($cleaned) ? (float) $cleaned : null;
+    }
+
+    private function cell(mixed $value): string
+    {
+        return trim(preg_replace('/\s+/', ' ', (string) ($value ?? '')));
+    }
+
+    private function firstLine(mixed $value): string
+    {
+        return trim(explode("\n", (string) ($value ?? ''))[0]);
+    }
+}

+ 25 - 0
database/migrations/2026_05_25_000001_create_users_access_logs_table.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('users_access_logs', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+            $table->timestamp('accessed_at');
+
+            $table->index('user_id');
+            $table->index('accessed_at');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('users_access_logs');
+    }
+};

+ 24 - 0
database/migrations/2026_06_03_095127_expand_phone_columns_in_partner_agreements.php

@@ -0,0 +1,24 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('partner_agreements', function (Blueprint $table) {
+            $table->string('phone', 100)->nullable()->change();
+            $table->string('whatsapp', 100)->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('partner_agreements', function (Blueprint $table) {
+            $table->string('phone', 20)->nullable()->change();
+            $table->string('whatsapp', 20)->nullable()->change();
+        });
+    }
+};

+ 8 - 0
routes/authRoutes/associado_import.php

@@ -0,0 +1,8 @@
+<?php
+
+use App\Http\Controllers\AssociadoImportController;
+use Illuminate\Support\Facades\Route;
+
+Route::controller(AssociadoImportController::class)->prefix('user')->group(function () {
+    Route::post('/import/associados', 'importAndSync')->middleware('permission:config.user,add');
+});

+ 6 - 0
routes/authRoutes/import_status.php

@@ -0,0 +1,6 @@
+<?php
+
+use App\Http\Controllers\ImportStatusController;
+use Illuminate\Support\Facades\Route;
+
+Route::get('/import-status/{importId}', [ImportStatusController::class, 'status']);

+ 12 - 0
routes/authRoutes/parceiro_import.php

@@ -0,0 +1,12 @@
+<?php
+
+use App\Http\Controllers\PartnerImportController;
+use Illuminate\Support\Facades\Route;
+
+Route::controller(PartnerImportController::class)->prefix('partner-agreement')->group(function () {
+    Route::post('/import/parceiros', 'importParceiros')
+        ->middleware('permission:parceiro.convenio,add');
+
+    Route::post('/import/convenios-medicos', 'importConveniosMedicos')
+        ->middleware('permission:parceiro.convenio,add');
+});

+ 8 - 0
routes/authRoutes/user_access_log.php

@@ -0,0 +1,8 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\UserAccessLogController;
+
+Route::controller(UserAccessLogController::class)->prefix('user-access-log')->group(function () {
+    Route::get('/', 'index')->middleware('permission:config.user,view');
+});