argument('stabile_id'); $path = (string) $this->argument('path'); /** @var Stabile|null $stabile */ $stabile = Stabile::query()->with('amministratore')->find($stabileId); if (! $stabile) { $this->error('Stabile non trovato: ID=' . $stabileId); return self::FAILURE; } $amministratore = $stabile->amministratore; if (! $amministratore) { $this->error('Amministratore non trovato per stabile ID=' . $stabileId); return self::FAILURE; } $metadati = (bool) ((int) $this->option('metadati') === 1); $userId = (int) $this->option('user_id'); $dal = $this->parseDateYmd((string) $this->option('dal')); $al = $this->parseDateYmd((string) $this->option('al')); $sourceIsZip = is_file($path) && str_ends_with(strtolower($path), '.zip'); $sourceIsDir = is_dir($path); if (! $sourceIsZip && ! $sourceIsDir) { $this->error('Path non valido (atteso directory o .zip): ' . $path); return self::FAILURE; } $uuid = (string) Str::uuid(); $ym = now()->format('Y-m'); $adminBase = ArchivioPaths::adminBase($amministratore); $stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id); $stabileBase = $adminBase . '/stabili/' . $stabileFolder; $baseDownloads = $stabileBase . '/fatture-ricevute/downloads/' . $ym; Storage::disk('local')->makeDirectory($baseDownloads); $tmpDir = $stabileBase . '/temp/fe-cassetto-local/' . $uuid; Storage::disk('local')->makeDirectory($tmpDir); $tmpFsPath = Storage::disk('local')->path($tmpDir); try { if ($sourceIsZip) { if (! class_exists(\ZipArchive::class)) { throw new \RuntimeException('Estensione ZIP (ZipArchive) non disponibile sul server'); } $zip = new \ZipArchive(); if ($zip->open($path) !== true) { throw new \RuntimeException('Impossibile aprire ZIP: ' . $path); } $zip->extractTo($tmpFsPath); $zip->close(); } else { // Directory: lavoriamo direttamente sul path (nessuna copia). $tmpFsPath = $path; } $riepilogo = $this->findRiepilogoJson($tmpFsPath); if ($riepilogo && (! $dal || ! $al)) { $maybeDal = $this->parseDateDmy((string) ($riepilogo['data']['dataRicercaDa'] ?? '')); $maybeAl = $this->parseDateDmy((string) ($riepilogo['data']['dataRicercaA'] ?? '')); $dal = $dal ?: $maybeDal; $al = $al ?: $maybeAl; } if (! $dal || ! $al) { throw new \RuntimeException('Date dal/al mancanti: passale come --dal=YYYY-MM-DD --al=YYYY-MM-DD oppure assicurati che il riepilogo JSON contenga dataRicercaDa/dataRicercaA'); } $cf = $this->resolveCodiceFiscaleSoggetto($stabile); if ($cf === '') { throw new \RuntimeException('Codice fiscale dello stabile non presente (neanche in rubrica)'); } $requestHash = hash('sha256', implode('|', [ 'local=1', 'path=' . $path, 'cf=' . $cf, 'dal=' . $dal->format('Y-m-d'), 'al=' . $al->format('Y-m-d'), '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(); $noSkip = (int) $this->option('no_skip') === 1; if ($existingOk && ! $noSkip) { $this->warn('Già importato (stesso hash): log #' . (int) $existingOk->id); return self::SUCCESS; } $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', 'message' => 'source=local\npath=' . $path, 'created_at' => now(), 'updated_at' => now(), ]); $riepilogoStoredPath = null; $messageParts = []; if ($riepilogo) { $riepilogoStoredPath = $baseDownloads . '/' . $uuid . '-' . $riepilogo['filename']; Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']); $tot = $riepilogo['data']['totaleFatture'] ?? null; if (is_int($tot) || (is_string($tot) && ctype_digit($tot))) { $messageParts[] = 'riepilogo_totaleFatture=' . (int) $tot; } $messageParts[] = 'riepilogo_json=' . $riepilogoStoredPath; $riepilogoCf = trim((string) ($riepilogo['data']['codiceFiscale'] ?? $riepilogo['data']['cf'] ?? '')); if ($riepilogoCf !== '' && strtoupper($riepilogoCf) !== strtoupper($cf)) { $messageParts[] = 'WARN: CF riepilogo (' . strtoupper($riepilogoCf) . ') diverso da CF stabile (' . strtoupper($cf) . ')'; } try { $this->upsertAdeFromRiepilogo((int) $amministratore->id, (int) $stabile->id, $riepilogo['data'], (string) $riepilogoStoredPath); } catch (\Throwable) { // ignore } } $files = $this->collectFeFiles($tmpFsPath); $onlyFilesOpt = trim((string) ($this->option('only_files') ?? '')); $onlyFiles = []; if ($onlyFilesOpt !== '') { foreach (explode(',', $onlyFilesOpt) as $p) { $p = trim((string) $p); if ($p !== '') { $onlyFiles[$p] = true; } } $files = array_values(array_filter($files, function (string $full) use ($onlyFiles): bool { $b = basename($full); return isset($onlyFiles[$b]); })); } $filesTotal = count($files); $importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser()); $extractor = app(P7mExtractor::class); $imported = 0; $duplicates = 0; $errors = 0; $errorDetails = []; $errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m'); Storage::disk('local')->makeDirectory($errorsDir); // Salva i file in una cartella stabile XML dentro lo stabile. $xmlBase = $stabileBase . '/XML'; Storage::disk('local')->makeDirectory($xmlBase); foreach ($files as $fileFsPath) { $filename = basename($fileFsPath); $lower = strtolower($filename); $permanentP7mPath = null; $permanentXmlPath = null; try { $bytes = @file_get_contents($fileFsPath); if (! is_string($bytes) || $bytes === '') { throw new \RuntimeException('File vuoto'); } $isP7m = str_ends_with($lower, '.p7m'); if ($isP7m) { $permanentP7mPath = $xmlBase . '/' . $filename; if (! Storage::disk('local')->exists($permanentP7mPath)) { Storage::disk('local')->put($permanentP7mPath, $bytes); } $xml = $extractor->extractXmlFromP7m(Storage::disk('local')->path($permanentP7mPath)); if (! is_string($xml) || trim($xml) === '') { throw new \RuntimeException('XML non valido/assente (estratto da P7M)'); } $xmlFilename = pathinfo($filename, PATHINFO_FILENAME); // Gestisce *.xml.p7m => *.xml if (! str_ends_with(strtolower($xmlFilename), '.xml')) { $xmlFilename .= '.xml'; } $permanentXmlPath = $xmlBase . '/' . $xmlFilename; $forceUpdate = (int) $this->option('force_update') === 1; $shouldWriteXml = $forceUpdate || ! Storage::disk('local')->exists($permanentXmlPath); if (! $shouldWriteXml) { try { $size = (int) (Storage::disk('local')->size($permanentXmlPath) ?? 0); if ($size < 2000) { $shouldWriteXml = true; } else { $existing = Storage::disk('local')->get($permanentXmlPath); if (! is_string($existing) || ! preg_match('/<\s*\/\s*(?:[a-z0-9_\-]+:)?FatturaElettronica\s*>/i', $existing)) { $shouldWriteXml = true; } } } catch (\Throwable) { $shouldWriteXml = true; } } if ($shouldWriteXml) { Storage::disk('local')->put($permanentXmlPath, $xml); } $result = $importer->importXml($xml, 0, $xmlFilename, [ 'xml_path' => $permanentXmlPath, 'p7m_path' => $permanentP7mPath, 'nome_file_p7m' => $filename, 'force_stabile_id' => (int) $stabile->id, 'allow_fallback_stabile' => false, 'force_update' => (bool) ((int) $this->option('force_update') === 1), 'auto_repair_duplicate' => true, 'importa_righe' => (string) ($this->option('importa_righe') ?? 'auto'), 'create_fornitore_if_missing' => (bool) ((int) $this->option('create_fornitore_if_missing') === 1), 'user_id' => $userId, ]); } else { $xml = $bytes; if (! is_string($xml) || trim($xml) === '') { throw new \RuntimeException('XML non valido/assente'); } $permanentXmlPath = $xmlBase . '/' . $filename; if (! Storage::disk('local')->exists($permanentXmlPath)) { Storage::disk('local')->put($permanentXmlPath, $xml); } $result = $importer->importXml($xml, 0, $filename, [ 'xml_path' => $permanentXmlPath, 'force_stabile_id' => (int) $stabile->id, 'allow_fallback_stabile' => false, 'force_update' => (bool) ((int) $this->option('force_update') === 1), 'auto_repair_duplicate' => true, 'importa_righe' => (string) ($this->option('importa_righe') ?? 'auto'), 'create_fornitore_if_missing' => (bool) ((int) $this->option('create_fornitore_if_missing') === 1), 'user_id' => $userId, ]); } $status = (string) ($result['status'] ?? 'error'); if ($status === 'imported') { $imported++; } elseif ($status === 'duplicate') { $duplicates++; } else { $errors++; $msg = (string) ($result['message'] ?? 'Errore import'); $errorDetails[] = $filename . ' — ' . $msg; } } catch (\Throwable $e) { $errors++; $errorDetails[] = $filename . ' — ' . $e->getMessage(); try { // Salva una copia del file problematico per debug. $bytesForDebug = @file_get_contents($fileFsPath); if (is_string($bytesForDebug) && $bytesForDebug !== '') { $debugPath = $errorsDir . '/' . (string) Str::uuid() . '-' . $filename; Storage::disk('local')->put($debugPath, $bytesForDebug); } } catch (\Throwable) { // ignore } $this->warn($filename . ' — ' . $e->getMessage()); } } $message = "source=local\npath={$path}"; if ($messageParts) { $message .= "\n" . implode("\n", $messageParts); } DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'files_total' => $filesTotal, 'imported' => $imported, 'duplicates' => $duplicates, 'errors' => $errors, 'status' => 'ok', 'message' => $message, 'updated_at' => now(), ]); $this->info('OK. log #' . $logId . ' | files=' . $filesTotal . ' imported=' . $imported . ' dup=' . $duplicates . ' err=' . $errors); if ($riepilogoStoredPath) { $this->line('Riepilogo salvato: ' . $riepilogoStoredPath); } if ((int) $this->option('print_errors') === 1 && $errorDetails) { $this->line('--- Errori (' . count($errorDetails) . ') ---'); foreach (array_slice($errorDetails, 0, 50) as $line) { $this->line($line); } if (count($errorDetails) > 50) { $this->line('... (altri ' . (count($errorDetails) - 50) . ')'); } } return self::SUCCESS; } catch (\Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; } finally { // Se era ZIP, pulisci temp estrazione. try { if ($sourceIsZip) { Storage::disk('local')->deleteDirectory($tmpDir); } } catch (\Throwable) { // ignore } } } private function parseDateYmd(string $value): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { return null; } return \DateTimeImmutable::createFromFormat('Y-m-d', $value) ?: null; } private function parseDateDmy(string $value): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { return null; } return \DateTimeImmutable::createFromFormat('d/m/Y', $value) ?: null; } 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; } /** * @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; } if (str_ends_with($lower, '.xml') || str_ends_with($lower, '.p7m')) { $out[] = $file->getPathname(); } } sort($out); return $out; } /** * @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(int $amministratoreId, int $stabileId, 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' => $amministratoreId, 'stabile_id' => $stabileId, '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; } $v = str_replace(['.', ' '], ['', ''], $v); $v = str_replace(',', '.', $v); $v = preg_replace('/[^0-9+\-\.]/', '', $v) ?? $v; return is_numeric($v) ? (float) $v : 0.0; } }