Fix supplier FE access and archive isolation

This commit is contained in:
michele 2026-04-15 14:26:18 +00:00
parent 2810d2c70e
commit 19453c050f
12 changed files with 896 additions and 169 deletions

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
@ -13,7 +12,8 @@ class GesconFixFattureFornitoriLinkCommand extends Command
{ {
protected $signature = 'gescon:fatture-fix-fornitore-link protected $signature = 'gescon:fatture-fix-fornitore-link
{stabile_id : ID dello stabile} {stabile_id : ID dello stabile}
{--dry-run : Non scrive, mostra solo cosa aggiornerebbe}'; {--dry-run : Non scrive, mostra solo cosa aggiornerebbe}
{--fix-mismatches : Corregge anche i fornitore_id gia valorizzati ma errati}';
protected $description = 'Backfill: collega le fatture fornitori contabili al fornitore corretto usando PIVA/CF della Fattura Elettronica.'; protected $description = 'Backfill: collega le fatture fornitori contabili al fornitore corretto usando PIVA/CF della Fattura Elettronica.';
@ -26,6 +26,7 @@ public function handle(): int
} }
$dry = (bool) $this->option('dry-run'); $dry = (bool) $this->option('dry-run');
$fixMismatches = (bool) $this->option('fix-mismatches');
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId);
if (! $stabile) { if (! $stabile) {
@ -39,13 +40,23 @@ public function handle(): int
return self::FAILURE; return self::FAILURE;
} }
$fatture = FatturaFornitore::query() $fattureQuery = FatturaFornitore::query()
->where('stabile_id', $stabileId) ->where('stabile_id', $stabileId)
->whereNull('fornitore_id') ->whereNotNull('fattura_elettronica_id');
if (! $fixMismatches) {
$fattureQuery->whereNull('fornitore_id');
}
$fatture = $fattureQuery
->orderBy('id') ->orderBy('id')
->get(['id', 'fattura_elettronica_id', 'fornitore_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; $updated = 0;
$skipped = 0; $skipped = 0;
@ -72,6 +83,11 @@ public function handle(): int
continue; continue;
} }
if ((int) ($fattura->fornitore_id ?: 0) === $fornitoreId) {
$skipped++;
continue;
}
$updated++; $updated++;
if ($dry) { if ($dry) {
@ -83,7 +99,7 @@ public function handle(): int
$fattura->fornitore_id = $fornitoreId; $fattura->fornitore_id = $fornitoreId;
$fattura->save(); $fattura->save();
if ((int) ($fe->fornitore_id ?: 0) <= 0) { if ((int) ($fe->fornitore_id ?: 0) !== $fornitoreId) {
$fe->fornitore_id = $fornitoreId; $fe->fornitore_id = $fornitoreId;
$fe->save(); $fe->save();
} }
@ -94,6 +110,7 @@ public function handle(): int
$this->line(json_encode([ $this->line(json_encode([
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'amministratore_id' => $adminId, 'amministratore_id' => $adminId,
'fix_mismatches' => $fixMismatches,
'updated' => $updated, 'updated' => $updated,
'skipped' => $skipped, 'skipped' => $skipped,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));

View File

@ -2,6 +2,7 @@
namespace App\Filament\Pages\Contabilita; namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Contabilita\FattureElettronicheArchivio; use App\Filament\Pages\Contabilita\FattureElettronicheArchivio;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Documento; use App\Models\Documento;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
use App\Models\FeXsdCode; use App\Models\FeXsdCode;
@ -34,6 +35,8 @@
class FatturaElettronicaScheda extends Page class FatturaElettronicaScheda extends Page
{ {
use ResolvesOperatoreContext;
protected static ?string $title = 'Scheda fattura ricevuta'; protected static ?string $title = 'Scheda fattura ricevuta';
protected static ?string $slug = 'contabilita/fatture-ricevute/{record}'; protected static ?string $slug = 'contabilita/fatture-ricevute/{record}';
@ -133,12 +136,17 @@ public function mount(int | string $record): void
abort(403); 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); abort(403);
} }
$this->fattura = FatturaElettronica::query()->with(['stabile', 'fornitore'])->findOrFail((int) $record); $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() $this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class) ->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id) ->where('documentable_id', (int) $this->fattura->id)

View File

@ -2,9 +2,11 @@
namespace App\Filament\Pages\Contabilita; namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda; use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
use App\Models\Documento; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Jobs\RunCassettoFiscaleDownload; use App\Jobs\RunCassettoFiscaleDownload;
use App\Models\Documento;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\User; use App\Models\User;
use App\Services\Consumi\AcquaPdfTextParser; use App\Services\Consumi\AcquaPdfTextParser;
@ -47,6 +49,7 @@
class FattureElettronicheP7mRicevute extends Page implements HasTable class FattureElettronicheP7mRicevute extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
use ResolvesOperatoreContext;
public array $quarterDownloads = []; public array $quarterDownloads = [];
@ -584,6 +587,10 @@ public static function canAccess(): bool
return true; return true;
} }
if ($user->hasRole('fornitore')) {
return true;
}
return $user->can('contabilita.fatture-elettroniche'); return $user->can('contabilita.fatture-elettroniche');
} }
@ -1744,6 +1751,17 @@ protected function getTableQuery(): Builder
return FatturaElettronica::query()->whereRaw('1 = 0'); 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); $activeStabileId = StabileContext::resolveActiveStabileId($user);
if (! $activeStabileId) { if (! $activeStabileId) {
return FatturaElettronica::query()->whereRaw('1 = 0'); return FatturaElettronica::query()->whereRaw('1 = 0');

View File

@ -1,16 +1,31 @@
<?php <?php
namespace App\Filament\Pages\Fornitore; namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Contabilita\FattureElettronicheP7mRicevute;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Amministratore;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\Stabile;
use App\Models\TicketIntervento; use App\Models\TicketIntervento;
use App\Models\User; use App\Models\User;
use App\Modules\Contabilita\Models\FatturaFornitore; use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Support\ArchivioPaths;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
use ZipArchive;
class Contabilita extends Page class Contabilita extends Page
{ {
@ -93,6 +108,121 @@ public function getCollaboratoriUrl(): string
return Collaboratori::getUrl(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 protected function loadData(): void
{ {
if (! $this->fornitore instanceof Fornitore) { if (! $this->fornitore instanceof Fornitore) {
@ -155,4 +285,422 @@ protected function loadData(): void
]) ])
->all(); ->all();
} }
/**
* @param array<string, mixed> $options
* @return array{imported:int,duplicates:int,skipped:int,errors:int,messages:array<int,string>}
*/
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<string, mixed> $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<string, mixed> $summary
* @param array<string, string> $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 ([
'<RicevutaConsegna',
'<NotificaScarto',
'<NotificaMancataConsegna',
'<NotificaEsito',
'<MetadatiInvioFile',
'<AttestazioneTrasmissioneFattura',
'<EsitoCommittente',
] as $needle) {
if (str_contains($xml, $needle)) {
return true;
}
}
return false;
}
private function getSupplierFeUploadDirectory(): string
{
if (! $this->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<string, mixed> $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<string, mixed> $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) ?? '';
}
} }

