0, 'pagate' => 0, 'registrate' => 0, 'totale_aperto' => 0.0, 'totale_registrato' => 0.0, ]; /** @var array> */ public array $fattureRows = []; /** @var array> */ public array $queueRows = []; public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); } public function mount(): void { [$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true); if (! $fornitore instanceof Fornitore) { $this->missingAdminContext = true; return; } $this->fornitore = $fornitore; $this->fornitoreLabel = $this->getFornitoreLabel($fornitore); $this->loadData(); } public function getTicketsUrl(): string { if ($this->fornitore instanceof Fornitore) { return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } return TicketOperativi::getUrl(panel: 'admin-filament'); } public function getCollaboratoriUrl(): string { if ($this->fornitore instanceof Fornitore) { return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } return Collaboratori::getUrl(panel: 'admin-filament'); } public function getFattureRicevuteUrl(): string { return FattureElettronicheP7mRicevute::getUrl(panel: 'admin-filament'); } protected function getHeaderActions(): array { if (! $this->fornitore instanceof Fornitore) { return []; } return [ Action::make('importa_fe_manuale') ->label('Importa FE manuale') ->icon('heroicon-o-arrow-up-tray') ->modalWidth('4xl') ->form([ FileUpload::make('fe_files') ->label('File FE (XML/P7M/ZIP)') ->directory(fn(): string => $this->getSupplierFeUploadDirectory()) ->disk('local') ->acceptedFileTypes([ '.p7m', '.xml', '.zip', 'application/xml', 'text/xml', 'application/pkcs7-mime', 'application/x-pkcs7-mime', 'application/pkcs7-signature', 'application/x-pkcs7-signature', 'application/octet-stream', 'application/zip', 'application/x-zip-compressed', ]) ->multiple() ->required(), Select::make('importa_righe') ->label('Import righe') ->options([ 'auto' => 'Auto (segue impostazione fornitore)', 'si' => 'Si (forza import righe)', 'no' => 'No (non importare righe)', ]) ->default('auto') ->required(), Toggle::make('force_update') ->label('Reimport / completa se gia presente') ->default(false), ]) ->action(function (array $data): void { if (! $this->fornitore instanceof Fornitore) { Notification::make()->title('Fornitore non disponibile')->danger()->send(); return; } if (! $this->supplierHasTaxIdentity()) { Notification::make() ->title('Import FE bloccato') ->body('Il fornitore corrente non ha P.IVA o codice fiscale valorizzati: non posso verificare che l XML appartenga davvero a questa anagrafica.') ->warning() ->send(); return; } $paths = $data['fe_files'] ?? []; $paths = is_array($paths) ? $paths : [$paths]; $parser = new FatturaElettronicaXmlParser(); $importer = new FatturaElettronicaImporter($parser); $extractor = app(P7mExtractor::class); $summary = [ 'imported' => 0, 'duplicates' => 0, 'skipped' => 0, 'errors' => 0, 'messages' => [], ]; foreach ($paths as $path) { $result = $this->importSupplierUploadedFeFile( (string) $path, $importer, $parser, $extractor, [ 'force_update' => (bool) ($data['force_update'] ?? false), 'importa_righe' => (string) ($data['importa_righe'] ?? 'auto'), ] ); $summary['imported'] += (int) ($result['imported'] ?? 0); $summary['duplicates'] += (int) ($result['duplicates'] ?? 0); $summary['skipped'] += (int) ($result['skipped'] ?? 0); $summary['errors'] += (int) ($result['errors'] ?? 0); $summary['messages'] = array_merge($summary['messages'], $result['messages'] ?? []); } $this->loadData(); $body = 'Importate: ' . $summary['imported'] . ' · Duplicate: ' . $summary['duplicates'] . ' · Saltate: ' . $summary['skipped'] . ' · Errori: ' . $summary['errors']; if ($summary['messages'] !== []) { $body .= "\n" . implode("\n", array_slice($summary['messages'], 0, 3)); } Notification::make() ->title('Import FE manuale completato') ->body($body) ->{$summary['errors'] > 0 ? 'warning' : 'success'}() ->send(); }), ]; } protected function loadData(): void { if (! $this->fornitore instanceof Fornitore) { return; } $this->queueRows = TicketIntervento::query() ->with(['ticket.stabile.amministratore']) ->where('fornitore_id', (int) $this->fornitore->id) ->whereIn('stato', ['in_verifica', 'fatturabile', 'fatturato']) ->orderByDesc('updated_at') ->limit(40) ->get() ->map(fn(TicketIntervento $intervento) => [ 'ticket_id' => (int) $intervento->ticket_id, 'intervento_id' => (int) $intervento->id, 'stato' => (string) $intervento->stato, 'stabile' => (string) ($intervento->ticket?->stabile?->denominazione ?? '-'), 'amministratore' => (string) ($intervento->ticket?->stabile?->amministratore?->denominazione_studio ?? '-'), 'titolo' => (string) ($intervento->ticket?->titolo ?? '-'), 'aggiornato' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', 'url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'), ]) ->all(); if (! Schema::hasTable('contabilita_fatture_fornitori')) { $this->contabilitaReady = false; return; } $this->contabilitaReady = true; $fatture = FatturaFornitore::query() ->with(['stabile.amministratore']) ->where('fornitore_id', (int) $this->fornitore->id) ->orderByDesc('data_documento') ->orderByDesc('id') ->limit(100) ->get(); $this->stats = [ 'aperte' => $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->count(), 'pagate' => $fatture->where('stato', 'pagato')->count(), 'registrate' => $fatture->count(), 'totale_aperto' => (float) $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->sum('netto_da_pagare'), 'totale_registrato' => (float) $fatture->sum('totale'), ]; $this->fattureRows = $fatture ->map(fn(FatturaFornitore $fattura) => [ 'id' => (int) $fattura->id, 'data_documento' => optional($fattura->data_documento)->format('d/m/Y') ?: '-', 'numero_documento' => (string) ($fattura->numero_documento ?? '-'), 'stato' => (string) ($fattura->stato ?? 'inserito'), 'stabile' => (string) ($fattura->stabile?->denominazione ?? '-'), 'amministratore' => (string) ($fattura->stabile?->amministratore?->denominazione_studio ?? '-'), 'descrizione' => (string) ($fattura->descrizione ?? ''), 'totale' => (float) ($fattura->totale ?? 0), 'netto' => (float) ($fattura->netto_da_pagare ?? 0), ]) ->all(); } /** * @param array $options * @return array{imported:int,duplicates:int,skipped:int,errors:int,messages:array} */ private function importSupplierUploadedFeFile( string $path, FatturaElettronicaImporter $importer, FatturaElettronicaXmlParser $parser, P7mExtractor $extractor, array $options, ): array { $summary = [ 'imported' => 0, 'duplicates' => 0, 'skipped' => 0, 'errors' => 0, 'messages' => [], ]; $path = trim($path); if ($path === '') { return $summary; } try { $filename = basename($path); $lower = Str::lower($filename); if (Str::endsWith($lower, '.zip')) { $absZip = Storage::disk('local')->path($path); if (! is_file($absZip)) { throw new \RuntimeException('ZIP non trovato'); } $tmpDir = 'tmp/fornitori-fe/' . now()->format('Y/m') . '/' . (string) Str::uuid(); Storage::disk('local')->makeDirectory($tmpDir); $absTmp = Storage::disk('local')->path($tmpDir); $zip = new ZipArchive(); $open = $zip->open($absZip); if ($open !== true) { throw new \RuntimeException('ZIP non valido'); } $zip->extractTo($absTmp); $zip->close(); $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absTmp)); foreach ($it as $fileInfo) { if (! $fileInfo instanceof \SplFileInfo || ! $fileInfo->isFile()) { continue; } $innerName = (string) $fileInfo->getFilename(); $innerLower = Str::lower($innerName); if (! (Str::endsWith($innerLower, '.xml') || Str::endsWith($innerLower, '.p7m'))) { continue; } $bytes = file_get_contents($fileInfo->getPathname()); if (! is_string($bytes) || $bytes === '') { $summary['errors']++; $summary['messages'][] = $filename . '::' . $innerName . ' - file vuoto'; continue; } $isP7m = Str::endsWith($innerLower, '.p7m'); $xml = $isP7m ? $extractor->extractXmlFromP7m($fileInfo->getPathname()) : $bytes; $this->mergeSupplierImportResult( $summary, $this->importSupplierParsedXml( is_string($xml) ? $xml : '', $isP7m ? (pathinfo($innerName, PATHINFO_FILENAME) . '.xml') : $innerName, $importer, $parser, $options, $isP7m ? $innerName : null, $isP7m ? $bytes : null ), $filename . '::' . $innerName ); } Storage::disk('local')->deleteDirectory($tmpDir); return $summary; } $bytes = Storage::disk('local')->get($path); if (! is_string($bytes) || $bytes === '') { throw new \RuntimeException('File vuoto'); } $isP7m = Str::endsWith($lower, '.p7m'); $xml = $isP7m ? $extractor->extractXmlFromP7m(Storage::disk('local')->path($path)) : $bytes; $this->mergeSupplierImportResult( $summary, $this->importSupplierParsedXml( is_string($xml) ? $xml : '', $isP7m ? (pathinfo($filename, PATHINFO_FILENAME) . '.xml') : $filename, $importer, $parser, $options, $isP7m ? $filename : null, $isP7m ? $bytes : null ), $filename ); } catch (\Throwable $e) { $summary['errors']++; $summary['messages'][] = basename($path) . ' - ' . $e->getMessage(); } finally { try { Storage::disk('local')->delete($path); } catch (\Throwable) { // ignore } } return $summary; } /** * @param array $options * @return array{status:string,message:string} */ private function importSupplierParsedXml( string $xml, string $xmlFilename, FatturaElettronicaImporter $importer, FatturaElettronicaXmlParser $parser, array $options, ?string $p7mFilename = null, ?string $p7mBytes = null, ): array { $xml = trim($xml); if ($xml === '') { return ['status' => 'error', 'message' => 'XML non valido o assente']; } if ($this->isSdINotificationDocument($xmlFilename, $xml)) { return [ 'status' => 'skipped', 'message' => 'Ricevuta/notifica SdI: non viene importata nella contabilita fornitore', ]; } $parsed = $parser->parse($xml); if (! $this->supplierMatchesParsedIdentity($parsed)) { return ['status' => 'error', 'message' => 'Il file non appartiene al fornitore corrente']; } $stabileId = $this->resolveSupplierStabileIdFromParsed($parsed); if ($stabileId <= 0) { return ['status' => 'error', 'message' => 'Impossibile determinare uno stabile univoco per questa FE']; } $unique = (string) Str::uuid(); $ym = now()->format('Y/m'); $inboxBase = $this->supplierInboxBaseForStabile($stabileId, $ym); $permanentP7mPath = null; if ($p7mFilename !== null && is_string($p7mBytes) && $p7mBytes !== '') { $permanentP7mPath = $inboxBase . '/' . $unique . '-' . $p7mFilename; Storage::disk('local')->put($permanentP7mPath, $p7mBytes); } $permanentXmlPath = $inboxBase . '/' . $unique . '-' . $xmlFilename; Storage::disk('local')->put($permanentXmlPath, $xml); $result = $importer->importXml($xml, 0, $xmlFilename, [ 'xml_path' => $permanentXmlPath, 'p7m_path' => $permanentP7mPath, 'nome_file_p7m' => $p7mFilename, 'force_update' => (bool) ($options['force_update'] ?? false), 'auto_repair_duplicate' => true, 'importa_righe' => (string) ($options['importa_righe'] ?? 'auto'), 'create_fornitore_if_missing' => false, 'allow_fallback_stabile' => false, 'force_stabile_id' => $stabileId, 'force_fornitore_id' => (int) $this->fornitore->id, 'skip_fornitore_sync' => true, 'user_id' => (int) Auth::id(), ]); $status = (string) ($result['status'] ?? 'error'); $message = (string) ($result['message'] ?? 'Import FE non riuscito'); if ($status === 'duplicate' || $status === 'error') { try { Storage::disk('local')->delete($permanentXmlPath); if ($permanentP7mPath !== null) { Storage::disk('local')->delete($permanentP7mPath); } } catch (\Throwable) { // ignore } } return [ 'status' => $status, 'message' => $message, ]; } /** * @param array $summary * @param array $result */ private function mergeSupplierImportResult(array &$summary, array $result, string $label): void { $status = (string) ($result['status'] ?? 'error'); $message = trim((string) ($result['message'] ?? '')); if ($status === 'imported') { $summary['imported']++; return; } if ($status === 'duplicate') { $summary['duplicates']++; $summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'duplicato'); return; } if ($status === 'skipped') { $summary['skipped']++; $summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'saltato'); return; } $summary['errors']++; $summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'errore'); } private function isSdINotificationDocument(string $xmlFilename, string $xml): bool { $filename = strtoupper(trim($xmlFilename)); if ($filename !== '' && preg_match('/_(RC|NS|MC|NE|EC|AT|DT|MT|SE|SM)_\d+\.XML$/', $filename) === 1) { return true; } foreach ([ 'fornitore instanceof Fornitore) { return 'tmp/fornitori-fe'; } $adminCode = (string) ($this->fornitore->amministratore?->codice_amministratore ?: $this->fornitore->amministratore?->codice_univoco ?: 'ID-' . (int) $this->fornitore->amministratore_id); $fornitoreCode = (string) ($this->fornitore->codice_fornitore ?: $this->fornitore->codice_univoco ?: 'ID-' . (int) $this->fornitore->id); return 'amministratori/' . $adminCode . '/fornitori/' . $fornitoreCode . '/temp/upload/fatture-fe'; } private function supplierHasTaxIdentity(): bool { if (! $this->fornitore instanceof Fornitore) { return false; } return $this->normalizeTaxId((string) ($this->fornitore->partita_iva ?? '')) !== '' || $this->normalizeTaxId((string) ($this->fornitore->codice_fiscale ?? '')) !== '' || $this->normalizeTaxId((string) ($this->fornitore->rubrica?->partita_iva ?? '')) !== '' || $this->normalizeTaxId((string) ($this->fornitore->rubrica?->codice_fiscale ?? '')) !== ''; } /** * @param array $parsed */ private function supplierMatchesParsedIdentity(array $parsed): bool { if (! $this->fornitore instanceof Fornitore) { return false; } $invoiceIds = array_values(array_unique(array_filter([ $this->normalizeTaxId((string) ($parsed['fornitore_piva'] ?? '')), $this->normalizeTaxId((string) ($parsed['fornitore_cf'] ?? '')), $this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? '')), $this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? '')), ]))); if ($invoiceIds === []) { return false; } $supplierIds = array_values(array_filter([ $this->normalizeTaxId((string) ($this->fornitore->partita_iva ?? '')), $this->normalizeTaxId((string) ($this->fornitore->codice_fiscale ?? '')), $this->normalizeTaxId((string) ($this->fornitore->rubrica?->partita_iva ?? '')), $this->normalizeTaxId((string) ($this->fornitore->rubrica?->codice_fiscale ?? '')), ])); foreach ($invoiceIds as $invoiceId) { if (in_array($invoiceId, $supplierIds, true)) { return true; } } return false; } /** * @param array $parsed */ private function resolveSupplierStabileIdFromParsed(array $parsed): int { $adminId = (int) ($this->fornitore?->amministratore_id ?? 0); if ($adminId <= 0) { return 0; } $cf = $this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? '')); if ($cf !== '') { $match = Stabile::query() ->where('amministratore_id', $adminId) ->whereNotNull('codice_fiscale') ->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf]) ->where('attivo', true) ->get(); if ($match->count() === 1) { return (int) $match->first()->id; } } $codice = $this->normalizeTaxId((string) ($parsed['codice_destinatario'] ?? '')); if ($codice !== '') { $match = Stabile::query() ->where('amministratore_id', $adminId) ->whereNotNull('codice_destinatario_sdi') ->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice]) ->where('attivo', true) ->get(); if ($match->count() === 1) { return (int) $match->first()->id; } } $pec = trim(strtolower((string) ($parsed['pec_destinatario'] ?? ''))); if ($pec !== '') { $match = Stabile::query() ->where('amministratore_id', $adminId) ->where(function ($query) use ($pec): void { $query->whereRaw('LOWER(pec_condominio) = ?', [$pec]); if (Schema::hasColumn('stabili', 'pec_amministratore')) { $query->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]); } }) ->where('attivo', true) ->get(); if ($match->count() === 1) { return (int) $match->first()->id; } } $destPiva = $this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? '')); if ($destPiva !== '') { $match = Stabile::query() ->select('stabili.id') ->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id') ->where('stabili.amministratore_id', $adminId) ->where('stabili.attivo', true) ->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva]) ->get(); if ($match->count() === 1) { return (int) $match->first()->id; } } return 0; } private function supplierInboxBaseForStabile(int $stabileId, string $ym): string { $stabile = Stabile::query() ->with(['amministratore:id,codice_amministratore,codice_univoco']) ->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco']) ->find($stabileId); if ($stabile instanceof Stabile) { $base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore instanceof Amministratore ? $stabile->amministratore : null); if (is_string($base) && $base !== '') { return $base . '/fatture-ricevute/inbox/' . $ym; } } $adminId = (int) ($stabile?->amministratore_id ?? 0); return 'amministratori/ID-' . $adminId . '/stabili/ID-' . $stabileId . '/fatture-ricevute/inbox/' . $ym; } private function normalizeTaxId(string $value): string { $normalized = strtoupper(str_replace(' ', '', trim($value))); if (str_starts_with($normalized, 'IT') && strlen($normalized) > 11) { $normalized = substr($normalized, 2); } return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? ''; } }