resolveCodiceFiscaleSoggetto($stabile); if ($cf === '') { return ['status' => 'error', 'message' => 'Codice fiscale dello stabile non presente']; } if (! $amministratore->fe_cassetto_enabled) { $adminLabel = trim((string) ($amministratore->codice_amministratore ?? '')); if ($adminLabel === '') { $adminLabel = 'ID-' . (int) $amministratore->id; } return [ 'status' => 'error', 'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')', ]; } if ($amministratore->fe_cassetto_expires_at) { try { $expires = $amministratore->fe_cassetto_expires_at; if ($expires instanceof \DateTimeInterface) { $today = new \DateTimeImmutable('today'); $exp = new \DateTimeImmutable($expires->format('Y-m-d')); if ($exp < $today) { return [ 'status' => 'error', 'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')', ]; } } } catch (\Throwable) { // ignore } } $cfg = FeCassettoServiceConfig::query()->first(); if (! $cfg && ! $amministratore->fe_cassetto_base_url && ! $amministratore->fe_cassetto_username) { return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)']; } $baseUrl = trim((string) ($amministratore->fe_cassetto_base_url ?: ($cfg->base_url ?? ''))); if ($baseUrl === '') { $baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php'; } $passwordScript = trim((string) ($amministratore->fe_cassetto_password_script ?: ($cfg?->password_script ?? ''))); $username = trim((string) ($amministratore->fe_cassetto_username ?: ($cfg?->username ?? ''))); $password = trim((string) ($amministratore->fe_cassetto_password ?: ($cfg?->password ?? ''))); $pin = trim((string) ($amministratore->fe_cassetto_pin ?: ($cfg?->pin ?? ''))); // passwordScript: nel servizio esterno viene usato come "token"/licenza. if ($passwordScript === '') { return ['status' => 'error', 'message' => 'passwordScript mancante (licenza). Compilarlo nel Super Admin: Servizio Cassetto Fiscale.']; } if ($username === '' || $password === '' || $pin === '') { return ['status' => 'error', 'message' => 'Credenziali servizio incomplete (username/password/pin). Compilarle nel Super Admin: Servizio Cassetto Fiscale.']; } $dalStr = $dal->format('d-m-Y'); $alStr = $al->format('d-m-Y'); $requestHash = hash('sha256', implode('|', [ 'cf=' . $cf, 'dal=' . $dalStr, 'al=' . $alStr, 'metadati=' . ($metadati ? '1' : '0'), 'stabile=' . (int) $stabile->id, 'amm=' . (int) $amministratore->id, ])); $existingOk = DB::table('fe_cassetto_download_logs') ->where('amministratore_id', (int) $amministratore->id) ->where('stabile_id', (int) $stabile->id) ->where('request_hash', $requestHash) ->where('status', 'ok') ->orderByDesc('id') ->first(); $allowSkip = ! (bool) ($opts['force_update'] ?? false); $allowSkip = $allowSkip && ! (bool) ($opts['no_skip'] ?? false); if ($existingOk && $allowSkip) { return [ 'status' => 'skipped', 'message' => 'Download giĆ  eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')', 'log_id' => (int) $existingOk->id, ]; } $logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([ 'amministratore_id' => (int) $amministratore->id, 'stabile_id' => (int) $stabile->id, 'codice_fiscale_soggetto' => $cf, 'dal' => $dal->format('Y-m-d'), 'al' => $al->format('Y-m-d'), 'metadati' => $metadati ? 1 : 0, 'request_hash' => $requestHash, 'status' => 'started', 'created_at' => now(), 'updated_at' => now(), ]); try { $query = [ 'passwordScript' => $passwordScript, 'usernameEntratel' => $username, 'passwordEntratel' => $password, 'pinEntratel' => $pin, 'cf' => $cf, 'dal' => $dalStr, 'al' => $alStr, ]; if ($metadati) { $query['metadati'] = 1; } if (! empty($opts['idFiscCedente'])) { $query['idFiscCedente'] = (string) $opts['idFiscCedente']; } $resp = Http::timeout(180)->get($baseUrl, $query); if (! $resp->successful()) { $preview = ''; try { $body = $resp->body(); if (is_string($body) && $body !== '') { $preview = trim(substr($body, 0, 500)); $preview = preg_replace('/\s+/', ' ', $preview) ?? $preview; } } catch (\Throwable) { // ignore } $msg = 'HTTP ' . $resp->status(); $msg .= ' | url=' . $baseUrl; if ($preview !== '') { $msg .= ' | preview: ' . $preview; } DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'status' => 'error', 'message' => $msg, 'updated_at' => now(), ]); return ['status' => 'error', 'message' => $msg, 'log_id' => $logId]; } $bytes = $resp->body(); if (! is_string($bytes) || $bytes === '') { throw new \RuntimeException('Risposta vuota dal servizio'); } // ZIP signature: PK if (! str_starts_with($bytes, "PK")) { $preview = trim(substr($bytes, 0, 500)); throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview); } $zipSha = hash('sha256', $bytes); $ym = now()->format('Y-m'); $adminBase = ArchivioPaths::adminBase($amministratore); $stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id); $stabileBase = $adminBase . '/stabili/' . $stabileFolder; $basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym; $uuid = (string) Str::uuid(); $zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip'; Storage::disk('local')->put($zipPath, $bytes); DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'zip_sha256' => $zipSha, 'zip_bytes' => strlen($bytes), 'status' => 'downloaded', 'updated_at' => now(), ]); $tmpDir = $stabileBase . '/temp/fe-cassetto/' . $uuid; Storage::disk('local')->makeDirectory($tmpDir); $zipFsPath = Storage::disk('local')->path($zipPath); $tmpFsPath = Storage::disk('local')->path($tmpDir); if (! class_exists(\ZipArchive::class)) { throw new \RuntimeException('Estensione ZIP (ZipArchive) non disponibile sul server'); } $zip = new \ZipArchive(); if ($zip->open($zipFsPath) !== true) { throw new \RuntimeException('Impossibile aprire lo ZIP scaricato'); } $zip->extractTo($tmpFsPath); $zip->close(); $riepilogo = $this->findRiepilogoJson($tmpFsPath); $riepilogoStoredPath = null; $riepilogoWarnings = []; if ($riepilogo !== null) { $riepilogoFilename = $riepilogo['filename']; $riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename; Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']); $riepilogoCf = trim((string) ($riepilogo['data']['codiceFiscale'] ?? $riepilogo['data']['cf'] ?? '')); if ($riepilogoCf !== '' && strtoupper($riepilogoCf) !== strtoupper($cf)) { $riepilogoWarnings[] = 'CF riepilogo (' . strtoupper($riepilogoCf) . ') diverso da CF richiesta (' . strtoupper($cf) . ')'; } try { $this->upsertAdeFromRiepilogo($amministratore, $stabile, $riepilogo['data'], (string) $riepilogoStoredPath); } catch (\Throwable) { // ignore: non blocchiamo lo scarico/import FE se la lista AdE non si aggiorna. } } $files = $this->collectFeFiles($tmpFsPath); $filesTotal = count($files); $importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser()); $extractor = app(P7mExtractor::class); $imported = 0; $duplicates = 0; $errors = 0; $errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m'); Storage::disk('local')->makeDirectory($errorsDir); $errorsLogPath = $errorsDir . '/' . $uuid . '-download-errors.jsonl'; // Salva i file scaricati in una cartella stabile XML dentro lo stabile. $xmlBase = $stabileBase . '/XML'; Storage::disk('local')->makeDirectory($xmlBase); foreach ($files as $filePath) { $filename = basename($filePath); $lower = strtolower($filename); $bytes = @file_get_contents($filePath); if (! is_string($bytes) || trim($bytes) === '') { $errors++; continue; } $unique = (string) Str::uuid(); $storedP7mPath = null; $storedXmlPath = null; $isP7m = str_ends_with($lower, '.p7m'); $xml = $isP7m ? null : $bytes; if ($isP7m) { $storedP7mPath = $xmlBase . '/' . $unique . '-' . $filename; Storage::disk('local')->put($storedP7mPath, $bytes); try { $xml = $extractor->extractXmlFromP7m(Storage::disk('local')->path($storedP7mPath)); } catch (\Throwable) { // fallback: lascia andare a errore $xml = ''; } } if (! is_string($xml) || trim($xml) === '') { $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ 'type' => 'empty_xml', 'filename' => $filename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, ], JSON_UNESCAPED_SLASHES)); } catch (\Throwable) { } continue; } $xmlFilename = $isP7m ? (pathinfo($filename, PATHINFO_FILENAME) . (str_ends_with(strtolower(pathinfo($filename, PATHINFO_FILENAME)), '.xml') ? '' : '.xml')) : $filename; $storedXmlPath = $xmlBase . '/' . $unique . '-' . $xmlFilename; Storage::disk('local')->put($storedXmlPath, $xml); try { $result = $importer->importXml($xml, 0, $xmlFilename, [ 'xml_path' => $storedXmlPath, 'p7m_path' => $storedP7mPath, 'nome_file_p7m' => $isP7m ? $filename : null, 'force_stabile_id' => (int) $stabile->id, 'allow_fallback_stabile' => false, 'force_update' => (bool) ($opts['force_update'] ?? false), 'auto_repair_duplicate' => true, 'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'), 'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true), 'user_id' => $userId, ]); $status = (string) ($result['status'] ?? 'error'); if ($status === 'imported') { $imported++; } elseif ($status === 'duplicate') { $duplicates++; } else { $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ 'type' => 'import_error', 'filename' => $filename, 'xml_filename' => $xmlFilename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, 'result' => $result, ], JSON_UNESCAPED_SLASHES)); } catch (\Throwable) { } } } catch (\Throwable $e) { $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ 'type' => 'exception', 'filename' => $filename, 'xml_filename' => $xmlFilename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, 'message' => $e->getMessage(), ], JSON_UNESCAPED_SLASHES)); } catch (\Throwable) { } } } $messageParts = []; $messageParts[] = 'zip=' . $zipPath; if ($riepilogo !== null) { $tot = $riepilogo['data']['totaleFatture'] ?? null; if (is_int($tot) || (is_string($tot) && ctype_digit($tot))) { $messageParts[] = 'riepilogo_totaleFatture=' . (int) $tot; } if (is_string($riepilogoStoredPath) && $riepilogoStoredPath !== '') { $messageParts[] = 'riepilogo_json=' . $riepilogoStoredPath; } } foreach ($riepilogoWarnings as $w) { $messageParts[] = 'WARN: ' . $w; } DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'files_total' => $filesTotal, 'imported' => $imported, 'duplicates' => $duplicates, 'errors' => $errors, 'status' => 'ok', 'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null, 'updated_at' => now(), ]); return [ 'status' => 'ok', 'log_id' => $logId, 'files_total' => $filesTotal, 'imported' => $imported, 'duplicates' => $duplicates, 'errors' => $errors, ]; } catch (\Throwable $e) { DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'status' => 'error', 'message' => $e->getMessage(), 'updated_at' => now(), ]); return ['status' => 'error', 'message' => $e->getMessage(), 'log_id' => $logId]; } } /** * @return list */ private function collectFeFiles(string $rootDir): array { $out = []; $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS) ); foreach ($it as $file) { /** @var \SplFileInfo $file */ if (! $file->isFile()) { continue; } $name = $file->getFilename(); $lower = strtolower($name); // File di supporto del portale (non sono fatture elettroniche). if (str_starts_with($lower, 'informazioni_associate_') && str_ends_with($lower, '.xml')) { continue; } // Importiamo XML e P7M (P7M verranno estratti a XML). if (str_ends_with($lower, '.xml') || str_ends_with($lower, '.p7m')) { $out[] = $file->getPathname(); } } sort($out); return $out; } private function resolveCodiceFiscaleSoggetto(Stabile $stabile): string { $cf = trim((string) ($stabile->codice_fiscale ?? '')); if ($cf === '') { try { $stabile->loadMissing('rubrica'); $cf = trim((string) ($stabile->rubrica?->codice_fiscale ?? '')); } catch (\Throwable) { // ignore } } $cf = strtoupper(trim($cf)); return $cf; } /** * Cerca un file JSON riepilogo (con chiave totaleFatture) all'interno dello ZIP estratto. * * @return array{filename: string, contents: string, data: array}|null */ private function findRiepilogoJson(string $rootDir): ?array { $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS) ); foreach ($it as $file) { /** @var \SplFileInfo $file */ if (! $file->isFile()) { continue; } $name = $file->getFilename(); $lower = strtolower($name); if (! str_ends_with($lower, '.json')) { continue; } $contents = @file_get_contents($file->getPathname()); if (! is_string($contents) || trim($contents) === '') { continue; } $decoded = json_decode($contents, true); if (! is_array($decoded)) { continue; } if (! array_key_exists('totaleFatture', $decoded)) { continue; } return [ 'filename' => $name, 'contents' => $contents, 'data' => $decoded, ]; } return null; } private function upsertAdeFromRiepilogo(Amministratore $amministratore, Stabile $stabile, array $data, string $sourceFile): void { $fatture = $data['fatture'] ?? null; if (! is_array($fatture)) { return; } foreach ($fatture as $row) { if (! is_array($row)) { continue; } $sdi = trim((string) (($row['fileDownload']['idInvio'] ?? null) ?? ($row['idInvio'] ?? null) ?? ($row['idFattura'] ?? null))); if ($sdi === '') { continue; } $date = null; $d = trim((string) ($row['dataFattura'] ?? '')); if ($d !== '') { $date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null; } $imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0')); $imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0')); StgFatturaAde::query()->updateOrCreate( [ 'amministratore_id' => (int) $amministratore->id, 'stabile_id' => (int) $stabile->id, 'sdi_file' => $sdi, ], [ 'cartella_legacy' => null, 'tipo_documento' => (string) ($row['tipoDocumento'] ?? null), 'numero_documento' => (string) ($row['numeroFattura'] ?? null), 'data_emissione' => $date ? $date->format('Y-m-d') : null, 'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))), 'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null), 'imponibile' => $imponibile, 'imposta' => $imposta, 'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null), 'source_file' => $sourceFile, 'raw' => $row, ] ); } } private function parseAdeMoney(string $value): float { $v = trim($value); if ($v === '') { return 0.0; } // Esempi: "+000000000828,00" oppure "828,00". $v = str_replace(['.', ' '], ['', ''], $v); $v = str_replace(',', '.', $v); $v = preg_replace('/[^0-9+\-\.]/', '', $v) ?? $v; return is_numeric($v) ? (float) $v : 0.0; } }