View File

@ -2,12 +2,12 @@
namespace App\Filament\Pages\Fornitore; namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\AssistenzaTecnorepairScheda; use App\Models\AssistenzaTecnorepairScheda;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreCliente; use App\Models\FornitoreCliente;
use App\Models\TicketIntervento; use App\Models\TicketIntervento;
use App\Models\User; use App\Models\User;
use App\Support\GoogleAccountStore;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -116,7 +116,7 @@ public function refreshRows(): void
->selectRaw('legacy_cliente_id, COUNT(*) as totale_schede, MAX(date_received) as ultima_data') ->selectRaw('legacy_cliente_id, COUNT(*) as totale_schede, MAX(date_received) as ultima_data')
->groupBy('legacy_cliente_id') ->groupBy('legacy_cliente_id')
->get() ->get()
->mapWithKeys(fn(AssistenzaTecnorepairScheda $row): array => [ ->mapWithKeys(fn(AssistenzaTecnorepairScheda $row): array=> [
(int) ($row->legacy_cliente_id ?? 0) => [ (int) ($row->legacy_cliente_id ?? 0) => [
'totale_schede' => (int) ($row->totale_schede ?? 0), 'totale_schede' => (int) ($row->totale_schede ?? 0),
'ultima_data' => optional($row->ultima_data)->format('d/m/Y') ?: '-', 'ultima_data' => optional($row->ultima_data)->format('d/m/Y') ?: '-',
@ -141,7 +141,6 @@ public function refreshRows(): void
}) })
->orderByRaw('CASE WHEN rubrica_id IS NULL THEN 1 ELSE 0 END') ->orderByRaw('CASE WHEN rubrica_id IS NULL THEN 1 ELSE 0 END')
->orderBy('display_name') ->orderBy('display_name')
->limit(500)
->get(); ->get();
if ($items->isEmpty()) { if ($items->isEmpty()) {
@ -162,7 +161,7 @@ public function refreshRows(): void
return [ return [
'id' => (int) $cliente->id, 'id' => (int) $cliente->id,
'legacy_cliente_id'=> (int) ($cliente->legacy_cliente_id ?? 0), 'legacy_cliente_id' => (int) ($cliente->legacy_cliente_id ?? 0),
'display_name' => (string) ($cliente->display_name ?: 'Cliente TecnoRepair'), 'display_name' => (string) ($cliente->display_name ?: 'Cliente TecnoRepair'),
'phone' => (string) ($cliente->phone ?? ''), 'phone' => (string) ($cliente->phone ?? ''),
'phone_alt' => (string) ($cliente->phone_alt ?? ''), 'phone_alt' => (string) ($cliente->phone_alt ?? ''),
@ -171,7 +170,7 @@ public function refreshRows(): void
'citta' => (string) ($cliente->citta ?? ''), 'citta' => (string) ($cliente->citta ?? ''),
'provincia' => (string) ($cliente->provincia ?? ''), 'provincia' => (string) ($cliente->provincia ?? ''),
'rubrica_id' => (int) ($cliente->rubrica_id ?? 0), 'rubrica_id' => (int) ($cliente->rubrica_id ?? 0),
'rubrica_url' => $cliente->rubrica_id ? RubricaUniversaleScheda::getUrl(['record' => (int) $cliente->rubrica_id], panel: 'admin-filament') : null, 'rubrica_url' => $cliente->rubrica_id ? $this->buildRubricaUrlForCurrentUser((int) $cliente->rubrica_id, (string) ($cliente->display_name ?? '')) : null,
'rubrica_label' => (string) ($cliente->rubrica?->categoria ?? ''), 'rubrica_label' => (string) ($cliente->rubrica?->categoria ?? ''),
'totale_schede' => (int) ($legacy['totale_schede'] ?? 0), 'totale_schede' => (int) ($legacy['totale_schede'] ?? 0),
'ultima_data' => (string) ($legacy['ultima_data'] ?? '-'), 'ultima_data' => (string) ($legacy['ultima_data'] ?? '-'),
@ -220,7 +219,7 @@ public function getSelectedClienteSchedeProperty(): array
->orderByDesc('id') ->orderByDesc('id')
->limit(30) ->limit(30)
->get() ->get()
->map(fn(AssistenzaTecnorepairScheda $scheda): array => [ ->map(fn(AssistenzaTecnorepairScheda $scheda): array=> [
'id' => (int) $scheda->id, 'id' => (int) $scheda->id,
'numero' => (string) ($scheda->legacy_numero_scheda ?: ('#' . $scheda->id)), 'numero' => (string) ($scheda->legacy_numero_scheda ?: ('#' . $scheda->id)),
'prodotto' => (string) ($scheda->product_model ?: '-'), 'prodotto' => (string) ($scheda->product_model ?: '-'),
@ -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(); $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 \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;
} }
/** /**
@ -453,13 +455,22 @@ protected function resolveGoogleWorkspaceStatus(Fornitore $fornitore): ?array
return null; 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 [ return [
'connected' => (bool) ($oauth['connected'] ?? false), 'connected' => (bool) ($account['connected'] ?? false),
'email' => (string) ($oauth['email'] ?? ''), 'email' => (string) ($account['email'] ?? ''),
'name' => (string) ($oauth['name'] ?? ''), 'name' => (string) ($account['name'] ?? ''),
'connected_at' => (string) ($oauth['connected_at'] ?? ''), 'connected_at' => (string) ($account['connected_at'] ?? ''),
]; ];
} }
} }

View File

@ -27,6 +27,7 @@
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema as FilamentSchema; use Filament\Schemas\Schema as FilamentSchema;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -89,7 +90,9 @@ class SchedaAmministratore extends Page implements HasForms
/** @var array<int,array<string,mixed>> */ /** @var array<int,array<string,mixed>> */
public array $collaboratoreRubricaMatches = []; public array $collaboratoreRubricaMatches = [];
public Amministratore $amministratore; protected Amministratore $amministratore;
public int $amministratoreId = 0;
public static function canAccess(): bool public static function canAccess(): bool
{ {
@ -127,39 +130,29 @@ public function mount(): void
} }
$this->amministratore = $amministratore; $this->amministratore = $amministratore;
$this->amministratoreId = (int) $amministratore->id;
$isSuperAdmin = $user->hasAnyRole(['super-admin', 'admin']); $isSuperAdmin = $user->hasAnyRole(['super-admin', 'admin']);
$this->getSchema('form')?->fill([ $this->getSchema('form')?->fill([
...$this->amministratore->only([ ...$this->safeAmministratoreFillableState($this->amministratore),
'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',
]),
// Per sicurezza non precompiliamo mai i segreti nel form. // Per sicurezza non precompiliamo mai i segreti nel form.
// La configurazione completa è gestita dal Super Admin. // La configurazione completa è gestita dal Super Admin.
'fe_cassetto_password_script' => $isSuperAdmin ? null : null, 'fe_cassetto_password_script' => $isSuperAdmin ? null : null,
'fe_cassetto_username' => $isSuperAdmin ? null : null, 'fe_cassetto_username' => $isSuperAdmin ? null : null,
'fe_cassetto_password' => $isSuperAdmin ? null : null, 'fe_cassetto_password' => $isSuperAdmin ? null : null,
'fe_cassetto_pin' => $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 public function form(FilamentSchema $schema): FilamentSchema
{ {
return $schema return $schema
@ -1995,7 +1988,16 @@ private function runOpsProcess(array $command, string $successTitle, string $err
public function submit(): void public function submit(): void
{ {
try {
$state = $this->getSchema('form')?->getState() ?? []; $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' => ...]. // In alcune configurazioni Filament, con statePath('data') getState() può restituire ['data' => ...].
// Normalizziamo per essere robusti. // Normalizziamo per essere robusti.
if (is_array($state['data'] ?? null)) { if (is_array($state['data'] ?? null)) {
@ -2042,4 +2044,54 @@ public function submit(): void
->success() ->success()
->send(); ->send();
} }
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
private function safeAmministratoreImpostazioni(Amministratore $amministratore): array
{
try {
$settings = $amministratore->impostazioni;
} catch (DecryptException) {
return [];
}
return is_array($settings) ? $settings : [];
}
} }

View File

@ -94,16 +94,17 @@ public function callback(): RedirectResponse
try { try {
$oauthContext = (array) session()->pull('google_oauth_context', []); $oauthContext = (array) session()->pull('google_oauth_context', []);
$redirectTo = $this->resolveRedirectTarget($oauthContext);
$incomingState = (string) request()->query('state', ''); $incomingState = (string) request()->query('state', '');
$sessionState = (string) ($oauthContext['state'] ?? ''); $sessionState = (string) ($oauthContext['state'] ?? '');
if ($incomingState === '' || $sessionState === '' || ! hash_equals($sessionState, $incomingState)) { 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.'); ->with('error', 'Stato OAuth non valido. Riprova il collegamento Google.');
} }
$code = (string) request()->query('code', ''); $code = (string) request()->query('code', '');
if ($code === '') { if ($code === '') {
return redirect('/admin-filament/impostazioni/scheda-amministratore') return redirect($redirectTo)
->with('error', 'Google non ha restituito il codice di autorizzazione.'); ->with('error', 'Google non ha restituito il codice di autorizzazione.');
} }
@ -117,7 +118,7 @@ public function callback(): RedirectResponse
if (! $tokenResponse->successful()) { if (! $tokenResponse->successful()) {
$message = (string) (Arr::get($tokenResponse->json(), 'error_description') ?: Arr::get($tokenResponse->json(), 'error.message') ?: 'token_exchange_failed'); $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); ->with('error', 'Errore scambio token Google: ' . $message);
} }
@ -146,6 +147,8 @@ public function callback(): RedirectResponse
$refreshToken = trim((string) ($existing['refresh_token'] ?? '')); $refreshToken = trim((string) ($existing['refresh_token'] ?? ''));
} }
$setDefault = $existing === null && ! $store->isScopedAccountKey($accountKey);
$store->upsertAccount( $store->upsertAccount(
$amministratore, $amministratore,
$accountKey, $accountKey,
@ -162,12 +165,10 @@ public function callback(): RedirectResponse
'refreshed_at' => now()->toDateTimeString(), 'refreshed_at' => now()->toDateTimeString(),
'scopes' => self::GOOGLE_SCOPES, 'scopes' => self::GOOGLE_SCOPES,
], ],
$existing === null $setDefault
); );
$redirectTo = (string) ($oauthContext['return_to'] ?? ''); return redirect($redirectTo)
return redirect($redirectTo !== '' ? $redirectTo : '/admin-filament/impostazioni/scheda-amministratore')
->with('status', 'Account Google collegato con successo.'); ->with('status', 'Account Google collegato con successo.');
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('Google OAuth callback failed', [ Log::error('Google OAuth callback failed', [
@ -175,7 +176,7 @@ public function callback(): RedirectResponse
'error' => $e->getMessage(), '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()); ->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); return Fornitore::query()->find((int) $dipendente->fornitore_id);
} }
/**
* @param array<string, mixed> $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';
}
} }

View File

@ -59,7 +59,7 @@ public function panel(Panel $panel): Panel
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Cellulare / WebApp') NavigationItem::make('Cellulare / WebApp')
@ -69,7 +69,7 @@ public function panel(Panel $panel): Panel
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Ticket Mobile') NavigationItem::make('Ticket Mobile')
@ -79,7 +79,7 @@ public function panel(Panel $panel): Panel
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Scheda Stabile') NavigationItem::make('Scheda Stabile')
@ -89,7 +89,7 @@ public function panel(Panel $panel): Panel
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Catalogo Fornitore') NavigationItem::make('Catalogo Fornitore')
@ -171,5 +171,4 @@ protected function resolveSupplierCatalogUrl(): string
return TicketOperativi::getUrl(panel: 'admin-filament'); return TicketOperativi::getUrl(panel: 'admin-filament');
} }
} }

View File

@ -182,7 +182,11 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId); $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId);
$amministratoreId = (int) ($stabile?->amministratore_id ?: 0); $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) { if (! $fornitoreId && ! empty($extra['create_fornitore_if_missing']) && $amministratoreId > 0) {
try { try {
$fornitoreId = $this->ensureFornitoreFromFeData($amministratoreId, $data, $extra); $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). // Best-effort: se il fornitore esiste, aggiorna i suoi dati base dalla FE (P.IVA/CF/denominazione, contatti, pagamenti).
try { try {
if ($fornitoreId) { if ($fornitoreId && empty($extra['skip_fornitore_sync'])) {
$this->syncFornitoreFromFeData((int) $fornitoreId, $data, $extra); $this->syncFornitoreFromFeData((int) $fornitoreId, $data, $extra);
} }
} catch (\Throwable) { } catch (\Throwable) {

View File

@ -55,10 +55,7 @@ public function all(Amministratore $amministratore): array
} }
} }
$defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); $defaultKey = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')));
if ($defaultKey === '' && $accounts !== []) {
$defaultKey = (string) array_key_first($accounts);
}
foreach ($accounts as $key => $account) { foreach ($accounts as $key => $account) {
$accounts[$key]['is_default'] = $key === $defaultKey; $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('-'); 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<string, mixed> $payload * @param array<string, mixed> $payload
*/ */
@ -145,18 +159,15 @@ public function upsertAccount(Amministratore $amministratore, string $accountKey
$accounts[$key] = $account; $accounts[$key] = $account;
$google['accounts'] = $accounts; $google['accounts'] = $accounts;
$defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); $defaultKey = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')));
if ($setDefault || $defaultKey === '') { $canBecomeDefault = ! $this->isScopedAccountKey($key);
if ($setDefault && $canBecomeDefault) {
$defaultKey = $key; $defaultKey = $key;
} }
$google['default_account_key'] = $defaultKey; $google['default_account_key'] = $defaultKey;
if ($defaultKey === $key) { $this->syncLegacyOauthState($google, $accounts, $defaultKey);
$google['oauth'] = $this->toLegacyOauthPayload($account);
if (trim((string) ($account['email'] ?? '')) !== '') {
$google['workspace_email'] = trim((string) $account['email']);
}
}
$impostazioni['google'] = $google; $impostazioni['google'] = $google;
$amministratore->impostazioni = $impostazioni; $amministratore->impostazioni = $impostazioni;
@ -188,12 +199,8 @@ public function disconnectAccount(Amministratore $amministratore, ?string $accou
]); ]);
$google['accounts'] = $accounts; $google['accounts'] = $accounts;
if ($this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')) === $key) { $google['default_account_key'] = $this->resolveDefaultAccountKey($accounts, $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')));
$google['oauth'] = [ $this->syncLegacyOauthState($google, $accounts, (string) $google['default_account_key']);
'connected' => false,
'disconnected_at' => now()->toDateTimeString(),
];
}
$impostazioni['google'] = $google; $impostazioni['google'] = $google;
$amministratore->impostazioni = $impostazioni; $amministratore->impostazioni = $impostazioni;
@ -332,4 +339,50 @@ private function hasUsableOauthPayload(array $oauth): bool
|| trim((string) ($oauth['refresh_token'] ?? '')) !== '' || trim((string) ($oauth['refresh_token'] ?? '')) !== ''
|| trim((string) ($oauth['access_token'] ?? '')) !== ''; || trim((string) ($oauth['access_token'] ?? '')) !== '';
} }
/**
* @param array<string, array<string, mixed>> $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<string, mixed> $google
* @param array<string, array<string, mixed>> $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']);
}
} }

View File

@ -13,6 +13,10 @@
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div class="rounded-md border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs text-sky-900">
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.
</div>
<a href="{{ $this->getFattureRicevuteUrl() }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Fatture ricevute</a>
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Ticket</a> <a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Ticket</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collaboratori</a> <a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collaboratori</a>
</div> </div>

View File

@ -6,7 +6,7 @@
<div class="text-lg font-semibold">Rubrica clienti fornitore</div> <div class="text-lg font-semibold">Rubrica clienti fornitore</div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
@if($this->fornitoreLabel) @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 @else
Seleziona un fornitore per aprire la rubrica clienti. Seleziona un fornitore per aprire la rubrica clienti.
@endif @endif
@ -20,7 +20,7 @@
</div> </div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700"> <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
Questa vista resta stretta sul fornitore, ma la scheda definitiva resta quella condivisa di anagrafica unica. Prima importa i clienti con <span class="font-mono text-xs">{{ $this->importCommandHint }}</span>, 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 <span class="font-mono text-xs">{{ $this->importCommandHint }}</span>, poi usa questa pagina per lavorare in modo compatto su elenco, recapiti e storico apparecchi senza uscire nel pannello anagrafico admin.
</div> </div>
@if($this->missingAdminContext) @if($this->missingAdminContext)
@ -84,7 +84,7 @@
</td> </td>
<td class="border px-2 py-2 align-top"> <td class="border px-2 py-2 align-top">
@if($row['rubrica_url']) @if($row['rubrica_url'])
<a href="{{ $row['rubrica_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-indigo-700 ring-1 ring-inset ring-indigo-300 hover:bg-indigo-50">Scheda condivisa</a> <a href="{{ $row['rubrica_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-indigo-700 ring-1 ring-inset ring-indigo-300 hover:bg-indigo-50">Vai al cliente</a>
@else @else
<span class="text-[11px] text-amber-700">Non collegato</span> <span class="text-[11px] text-amber-700">Non collegato</span>
@endif @endif
@ -140,9 +140,9 @@
<div class="mt-1">{{ $selected->partita_iva ?: '-' }}</div> <div class="mt-1">{{ $selected->partita_iva ?: '-' }}</div>
</div> </div>
</div> </div>
@if($selected->rubrica_id) @if($selected->rubrica_id && $selected->rubrica_url)
<div class="mt-4"> <div class="mt-4">
<a href="{{ \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $selected->rubrica_id], panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Apri scheda anagrafica condivisa</a> <a href="{{ $selected->rubrica_url }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Apri vista cliente collegata</a>
</div> </div>
@endif @endif
</div> </div>
@ -186,7 +186,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Google workspace</div> <div class="text-sm font-semibold">Google workspace fornitore</div>
@if($googleWorkspaceStatus) @if($googleWorkspaceStatus)
<div class="mt-3 space-y-2 text-sm"> <div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div> <div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div>
@ -204,7 +204,7 @@
@endif @endif
</div> </div>
@else @else
<div class="mt-3 text-sm text-gray-500">Fornitore senza amministratore collegato: stato Google non disponibile.</div> <div class="mt-3 text-sm text-gray-500">Stato Google del fornitore non disponibile.</div>
@endif @endif
</div> </div>