, files:int} */ public function importZipFromUrl(string $url): array { @set_time_limit(0); $url = trim($url); if ($url === '' || ! preg_match('/^https?:\/\//i', $url)) { throw new \InvalidArgumentException('URL ARERA non valido.'); } $host = parse_url($url, PHP_URL_HOST); if (! is_string($host) || ! str_ends_with(strtolower($host), 'arera.it')) { throw new \InvalidArgumentException('Per sicurezza sono accettati solo URL del dominio arera.it.'); } $response = Http::timeout(180)->get($url); if (! $response->successful()) { throw new \RuntimeException('Download ARERA fallito: HTTP ' . $response->status()); } $bytes = $response->body(); if (! is_string($bytes) || $bytes === '') { throw new \RuntimeException('Download ARERA vuoto.'); } $zipPath = tempnam(sys_get_temp_dir(), 'arera_zip_'); if (! is_string($zipPath) || $zipPath === '') { throw new \RuntimeException('Impossibile creare file temporaneo ZIP.'); } file_put_contents($zipPath, $bytes); try { return $this->importZipFile($zipPath, basename((string) parse_url($url, PHP_URL_PATH)) ?: 'arera.zip'); } finally { @unlink($zipPath); } } /** * @return array{rows:int, imported:int, updated:int, skipped:int, sectors:int, errors:array, files:int} */ public function importZipFile(string $zipPath, string $sourceFilename = ''): array { @set_time_limit(0); if (! class_exists(ZipArchive::class)) { throw new \RuntimeException('Estensione ZIP non disponibile sul server.'); } $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { throw new \RuntimeException('Impossibile aprire lo ZIP ARERA.'); } $summary = ['rows' => 0, 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'sectors' => 0, 'errors' => [], 'files' => 0]; try { for ($index = 0; $index < $zip->numFiles; $index++) { $name = (string) $zip->getNameIndex($index); $lower = strtolower($name); if (! str_ends_with($lower, '.xlsx') && ! str_ends_with($lower, '.xls')) { continue; } $contents = $zip->getFromIndex($index); if (! is_string($contents) || $contents === '') { $summary['errors'][] = 'File Excel vuoto nello ZIP: ' . $name; continue; } $xlsxPath = tempnam(sys_get_temp_dir(), 'arera_xlsx_'); if (! is_string($xlsxPath) || $xlsxPath === '') { $summary['errors'][] = 'Impossibile creare temporaneo per ' . $name; continue; } file_put_contents($xlsxPath, $contents); try { $result = $this->importXlsx($xlsxPath, $sourceFilename !== '' ? ($sourceFilename . '::' . basename($name)) : basename($name)); $summary['rows'] += $result['rows']; $summary['imported'] += $result['imported']; $summary['updated'] += $result['updated']; $summary['skipped'] += $result['skipped']; $summary['sectors'] += $result['sectors']; $summary['errors'] = array_slice(array_merge($summary['errors'], $result['errors']), 0, 40); $summary['files']++; } finally { @unlink($xlsxPath); } } } finally { $zip->close(); } if ($summary['files'] === 0) { throw new \RuntimeException('Nessun file Excel trovato nello ZIP ARERA.'); } return $summary; } /** * @return array{rows:int, imported:int, updated:int, skipped:int, sectors:int, errors:array} */ public function importXlsx(string $path, string $sourceFilename = ''): array { @set_time_limit(0); $spreadsheet = IOFactory::load($path); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(null, true, true, true); if ($rows === []) { return ['rows' => 0, 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'sectors' => 0, 'errors' => ['File vuoto']]; } $headerRow = array_shift($rows) ?: []; $headers = []; foreach ($headerRow as $column => $label) { $normalized = $this->normalizeHeader((string) $label); if ($normalized !== '') { $headers[$column] = $normalized; } } $imported = 0; $updated = 0; $skipped = 0; $sectorIds = []; $errors = []; foreach ($rows as $index => $row) { $data = []; foreach ($headers as $column => $key) { $data[$key] = trim((string) ($row[$column] ?? '')); } $societa = $this->cleanText($data['societa'] ?? $data['ragione_sociale'] ?? ''); $piva = $this->normalizeVat($data['partita_iva'] ?? ''); if ($societa === '' && $piva === '') { $skipped++; continue; } if ($piva === '') { $errors[] = 'Riga ' . ((int) $index + 1) . ': P.IVA mancante per ' . ($societa ?: 'operatore senza nome'); $skipped++; continue; } $natura = $this->cleanText($data['natura_giuridica'] ?? ''); $naturaId = null; if ($natura !== '') { $naturaId = (int) AreraNaturaGiuridica::query()->firstOrCreate(['nome' => $natura])->id; } $settori = $this->splitMultiValue($data['settori_attivita'] ?? ''); if ($settori === []) { $sourceLower = mb_strtolower($sourceFilename); if (str_contains($sourceLower, 'export-gas-vend')) { $settori = ['Gas vendita']; } elseif (str_contains($sourceLower, 'export-mercato-vend')) { $settori = ['Mercato vendita']; } } $settoreSync = []; foreach ($settori as $settore) { $settoreModel = AreraSettoreAttivita::query()->firstOrCreate( ['slug' => $this->sectorSlug($settore)], ['nome' => $settore] ); $settoreSync[] = (int) $settoreModel->id; $sectorIds[(int) $settoreModel->id] = true; } $sedeLegaleRaw = (string) ($data['sede_legale'] ?? ''); if ($sedeLegaleRaw === '' && ! empty($data['indirizzo'])) { $comuneSedeLegale = $this->cleanText($data['comune_sede_legale'] ?? ''); $sedeLegaleRaw = $this->cleanText((string) $data['indirizzo'] . ($comuneSedeLegale !== '' ? (' - ' . $comuneSedeLegale) : '')); } $sedeLegale = $this->parseAddress($sedeLegaleRaw); $sedeOperativa = $this->parseAddress($data['sede_operativa'] ?? ''); $payload = [ 'societa' => $societa, 'id_soggetto' => $this->cleanText($data['id_soggetto'] ?? ''), 'natura_giuridica_id' => $naturaId, 'partita_iva' => $this->cleanText($data['partita_iva'] ?? ''), 'partita_iva_normalizzata' => $piva, 'gruppo_societario' => $this->nullIfEmpty($data['gruppo_societario'] ?? ''), 'sito_internet' => $this->normalizeWebsite($data['sito_internet'] ?? ''), 'sede_legale_raw' => $this->nullIfEmpty($sedeLegaleRaw), 'sede_legale_indirizzo' => $sedeLegale['indirizzo'], 'sede_legale_cap' => $sedeLegale['cap'], 'sede_legale_citta' => $sedeLegale['citta'], 'sede_legale_provincia' => $sedeLegale['provincia'], 'sede_operativa_raw' => $this->nullIfEmpty($data['sede_operativa'] ?? ''), 'sede_operativa_indirizzo' => $sedeOperativa['indirizzo'], 'sede_operativa_cap' => $sedeOperativa['cap'], 'sede_operativa_citta' => $sedeOperativa['citta'], 'sede_operativa_provincia' => $sedeOperativa['provincia'], 'contatti_cliente' => $this->splitMultiValue($data['contatti_cliente'] ?? ''), 'contatti_cliente2' => $this->splitMultiValue($data['contatti_clienti2'] ?? ''), 'settori_raw' => $settori, 'extra_search_tokens' => array_values(array_unique(array_filter(array_merge( $settori, $this->splitMultiValue($data['contatti_cliente'] ?? ''), $this->splitMultiValue($data['contatti_clienti2'] ?? ''), [$natura, $data['gruppo_societario'] ?? ''] )))), 'source_filename' => $sourceFilename !== '' ? basename($sourceFilename) : null, 'source_imported_at' => now(), ]; $existing = AreraOperatore::query()->where('partita_iva_normalizzata', $piva)->first(); $operatore = AreraOperatore::query()->updateOrCreate( ['partita_iva_normalizzata' => $piva], $payload ); if ($settoreSync !== []) { $operatore->settori()->sync($settoreSync); } $existing ? $updated++ : $imported++; } return [ 'rows' => count($rows), 'imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'sectors' => count($sectorIds), 'errors' => array_slice($errors, 0, 20), ]; } private function normalizeHeader(string $value): string { $value = Str::of($value)->lower()->ascii()->replaceMatches('/[^a-z0-9]+/', '_')->trim('_')->toString(); return match ($value) { 'societa', 'societa_' => 'societa', 'ragione_sociale' => 'ragione_sociale', 'id_soggetto' => 'id_soggetto', 'natura_giuridica' => 'natura_giuridica', 'partita_iva' => 'partita_iva', 'gruppo_societario' => 'gruppo_societario', 'sito_internet' => 'sito_internet', 'sito_web' => 'sito_internet', 'sede_legale' => 'sede_legale', 'comune_sede_legale' => 'comune_sede_legale', 'indirizzo' => 'indirizzo', 'sede_operativa' => 'sede_operativa', 'settori_attivita' => 'settori_attivita', 'contatti_cliente' => 'contatti_cliente', 'contatti_clienti2', 'contatti_cliente2' => 'contatti_clienti2', default => $value, }; } private function cleanText(string $value): string { return trim(preg_replace('/\s+/', ' ', $value) ?? $value); } private function nullIfEmpty(string $value): ?string { $value = $this->cleanText($value); return $value !== '' ? $value : null; } private function normalizeVat(string $value): string { return preg_replace('/\D+/', '', $value) ?? ''; } /** @return list */ private function splitMultiValue(string $value): array { return array_values(array_unique(array_filter(array_map( fn(string $part): string => $this->cleanText($part), preg_split('/;|\R/', $value) ?: [] )))); } private function normalizeWebsite(string $value): ?string { $value = $this->cleanText($value); if ($value === '') { return null; } return preg_match('/^https?:\/\//i', $value) ? $value : 'https://' . $value; } private function sectorSlug(string $value): string { $base = Str::slug($value); if ($base === '') { $base = 'settore'; } $hash = substr(sha1($value), 0, 10); $prefix = Str::limit($base, 175, ''); return $prefix . '-' . $hash; } /** @return array{indirizzo:?string, cap:?string, citta:?string, provincia:?string} */ private function parseAddress(string $value): array { $value = $this->cleanText($value); $out = ['indirizzo' => null, 'cap' => null, 'citta' => null, 'provincia' => null]; if ($value === '') { return $out; } $parts = array_map('trim', explode(' - ', $value, 2)); $out['indirizzo'] = $parts[0] !== '' ? $parts[0] : null; $tail = $parts[1] ?? ''; if ($tail !== '' && preg_match('/^(\d{5})\s+(.+?)(?:\s+\(([^)]+)\))?$/u', $tail, $matches)) { $out['cap'] = $matches[1]; $out['citta'] = trim($matches[2]); $out['provincia'] = isset($matches[3]) ? trim($matches[3]) : null; } return $out; } }