From 19453c050f31f1213d5a3438b57fe8a85284f01e Mon Sep 17 00:00:00 2001 From: michele Date: Wed, 15 Apr 2026 14:26:18 +0000 Subject: [PATCH] Fix supplier FE access and archive isolation --- .../GesconFixFattureFornitoriLinkCommand.php | 41 +- .../Contabilita/FatturaElettronicaScheda.php | 10 +- .../FattureElettronicheP7mRicevute.php | 62 +- app/Filament/Pages/Fornitore/Contabilita.php | 548 ++++++++++++++++++ .../Pages/Fornitore/RubricaClienti.php | 137 +++-- .../Impostazioni/SchedaAmministratore.php | 98 +++- .../Integrations/GoogleOAuthController.php | 29 +- .../Filament/AdminFilamentPanelProvider.php | 17 +- .../FatturaElettronicaImporter.php | 14 +- app/Support/GoogleAccountStore.php | 91 ++- .../pages/fornitore/contabilita.blade.php | 4 + .../pages/fornitore/rubrica-clienti.blade.php | 14 +- 12 files changed, 896 insertions(+), 169 deletions(-) diff --git a/app/Console/Commands/GesconFixFattureFornitoriLinkCommand.php b/app/Console/Commands/GesconFixFattureFornitoriLinkCommand.php index 73b8094..7a8c72b 100644 --- a/app/Console/Commands/GesconFixFattureFornitoriLinkCommand.php +++ b/app/Console/Commands/GesconFixFattureFornitoriLinkCommand.php @@ -1,5 +1,4 @@ option('dry-run'); + $dry = (bool) $this->option('dry-run'); + $fixMismatches = (bool) $this->option('fix-mismatches'); $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); if (! $stabile) { @@ -39,13 +40,23 @@ public function handle(): int return self::FAILURE; } - $fatture = FatturaFornitore::query() + $fattureQuery = FatturaFornitore::query() ->where('stabile_id', $stabileId) - ->whereNull('fornitore_id') + ->whereNotNull('fattura_elettronica_id'); + + if (! $fixMismatches) { + $fattureQuery->whereNull('fornitore_id'); + } + + $fatture = $fattureQuery ->orderBy('id') ->get(['id', 'fattura_elettronica_id', 'fornitore_id']); - $this->info("Fatture contabili senza fornitore_id: " . $fatture->count()); + $this->info( + $fixMismatches + ? 'Fatture contabili da verificare: ' . $fatture->count() + : 'Fatture contabili senza fornitore_id: ' . $fatture->count() + ); $updated = 0; $skipped = 0; @@ -64,7 +75,7 @@ public function handle(): int } $piva = $this->normalizeId($fe->fornitore_piva); - $cf = $this->normalizeId($fe->fornitore_cf); + $cf = $this->normalizeId($fe->fornitore_cf); $fornitoreId = $this->matchFornitoreIdForAdmin($adminId, $piva, $cf); if (! $fornitoreId) { @@ -72,6 +83,11 @@ public function handle(): int continue; } + if ((int) ($fattura->fornitore_id ?: 0) === $fornitoreId) { + $skipped++; + continue; + } + $updated++; if ($dry) { @@ -83,7 +99,7 @@ public function handle(): int $fattura->fornitore_id = $fornitoreId; $fattura->save(); - if ((int) ($fe->fornitore_id ?: 0) <= 0) { + if ((int) ($fe->fornitore_id ?: 0) !== $fornitoreId) { $fe->fornitore_id = $fornitoreId; $fe->save(); } @@ -92,10 +108,11 @@ public function handle(): int $this->info('✅ Completato' . ($dry ? ' (dry-run)' : '')); $this->line(json_encode([ - 'stabile_id' => $stabileId, + 'stabile_id' => $stabileId, 'amministratore_id' => $adminId, - 'updated' => $updated, - 'skipped' => $skipped, + 'fix_mismatches' => $fixMismatches, + 'updated' => $updated, + 'skipped' => $skipped, ], JSON_UNESCAPED_UNICODE)); return self::SUCCESS; @@ -122,7 +139,7 @@ private function matchFornitoreIdForAdmin(int $adminId, string $pivaNorm, string if (str_starts_with($pivaNorm, 'IT') && strlen($pivaNorm) > 2) { $pivaNoIt = substr($pivaNorm, 2); - $match = (clone $base) + $match = (clone $base) ->whereRaw("REPLACE(UPPER(partita_iva), ' ', '') = ?", [$pivaNoIt]) ->value('id'); if ($match) { diff --git a/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php b/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php index 91397f0..348dbf4 100644 --- a/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php +++ b/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Contabilita; use App\Filament\Pages\Contabilita\FattureElettronicheArchivio; +use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Models\Documento; use App\Models\FatturaElettronica; use App\Models\FeXsdCode; @@ -34,6 +35,8 @@ class FatturaElettronicaScheda extends Page { + use ResolvesOperatoreContext; + protected static ?string $title = 'Scheda fattura ricevuta'; protected static ?string $slug = 'contabilita/fatture-ricevute/{record}'; @@ -133,12 +136,17 @@ public function mount(int | string $record): void abort(403); } - if (! $user->hasAnyRole(['super-admin', 'admin']) && ! $user->can('contabilita.fatture-elettroniche')) { + if (! $user->hasAnyRole(['super-admin', 'admin']) && ! $user->hasRole('fornitore') && ! $user->can('contabilita.fatture-elettroniche')) { abort(403); } $this->fattura = FatturaElettronica::query()->with(['stabile', 'fornitore'])->findOrFail((int) $record); + if ($user->hasRole('fornitore')) { + [$fornitore] = $this->resolveOperatoreContext(); + abort_unless($fornitore instanceof Fornitore && (int) $this->fattura->fornitore_id === (int) $fornitore->id, 403); + } + $this->documento = Documento::query() ->where('documentable_type', FatturaElettronica::class) ->where('documentable_id', (int) $this->fattura->id) diff --git a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php index 10ebb4d..dad1d8e 100644 --- a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php +++ b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php @@ -2,9 +2,11 @@ namespace App\Filament\Pages\Contabilita; use App\Filament\Pages\Contabilita\FatturaElettronicaScheda; -use App\Models\Documento; +use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Jobs\RunCassettoFiscaleDownload; +use App\Models\Documento; use App\Models\FatturaElettronica; +use App\Models\Fornitore; use App\Models\Stabile; use App\Models\User; use App\Services\Consumi\AcquaPdfTextParser; @@ -47,6 +49,7 @@ class FattureElettronicheP7mRicevute extends Page implements HasTable { use InteractsWithTable; + use ResolvesOperatoreContext; public array $quarterDownloads = []; @@ -125,7 +128,7 @@ private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): a return ['status' => 'skipped', 'message' => 'Fattura non trovata']; } - $hasPdf = is_string($fattura->allegato_pdf_path ?? null) && trim((string) $fattura->allegato_pdf_path) !== ''; + $hasPdf = is_string($fattura->allegato_pdf_path ?? null) && trim((string) $fattura->allegato_pdf_path) !== ''; $hasStoredPayload = is_string($fattura->consumo_raw ?? null) && trim((string) $fattura->consumo_raw) !== ''; if (! $hasPdf && ! $hasStoredPayload) { return ['status' => 'skipped', 'message' => 'Nessun PDF/payload acqua disponibile']; @@ -173,15 +176,15 @@ private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): a } } - $codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : []; + $codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : []; $contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : []; - $consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []; - $generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : []; - $finestra = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : []; - $letture = is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : []; - $quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : []; - $tariffe = is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : []; - $ivaInfo = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : []; + $consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []; + $generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : []; + $finestra = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : []; + $letture = is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : []; + $quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : []; + $tariffe = is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : []; + $ivaInfo = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : []; $hasAny = false; foreach ([$codici['utenza'] ?? null, $codici['cliente'] ?? null, $codici['contratto'] ?? null, $contatore['matricola'] ?? null] as $value) { @@ -227,15 +230,15 @@ private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): a 'parsed_at' => now()->toISOString(), ]; - $totalMc = null; + $totalMc = null; $reference = null; if ($consumi !== []) { - $sum = 0.0; + $sum = 0.0; $hasNumeric = false; foreach ($consumi as $consumo) { if (isset($consumo['valore']) && is_numeric($consumo['valore'])) { - $sum += (float) $consumo['valore']; - $hasNumeric = true; + $sum += (float) $consumo['valore']; + $hasNumeric = true; } } if ($hasNumeric) { @@ -243,17 +246,17 @@ private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): a } $first = $consumi[0] ?? []; - $dal = $first['dal'] ?? null; - $al = $first['al'] ?? null; + $dal = $first['dal'] ?? null; + $al = $first['al'] ?? null; if (is_string($dal) && is_string($al) && $dal !== '' && $al !== '') { $reference = $dal . ' - ' . $al; } } - $fattura->consumo_unita = 'mc'; - $fattura->consumo_valore = $totalMc; + $fattura->consumo_unita = 'mc'; + $fattura->consumo_valore = $totalMc; $fattura->consumo_riferimento = $reference; - $fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $fattura->save(); try { @@ -584,6 +587,10 @@ public static function canAccess(): bool return true; } + if ($user->hasRole('fornitore')) { + return true; + } + return $user->can('contabilita.fatture-elettroniche'); } @@ -1171,9 +1178,9 @@ protected function getHeaderActions(): array $accessibleStabili = StabileContext::accessibleStabili($user)->pluck('id')->map(fn($v) => (int) $v)->all(); $accessibleMap = array_fill_keys($accessibleStabili, true); - $imported = 0; - $duplicates = 0; - $errors = 0; + $imported = 0; + $duplicates = 0; + $errors = 0; $waterCreated = 0; $waterUpdated = 0; @@ -1744,6 +1751,17 @@ protected function getTableQuery(): Builder return FatturaElettronica::query()->whereRaw('1 = 0'); } + if ($user->hasRole('fornitore')) { + [$fornitore] = $this->resolveOperatoreContext(); + + if (! $fornitore instanceof Fornitore) { + return FatturaElettronica::query()->whereRaw('1 = 0'); + } + + return FatturaElettronica::query() + ->where('fornitore_id', (int) $fornitore->id); + } + $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { return FatturaElettronica::query()->whereRaw('1 = 0'); diff --git a/app/Filament/Pages/Fornitore/Contabilita.php b/app/Filament/Pages/Fornitore/Contabilita.php index 6c1b0c9..440a5e3 100644 --- a/app/Filament/Pages/Fornitore/Contabilita.php +++ b/app/Filament/Pages/Fornitore/Contabilita.php @@ -1,16 +1,31 @@ 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) { @@ -155,4 +285,422 @@ protected function loadData(): void ]) ->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) ?? ''; + } } diff --git a/app/Filament/Pages/Fornitore/RubricaClienti.php b/app/Filament/Pages/Fornitore/RubricaClienti.php index ac9e1b2..01b3085 100644 --- a/app/Filament/Pages/Fornitore/RubricaClienti.php +++ b/app/Filament/Pages/Fornitore/RubricaClienti.php @@ -2,12 +2,12 @@ namespace App\Filament\Pages\Fornitore; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; -use App\Filament\Pages\Gescon\RubricaUniversaleScheda; use App\Models\AssistenzaTecnorepairScheda; use App\Models\Fornitore; use App\Models\FornitoreCliente; use App\Models\TicketIntervento; use App\Models\User; +use App\Support\GoogleAccountStore; use BackedEnum; use Filament\Pages\Page; use Illuminate\Support\Facades\Auth; @@ -49,9 +49,9 @@ class RubricaClienti extends Page /** @var array */ public array $counters = [ - 'totali' => 0, - 'collegati' => 0, - 'con_email' => 0, + 'totali' => 0, + 'collegati' => 0, + 'con_email' => 0, 'con_cellulare' => 0, ]; @@ -98,13 +98,13 @@ public function selectTab(string $tab): void public function selectCliente(int $clienteId): void { $this->selectedClienteId = $clienteId > 0 ? $clienteId : null; - $this->activeTab = 'scheda'; + $this->activeTab = 'scheda'; } public function refreshRows(): void { if (! $this->fornitoreId) { - $this->rows = []; + $this->rows = []; $this->counters = ['totali' => 0, 'collegati' => 0, 'con_email' => 0, 'con_cellulare' => 0]; return; } @@ -116,7 +116,7 @@ public function refreshRows(): void ->selectRaw('legacy_cliente_id, COUNT(*) as totale_schede, MAX(date_received) as ultima_data') ->groupBy('legacy_cliente_id') ->get() - ->mapWithKeys(fn(AssistenzaTecnorepairScheda $row): array => [ + ->mapWithKeys(fn(AssistenzaTecnorepairScheda $row): array=> [ (int) ($row->legacy_cliente_id ?? 0) => [ 'totale_schede' => (int) ($row->totale_schede ?? 0), 'ultima_data' => optional($row->ultima_data)->format('d/m/Y') ?: '-', @@ -141,15 +141,14 @@ public function refreshRows(): void }) ->orderByRaw('CASE WHEN rubrica_id IS NULL THEN 1 ELSE 0 END') ->orderBy('display_name') - ->limit(500) ->get(); if ($items->isEmpty()) { - $this->rows = $this->buildTicketFallbackRows($term); + $this->rows = $this->buildTicketFallbackRows($term); $this->counters = [ - 'totali' => count($this->rows), - 'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['rubrica_id'] ?? null))->count(), - 'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['email'] ?? null))->count(), + 'totali' => count($this->rows), + 'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['rubrica_id'] ?? null))->count(), + 'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['email'] ?? null))->count(), 'con_cellulare' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['phone'] ?? null))->count(), ]; @@ -161,29 +160,29 @@ public function refreshRows(): void $legacy = $legacyCounts[(int) ($cliente->legacy_cliente_id ?? 0)] ?? ['totale_schede' => 0, 'ultima_data' => '-']; return [ - 'id' => (int) $cliente->id, - 'legacy_cliente_id'=> (int) ($cliente->legacy_cliente_id ?? 0), - 'display_name' => (string) ($cliente->display_name ?: 'Cliente TecnoRepair'), - 'phone' => (string) ($cliente->phone ?? ''), - 'phone_alt' => (string) ($cliente->phone_alt ?? ''), - 'email' => (string) ($cliente->email ?? ''), - 'indirizzo' => (string) ($cliente->indirizzo ?? ''), - 'citta' => (string) ($cliente->citta ?? ''), - 'provincia' => (string) ($cliente->provincia ?? ''), - 'rubrica_id' => (int) ($cliente->rubrica_id ?? 0), - 'rubrica_url' => $cliente->rubrica_id ? RubricaUniversaleScheda::getUrl(['record' => (int) $cliente->rubrica_id], panel: 'admin-filament') : null, - 'rubrica_label' => (string) ($cliente->rubrica?->categoria ?? ''), - 'totale_schede' => (int) ($legacy['totale_schede'] ?? 0), - 'ultima_data' => (string) ($legacy['ultima_data'] ?? '-'), - 'updated_at' => optional($cliente->updated_at)->format('d/m/Y H:i') ?: '-', - 'source' => 'tecnorepair_tclienti', + 'id' => (int) $cliente->id, + 'legacy_cliente_id' => (int) ($cliente->legacy_cliente_id ?? 0), + 'display_name' => (string) ($cliente->display_name ?: 'Cliente TecnoRepair'), + 'phone' => (string) ($cliente->phone ?? ''), + 'phone_alt' => (string) ($cliente->phone_alt ?? ''), + 'email' => (string) ($cliente->email ?? ''), + 'indirizzo' => (string) ($cliente->indirizzo ?? ''), + 'citta' => (string) ($cliente->citta ?? ''), + 'provincia' => (string) ($cliente->provincia ?? ''), + 'rubrica_id' => (int) ($cliente->rubrica_id ?? 0), + 'rubrica_url' => $cliente->rubrica_id ? $this->buildRubricaUrlForCurrentUser((int) $cliente->rubrica_id, (string) ($cliente->display_name ?? '')) : null, + 'rubrica_label' => (string) ($cliente->rubrica?->categoria ?? ''), + 'totale_schede' => (int) ($legacy['totale_schede'] ?? 0), + 'ultima_data' => (string) ($legacy['ultima_data'] ?? '-'), + 'updated_at' => optional($cliente->updated_at)->format('d/m/Y H:i') ?: '-', + 'source' => 'tecnorepair_tclienti', ]; })->all(); $this->counters = [ - 'totali' => count($this->rows), - 'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => (int) ($row['rubrica_id'] ?? 0) > 0)->count(), - 'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => trim((string) ($row['email'] ?? '')) !== '')->count(), + 'totali' => count($this->rows), + 'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => (int) ($row['rubrica_id'] ?? 0) > 0)->count(), + 'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => trim((string) ($row['email'] ?? '')) !== '')->count(), 'con_cellulare' => (int) collect($this->rows)->filter(fn(array $row): bool => trim((string) ($row['phone'] ?? '')) !== '')->count(), ]; @@ -220,15 +219,15 @@ public function getSelectedClienteSchedeProperty(): array ->orderByDesc('id') ->limit(30) ->get() - ->map(fn(AssistenzaTecnorepairScheda $scheda): array => [ - 'id' => (int) $scheda->id, - 'numero' => (string) ($scheda->legacy_numero_scheda ?: ('#' . $scheda->id)), - 'prodotto' => (string) ($scheda->product_model ?: '-'), - 'codice' => (string) ($scheda->product_code ?: ''), - 'seriali' => (string) $scheda->serial_label, - 'stato' => (string) ($scheda->status_label ?: '-'), - 'status_badge' => (string) $scheda->status_badge_classes, - 'ingresso' => optional($scheda->date_received)->format('d/m/Y') ?: '-', + ->map(fn(AssistenzaTecnorepairScheda $scheda): array=> [ + 'id' => (int) $scheda->id, + 'numero' => (string) ($scheda->legacy_numero_scheda ?: ('#' . $scheda->id)), + 'prodotto' => (string) ($scheda->product_model ?: '-'), + 'codice' => (string) ($scheda->product_code ?: ''), + 'seriali' => (string) $scheda->serial_label, + 'stato' => (string) ($scheda->status_label ?: '-'), + 'status_badge' => (string) $scheda->status_badge_classes, + 'ingresso' => optional($scheda->date_received)->format('d/m/Y') ?: '-', ])->all(); $items = TicketIntervento::query() @@ -375,7 +374,7 @@ protected function resolveCallerDataForRubrica(TicketIntervento $intervento): ar ]; } - protected function buildRubricaUrlForCurrentUser(int $rubricaId): ?string + protected function buildRubricaUrlForCurrentUser(int $rubricaId, string $search = ''): ?string { $user = Auth::user(); @@ -387,7 +386,10 @@ protected function buildRubricaUrlForCurrentUser(int $rubricaId): ?string return \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); } - return null; + $url = self::getUrl(['fornitore' => (int) ($this->fornitoreId ?? 0)], panel: 'admin-filament'); + $search = trim($search); + + return $search !== '' ? ($url . '?search=' . urlencode($search)) : $url; } /** @@ -406,7 +408,7 @@ private function buildTicketFallbackRows(string $term = ''): array foreach ($items as $intervento) { $contact = $this->resolveCallerDataForRubrica($intervento); - $key = $contact['identity_key'] !== '' ? $contact['identity_key'] : ('ticket-' . (int) $intervento->ticket_id); + $key = $contact['identity_key'] !== '' ? $contact['identity_key'] : ('ticket-' . (int) $intervento->ticket_id); if ($term !== '') { $haystack = Str::lower(implode(' ', [$contact['contatto'], $contact['telefono'], $contact['email'], $contact['stabile'], $contact['origine']])); @@ -417,22 +419,22 @@ private function buildTicketFallbackRows(string $term = ''): array if (! isset($grouped[$key])) { $grouped[$key] = [ - 'id' => 0, + 'id' => 0, 'legacy_cliente_id' => 0, - 'display_name' => $contact['contatto'], - 'phone' => $contact['telefono'], - 'phone_alt' => '', - 'email' => $contact['email'], - 'indirizzo' => '', - 'citta' => '', - 'provincia' => '', - 'rubrica_id' => 0, - 'rubrica_url' => $contact['rubrica_url'], - 'rubrica_label' => $contact['rubrica_label'], - 'totale_schede' => 1, - 'ultima_data' => optional($intervento->updated_at)->format('d/m/Y') ?: '-', - 'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', - 'source' => 'ticket_fallback', + 'display_name' => $contact['contatto'], + 'phone' => $contact['telefono'], + 'phone_alt' => '', + 'email' => $contact['email'], + 'indirizzo' => '', + 'citta' => '', + 'provincia' => '', + 'rubrica_id' => 0, + 'rubrica_url' => $contact['rubrica_url'], + 'rubrica_label' => $contact['rubrica_label'], + 'totale_schede' => 1, + 'ultima_data' => optional($intervento->updated_at)->format('d/m/Y') ?: '-', + 'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', + 'source' => 'ticket_fallback', ]; continue; } @@ -453,13 +455,22 @@ protected function resolveGoogleWorkspaceStatus(Fornitore $fornitore): ?array return null; } - $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); + $account = app(GoogleAccountStore::class)->get($amministratore, 'fornitore-' . (int) $fornitore->id); + + if (! is_array($account)) { + return [ + 'connected' => false, + 'email' => '', + 'name' => '', + 'connected_at' => '', + ]; + } return [ - 'connected' => (bool) ($oauth['connected'] ?? false), - 'email' => (string) ($oauth['email'] ?? ''), - 'name' => (string) ($oauth['name'] ?? ''), - 'connected_at' => (string) ($oauth['connected_at'] ?? ''), + 'connected' => (bool) ($account['connected'] ?? false), + 'email' => (string) ($account['email'] ?? ''), + 'name' => (string) ($account['name'] ?? ''), + 'connected_at' => (string) ($account['connected_at'] ?? ''), ]; } } diff --git a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php index 05a0f3d..9934eac 100644 --- a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php +++ b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php @@ -27,6 +27,7 @@ use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema as FilamentSchema; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -89,7 +90,9 @@ class SchedaAmministratore extends Page implements HasForms /** @var array> */ public array $collaboratoreRubricaMatches = []; - public Amministratore $amministratore; + protected Amministratore $amministratore; + + public int $amministratoreId = 0; public static function canAccess(): bool { @@ -126,40 +129,30 @@ public function mount(): void abort(403); } - $this->amministratore = $amministratore; + $this->amministratore = $amministratore; + $this->amministratoreId = (int) $amministratore->id; $isSuperAdmin = $user->hasAnyRole(['super-admin', 'admin']); $this->getSchema('form')?->fill([ - ...$this->amministratore->only([ - 'nome', - 'cognome', - 'denominazione_studio', - 'partita_iva', - 'codice_fiscale_studio', - 'indirizzo_studio', - 'cap_studio', - 'citta_studio', - 'provincia_studio', - 'telefono_studio', - 'cellulare', - 'email_studio', - 'pec_studio', - 'cartella_dati', - 'database_attivo', - 'fe_cassetto_enabled', - 'fe_cassetto_base_url', - ]), + ...$this->safeAmministratoreFillableState($this->amministratore), // Per sicurezza non precompiliamo mai i segreti nel form. // La configurazione completa è gestita dal Super Admin. 'fe_cassetto_password_script' => $isSuperAdmin ? null : null, 'fe_cassetto_username' => $isSuperAdmin ? null : null, 'fe_cassetto_password' => $isSuperAdmin ? null : null, 'fe_cassetto_pin' => $isSuperAdmin ? null : null, - 'impostazioni' => $this->amministratore->impostazioni ?? [], + 'impostazioni' => $this->safeAmministratoreImpostazioni($this->amministratore), ]); } + public function hydrate(): void + { + if ($this->amministratoreId > 0) { + $this->amministratore = Amministratore::query()->findOrFail($this->amministratoreId); + } + } + public function form(FilamentSchema $schema): FilamentSchema { return $schema @@ -1995,7 +1988,16 @@ private function runOpsProcess(array $command, string $successTitle, string $err public function submit(): void { - $state = $this->getSchema('form')?->getState() ?? []; + try { + $state = $this->getSchema('form')?->getState() ?? []; + } catch (DecryptException) { + Notification::make() + ->title('Configurazione cifrata non leggibile') + ->danger() + ->body('Uno dei segreti salvati per questo amministratore non è più decifrabile con la chiave applicativa attuale. Ripulisci o reinserisci i campi del cassetto fiscale e poi salva di nuovo.') + ->send(); + return; + } // In alcune configurazioni Filament, con statePath('data') getState() può restituire ['data' => ...]. // Normalizziamo per essere robusti. if (is_array($state['data'] ?? null)) { @@ -2042,4 +2044,54 @@ public function submit(): void ->success() ->send(); } + + /** + * @return array + */ + private function safeAmministratoreFillableState(Amministratore $amministratore): array + { + $state = []; + + foreach ([ + 'nome', + 'cognome', + 'denominazione_studio', + 'partita_iva', + 'codice_fiscale_studio', + 'indirizzo_studio', + 'cap_studio', + 'citta_studio', + 'provincia_studio', + 'telefono_studio', + 'cellulare', + 'email_studio', + 'pec_studio', + 'cartella_dati', + 'database_attivo', + 'fe_cassetto_enabled', + 'fe_cassetto_base_url', + ] as $key) { + try { + $state[$key] = $amministratore->getAttribute($key); + } catch (DecryptException) { + $state[$key] = null; + } + } + + return $state; + } + + /** + * @return array + */ + private function safeAmministratoreImpostazioni(Amministratore $amministratore): array + { + try { + $settings = $amministratore->impostazioni; + } catch (DecryptException) { + return []; + } + + return is_array($settings) ? $settings : []; + } } diff --git a/app/Http/Controllers/Integrations/GoogleOAuthController.php b/app/Http/Controllers/Integrations/GoogleOAuthController.php index 93cb1c0..59adac1 100644 --- a/app/Http/Controllers/Integrations/GoogleOAuthController.php +++ b/app/Http/Controllers/Integrations/GoogleOAuthController.php @@ -94,16 +94,17 @@ public function callback(): RedirectResponse try { $oauthContext = (array) session()->pull('google_oauth_context', []); + $redirectTo = $this->resolveRedirectTarget($oauthContext); $incomingState = (string) request()->query('state', ''); $sessionState = (string) ($oauthContext['state'] ?? ''); if ($incomingState === '' || $sessionState === '' || ! hash_equals($sessionState, $incomingState)) { - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($redirectTo) ->with('error', 'Stato OAuth non valido. Riprova il collegamento Google.'); } $code = (string) request()->query('code', ''); if ($code === '') { - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($redirectTo) ->with('error', 'Google non ha restituito il codice di autorizzazione.'); } @@ -117,7 +118,7 @@ public function callback(): RedirectResponse if (! $tokenResponse->successful()) { $message = (string) (Arr::get($tokenResponse->json(), 'error_description') ?: Arr::get($tokenResponse->json(), 'error.message') ?: 'token_exchange_failed'); - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($redirectTo) ->with('error', 'Errore scambio token Google: ' . $message); } @@ -146,6 +147,8 @@ public function callback(): RedirectResponse $refreshToken = trim((string) ($existing['refresh_token'] ?? '')); } + $setDefault = $existing === null && ! $store->isScopedAccountKey($accountKey); + $store->upsertAccount( $amministratore, $accountKey, @@ -162,12 +165,10 @@ public function callback(): RedirectResponse 'refreshed_at' => now()->toDateTimeString(), 'scopes' => self::GOOGLE_SCOPES, ], - $existing === null + $setDefault ); - $redirectTo = (string) ($oauthContext['return_to'] ?? ''); - - return redirect($redirectTo !== '' ? $redirectTo : '/admin-filament/impostazioni/scheda-amministratore') + return redirect($redirectTo) ->with('status', 'Account Google collegato con successo.'); } catch (Throwable $e) { Log::error('Google OAuth callback failed', [ @@ -175,7 +176,7 @@ public function callback(): RedirectResponse 'error' => $e->getMessage(), ]); - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($this->resolveRedirectTarget((array) session()->get('google_oauth_context', []))) ->with('error', 'Errore durante il collegamento Google: ' . $e->getMessage()); } } @@ -264,4 +265,16 @@ private function resolveCurrentUserSupplierFromCollaboratore(User $user): ?Forni return Fornitore::query()->find((int) $dipendente->fornitore_id); } + + /** + * @param array $oauthContext + */ + private function resolveRedirectTarget(array $oauthContext): string + { + $returnTo = trim((string) ($oauthContext['return_to'] ?? '')); + + return $returnTo !== '' && str_starts_with($returnTo, '/') + ? $returnTo + : '/admin-filament/impostazioni/scheda-amministratore'; + } } diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index af9e3ed..4f90bb6 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -59,8 +59,8 @@ public function panel(Panel $panel): Panel ->visible(function () { $user = Auth::user(); - return $user instanceof \App\Models\User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), NavigationItem::make('Cellulare / WebApp') ->group('NetGescon') @@ -69,8 +69,8 @@ public function panel(Panel $panel): Panel ->visible(function () { $user = Auth::user(); - return $user instanceof \App\Models\User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), NavigationItem::make('Ticket Mobile') ->group('NetGescon') @@ -79,8 +79,8 @@ public function panel(Panel $panel): Panel ->visible(function () { $user = Auth::user(); - return $user instanceof \App\Models\User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), NavigationItem::make('Scheda Stabile') ->group('NetGescon') @@ -89,8 +89,8 @@ public function panel(Panel $panel): Panel ->visible(function () { $user = Auth::user(); - return $user instanceof \App\Models\User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), NavigationItem::make('Catalogo Fornitore') ->group('Fornitore') @@ -171,5 +171,4 @@ protected function resolveSupplierCatalogUrl(): string return TicketOperativi::getUrl(panel: 'admin-filament'); } - } diff --git a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php index 7b93640..68230d9 100644 --- a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php @@ -182,7 +182,11 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); $amministratoreId = (int) ($stabile?->amministratore_id ?: 0); - $fornitoreId = $this->matchFornitoreIdForAmministratore($amministratoreId, $fornitorePiva, $fornitoreCf); + $forcedFornitoreId = (int) ($extra['force_fornitore_id'] ?? 0); + + $fornitoreId = $forcedFornitoreId > 0 + ? $forcedFornitoreId + : $this->matchFornitoreIdForAmministratore($amministratoreId, $fornitorePiva, $fornitoreCf); if (! $fornitoreId && ! empty($extra['create_fornitore_if_missing']) && $amministratoreId > 0) { try { $fornitoreId = $this->ensureFornitoreFromFeData($amministratoreId, $data, $extra); @@ -194,7 +198,7 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original // Best-effort: se il fornitore esiste, aggiorna i suoi dati base dalla FE (P.IVA/CF/denominazione, contatti, pagamenti). try { - if ($fornitoreId) { + if ($fornitoreId && empty($extra['skip_fornitore_sync'])) { $this->syncFornitoreFromFeData((int) $fornitoreId, $data, $extra); } } catch (\Throwable) { @@ -1453,9 +1457,9 @@ private function updateExisting(FatturaElettronica $existing, string $xml, ?stri $fornitorePiva = $existing->fornitore_piva ?: 'ND'; } - $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); - $adminId = (int) ($stabile?->amministratore_id ?: 0); - $fornitoreId = $this->matchFornitoreIdForAmministratore($adminId, $fornitorePiva, $fornitoreCf); + $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); + $adminId = (int) ($stabile?->amministratore_id ?: 0); + $fornitoreId = $this->matchFornitoreIdForAmministratore($adminId, $fornitorePiva, $fornitoreCf); $importRighe = $this->shouldImportRighe($fornitoreId, $extra); return DB::transaction(function () use ($existing, $data, $xml, $originalFilename, $extra, $stabileId, $fornitoreId, $fornitorePiva, $importRighe) { diff --git a/app/Support/GoogleAccountStore.php b/app/Support/GoogleAccountStore.php index 71c6a8e..610706d 100644 --- a/app/Support/GoogleAccountStore.php +++ b/app/Support/GoogleAccountStore.php @@ -55,10 +55,7 @@ public function all(Amministratore $amministratore): array } } - $defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); - if ($defaultKey === '' && $accounts !== []) { - $defaultKey = (string) array_key_first($accounts); - } + $defaultKey = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', ''))); foreach ($accounts as $key => $account) { $accounts[$key]['is_default'] = $key === $defaultKey; @@ -126,6 +123,23 @@ public function normalizeAccountKey(?string $value): string return (string) Str::of($raw)->lower()->replace('@', '-at-')->slug('-'); } + public function isScopedAccountKey(?string $accountKey): bool + { + $key = $this->normalizeAccountKey($accountKey); + + if ($key === '') { + return false; + } + + foreach (['fornitore-', 'dipendente-', 'stabile-'] as $prefix) { + if (str_starts_with($key, $prefix)) { + return true; + } + } + + return false; + } + /** * @param array $payload */ @@ -145,18 +159,15 @@ public function upsertAccount(Amministratore $amministratore, string $accountKey $accounts[$key] = $account; $google['accounts'] = $accounts; - $defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); - if ($setDefault || $defaultKey === '') { + $defaultKey = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', ''))); + $canBecomeDefault = ! $this->isScopedAccountKey($key); + + if ($setDefault && $canBecomeDefault) { $defaultKey = $key; } $google['default_account_key'] = $defaultKey; - if ($defaultKey === $key) { - $google['oauth'] = $this->toLegacyOauthPayload($account); - if (trim((string) ($account['email'] ?? '')) !== '') { - $google['workspace_email'] = trim((string) $account['email']); - } - } + $this->syncLegacyOauthState($google, $accounts, $defaultKey); $impostazioni['google'] = $google; $amministratore->impostazioni = $impostazioni; @@ -187,13 +198,9 @@ public function disconnectAccount(Amministratore $amministratore, ?string $accou 'disconnected_at' => now()->toDateTimeString(), ]); - $google['accounts'] = $accounts; - if ($this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')) === $key) { - $google['oauth'] = [ - 'connected' => false, - 'disconnected_at' => now()->toDateTimeString(), - ]; - } + $google['accounts'] = $accounts; + $google['default_account_key'] = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', ''))); + $this->syncLegacyOauthState($google, $accounts, (string) $google['default_account_key']); $impostazioni['google'] = $google; $amministratore->impostazioni = $impostazioni; @@ -332,4 +339,50 @@ private function hasUsableOauthPayload(array $oauth): bool || trim((string) ($oauth['refresh_token'] ?? '')) !== '' || trim((string) ($oauth['access_token'] ?? '')) !== ''; } + + /** + * @param array> $accounts + */ + private function resolveDefaultAccountKey(array $accounts, ?string $preferredKey = null): string + { + $candidate = $this->normalizeAccountKey($preferredKey); + if ($candidate !== '' && isset($accounts[$candidate]) && ! $this->isScopedAccountKey($candidate)) { + return $candidate; + } + + foreach ($accounts as $key => $account) { + if (! $this->isScopedAccountKey($key)) { + return $key; + } + } + + return ''; + } + + /** + * @param array $google + * @param array> $accounts + */ + private function syncLegacyOauthState(array &$google, array $accounts, string $defaultKey): void + { + $defaultKey = $this->normalizeAccountKey($defaultKey); + + if ($defaultKey !== '' && isset($accounts[$defaultKey]) && ! $this->isScopedAccountKey($defaultKey)) { + $account = $accounts[$defaultKey]; + $google['oauth'] = $this->toLegacyOauthPayload($account); + + $email = trim((string) ($account['email'] ?? '')); + if ($email !== '') { + $google['workspace_email'] = $email; + } + + return; + } + + $google['oauth'] = [ + 'connected' => false, + 'disconnected_at' => now()->toDateTimeString(), + ]; + unset($google['workspace_email']); + } } diff --git a/resources/views/filament/pages/fornitore/contabilita.blade.php b/resources/views/filament/pages/fornitore/contabilita.blade.php index cc9cb21..798044d 100644 --- a/resources/views/filament/pages/fornitore/contabilita.blade.php +++ b/resources/views/filament/pages/fornitore/contabilita.blade.php @@ -13,6 +13,10 @@
+
+ Import FE manuale disponibile dal pulsante in alto. Le fatture gia presenti finiscono come duplicate nell'archivio FE; le ricevute/notifiche SdI vengono saltate e non caricate in contabilita. +
+ Fatture ricevute Ticket Collaboratori
diff --git a/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php b/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php index 52d5851..8937ddf 100644 --- a/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php +++ b/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php @@ -6,7 +6,7 @@
Rubrica clienti fornitore
@if($this->fornitoreLabel) - Elenco clienti del singolo fornitore, con sorgente TecnoRepair `TClienti` e collegamento alla scheda condivisa di anagrafica unica quando disponibile. + Elenco clienti del singolo fornitore, con sorgente TecnoRepair `TClienti` e collegamento sicuro alla stessa rubrica fornitore quando esiste una scheda condivisa già agganciata. @else Seleziona un fornitore per aprire la rubrica clienti. @endif @@ -20,7 +20,7 @@
- Questa vista resta stretta sul fornitore, ma la scheda definitiva resta quella condivisa di anagrafica unica. Prima importa i clienti con {{ $this->importCommandHint }}, poi usa questa pagina per lavorare in modo compatto su elenco, recapiti e storico apparecchi. + Questa vista resta confinata al perimetro del fornitore. Prima importa i clienti con {{ $this->importCommandHint }}, poi usa questa pagina per lavorare in modo compatto su elenco, recapiti e storico apparecchi senza uscire nel pannello anagrafico admin.
@if($this->missingAdminContext) @@ -84,7 +84,7 @@ @if($row['rubrica_url']) - Scheda condivisa + Vai al cliente @else Non collegato @endif @@ -140,9 +140,9 @@
{{ $selected->partita_iva ?: '-' }}
- @if($selected->rubrica_id) + @if($selected->rubrica_id && $selected->rubrica_url) @endif @@ -186,7 +186,7 @@
-
Google workspace
+
Google workspace fornitore
@if($googleWorkspaceStatus)
Stato: {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}
@@ -204,7 +204,7 @@ @endif
@else -
Fornitore senza amministratore collegato: stato Google non disponibile.
+
Stato Google del fornitore non disponibile.
@endif