diff --git a/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php b/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php index e643f88..abb9ae4 100644 --- a/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php +++ b/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php @@ -34,13 +34,23 @@ public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogServ return self::FAILURE; } - $mdbPath = realpath((string) $this->option('mdb')) ?: (string) $this->option('mdb'); + $rawMdbPath = trim((string) $this->option('mdb')); + $mdbPath = $rawMdbPath !== '' && file_exists($rawMdbPath) + ? (realpath($rawMdbPath) ?: $rawMdbPath) + : $rawMdbPath; + if ($mdbPath === '' || ! is_file($mdbPath)) { $this->error('Archivio MDB non trovato: ' . $mdbPath); return self::FAILURE; } + if (! is_readable($mdbPath)) { + $this->error('Archivio MDB non leggibile: ' . $mdbPath); + + return self::FAILURE; + } + $fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term')); $limit = max(0, (int) $this->option('limit')); $dryRun = (bool) $this->option('dry-run'); diff --git a/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php b/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php index 010fe98..97c286a 100644 --- a/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php +++ b/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php @@ -4,12 +4,15 @@ use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Filament\Pages\Gescon\FornitoreScheda; use App\Models\Fornitore; +use App\Models\FornitoreStabileImpostazione; +use App\Models\Stabile; use App\Models\User; use App\Support\ArchivioPaths; use BackedEnum; use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; @@ -53,6 +56,19 @@ class ImpostazioniArchivio extends Page /** @var array|null */ public ?array $googleWorkspaceStatus = null; + public bool $operationalConfigAvailable = false; + + /** @var array */ + public array $stabileOptions = []; + + /** @var array> */ + public array $stabileAccessRows = []; + + public string $dispatchBoardNote = ''; + + /** @var array> */ + public array $preferredMaterialRows = []; + public static function canAccess(): bool { $user = auth()->user(); @@ -167,6 +183,8 @@ public function getProdottiUrl(): string public function getGoogleConnectUrl(): string { return route('oauth.google.redirect', [ + 'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0), + 'label' => trim((string) ($this->fornitore?->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) ($this->fornitore?->id ?? 0)), 'return_to' => request()->getRequestUri(), ]); } @@ -174,14 +192,161 @@ public function getGoogleConnectUrl(): string public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ + 'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0), 'return_to' => request()->getRequestUri(), ]); } + public function addStabileAccessRow(): void + { + $unusedStableId = null; + $usedIds = collect($this->stabileAccessRows) + ->pluck('stabile_id') + ->filter(fn($value): bool => (int) $value > 0) + ->map(fn($value): int => (int) $value) + ->all(); + + foreach (array_keys($this->stabileOptions) as $stabileId) { + if (! in_array((int) $stabileId, $usedIds, true)) { + $unusedStableId = (int) $stabileId; + break; + } + } + + $this->stabileAccessRows[] = [ + 'stabile_id' => $unusedStableId, + 'chi_suonare' => '', + 'contatto_nome' => '', + 'contatto_telefono' => '', + 'dove_sono_chiavi' => '', + 'istruzioni_accesso' => '', + 'fascia_reperibilita' => '', + 'note_urgenza' => '', + 'is_priority' => false, + ]; + } + + public function removeStabileAccessRow(int $index): void + { + unset($this->stabileAccessRows[$index]); + $this->stabileAccessRows = array_values($this->stabileAccessRows); + } + + public function addPreferredMaterialRow(): void + { + $this->preferredMaterialRows[] = [ + 'label' => '', + 'brand_model' => '', + 'barcode' => '', + 'internal_code' => '', + 'preferred_source' => '', + 'preferred_url' => '', + 'default_qty' => 1, + 'last_price' => null, + 'currency' => 'EUR', + 'note' => '', + ]; + } + + public function removePreferredMaterialRow(int $index): void + { + unset($this->preferredMaterialRows[$index]); + $this->preferredMaterialRows = array_values($this->preferredMaterialRows); + } + + public function saveOperationalWorkflow(): void + { + if (! $this->fornitore instanceof Fornitore) { + return; + } + + $normalizedAccessRows = collect($this->stabileAccessRows) + ->map(fn(array $row): ?array => $this->normalizeStabileAccessRow($row)) + ->filter() + ->values() + ->all(); + + $selectedStableIds = collect($normalizedAccessRows) + ->pluck('stabile_id') + ->map(fn($value): int => (int) $value) + ->all(); + + $existingSettings = FornitoreStabileImpostazione::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->get() + ->keyBy(fn(FornitoreStabileImpostazione $setting): int => (int) $setting->stabile_id); + + foreach ($normalizedAccessRows as $row) { + $setting = $existingSettings->get((int) $row['stabile_id']) ?? new FornitoreStabileImpostazione([ + 'fornitore_id' => (int) $this->fornitore->id, + 'stabile_id' => (int) $row['stabile_id'], + ]); + + $meta = is_array($setting->meta ?? null) ? $setting->meta : []; + $meta['access_operativo'] = $row; + $setting->fornitore_id = (int) $this->fornitore->id; + $setting->stabile_id = (int) $row['stabile_id']; + $setting->meta = $meta; + $setting->save(); + } + + foreach ($existingSettings as $stabileId => $setting) { + if (in_array((int) $stabileId, $selectedStableIds, true)) { + continue; + } + + $meta = is_array($setting->meta ?? null) ? $setting->meta : []; + if (! array_key_exists('access_operativo', $meta)) { + continue; + } + + unset($meta['access_operativo']); + + if ($meta === [] && ! $setting->voce_spesa_default_id && ! $setting->conto_costo_default_id) { + $setting->delete(); + continue; + } + + $setting->meta = $meta; + $setting->save(); + } + + if ($this->operationalConfigAvailable) { + $config = $this->fornitore->operational_config_safe; + + $dispatchBoardNote = trim($this->dispatchBoardNote); + if ($dispatchBoardNote === '') { + unset($config['dispatch_board_note']); + } else { + $config['dispatch_board_note'] = $dispatchBoardNote; + } + + $config['preferred_materials'] = collect($this->preferredMaterialRows) + ->map(fn(array $row): ?array => $this->normalizePreferredMaterialRow($row)) + ->filter() + ->values() + ->all(); + + $this->fornitore->operational_config = $config; + $this->fornitore->save(); + } + + $this->refreshArchiveSummary(); + + Notification::make() + ->title('Workflow operativo aggiornato') + ->body($this->operationalConfigAvailable + ? 'Chiavi, reperibilità e memoria materiali sono stati salvati.' + : 'Chiavi e reperibilità per stabile salvate. Esegui la migrazione per attivare anche i preferiti materiali.') + ->success() + ->send(); + } + private function refreshArchiveSummary(): void { $relativeBase = $this->getArchiveRelativeBase(); $absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null; + $this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config'); if ($relativeBase !== null) { foreach ([ @@ -222,16 +387,154 @@ private function refreshArchiveSummary(): void $stats = $this->fornitore?->tecnorepair_stats ?? ['total' => 0, 'open' => 0, 'closed' => 0, 'serials' => 0]; $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); + $this->loadOperationalWorkflowSettings(); $this->moduleRows = [ ['module' => 'Rubrica fornitore', 'status' => 'pronto', 'note' => 'Vista fornitore filtrata ma agganciata all\'anagrafica unica.'], ['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Rubrica e sincronizzazioni smartphone riusano l\'account Google del contesto amministratore.'], ['module' => 'TecnoRepair MDB', 'status' => ((int) ($stats['total'] ?? 0) > 0) ? 'importato' : 'da importare', 'note' => 'Schede: ' . (int) ($stats['total'] ?? 0) . ' · aperte: ' . (int) ($stats['open'] ?? 0) . ' · chiuse: ' . (int) ($stats['closed'] ?? 0)], ['module' => 'Catalogo fornitore', 'status' => 'attivo', 'note' => 'Il catalogo vero resta nella pagina Prodotti.'], + ['module' => 'Chiavi e reperibilità', 'status' => count($this->stabileAccessRows) > 0 ? 'configurato' : 'da configurare', 'note' => 'Istruzioni per accesso, chi suonare e dove recuperare chiavi per ciascuno stabile.'], + ['module' => 'Memoria materiali', 'status' => count($this->preferredMaterialRows) > 0 ? 'attiva' : ($this->operationalConfigAvailable ? 'da compilare' : 'migrazione richiesta'), 'note' => 'Preferiti ricorrenti per lampade, ricambi e prodotti ripetuti dal fornitore.'], ['module' => 'Comunicazioni / Post-it', 'status' => 'predisposto', 'note' => 'Cartelle e contesto archivio pronti per agganciare chiamate, post-it ed email.'], ['module' => 'FE e documenti', 'status' => 'predisposto', 'note' => 'Archivio separato per FE, documenti e flussi contabili del fornitore.'], ]; } + private function loadOperationalWorkflowSettings(): void + { + if (! $this->fornitore instanceof Fornitore) { + $this->stabileOptions = []; + $this->stabileAccessRows = []; + $this->dispatchBoardNote = ''; + $this->preferredMaterialRows = []; + return; + } + + $adminId = (int) ($this->fornitore->amministratore_id ?? 0); + + $stabili = Stabile::query() + ->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId)) + ->when(Schema::hasColumn('stabili', 'attivo'), fn($query) => $query->where('attivo', true)) + ->orderBy('codice_stabile') + ->orderBy('denominazione') + ->get(['id', 'codice_stabile', 'denominazione']); + + $this->stabileOptions = $stabili + ->mapWithKeys(fn(Stabile $stabile): array => [ + (int) $stabile->id => trim((string) ($stabile->codice_stabile ?: ('#' . $stabile->id)) . ' - ' . (string) ($stabile->denominazione ?: 'Stabile')), + ]) + ->all(); + + $accessRows = FornitoreStabileImpostazione::query() + ->with('stabile:id,codice_stabile,denominazione') + ->where('fornitore_id', (int) $this->fornitore->id) + ->when($stabili->isNotEmpty(), fn($query) => $query->whereIn('stabile_id', $stabili->pluck('id')->all())) + ->get() + ->map(function (FornitoreStabileImpostazione $setting): ?array { + $meta = is_array($setting->meta ?? null) ? $setting->meta : []; + $access = is_array($meta['access_operativo'] ?? null) ? $meta['access_operativo'] : null; + + if (! is_array($access)) { + return null; + } + + return [ + 'stabile_id' => (int) ($setting->stabile_id ?? 0), + 'chi_suonare' => trim((string) ($access['chi_suonare'] ?? '')), + 'contatto_nome' => trim((string) ($access['contatto_nome'] ?? '')), + 'contatto_telefono' => trim((string) ($access['contatto_telefono'] ?? '')), + 'dove_sono_chiavi' => trim((string) ($access['dove_sono_chiavi'] ?? '')), + 'istruzioni_accesso' => trim((string) ($access['istruzioni_accesso'] ?? '')), + 'fascia_reperibilita' => trim((string) ($access['fascia_reperibilita'] ?? '')), + 'note_urgenza' => trim((string) ($access['note_urgenza'] ?? '')), + 'is_priority' => (bool) ($access['is_priority'] ?? false), + ]; + }) + ->filter() + ->sortByDesc(fn(array $row): int => $row['is_priority'] ? 1 : 0) + ->values() + ->all(); + + $this->stabileAccessRows = $accessRows; + + $config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : []; + + $this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? '')); + $this->preferredMaterialRows = collect((array) ($config['preferred_materials'] ?? [])) + ->map(function ($row): ?array { + if (! is_array($row)) { + return null; + } + + return [ + 'label' => trim((string) ($row['label'] ?? '')), + 'brand_model' => trim((string) ($row['brand_model'] ?? '')), + 'barcode' => trim((string) ($row['barcode'] ?? '')), + 'internal_code' => trim((string) ($row['internal_code'] ?? '')), + 'preferred_source' => trim((string) ($row['preferred_source'] ?? '')), + 'preferred_url' => trim((string) ($row['preferred_url'] ?? '')), + 'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? (float) $row['default_qty'] : 1, + 'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? (float) $row['last_price'] : null, + 'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR', + 'note' => trim((string) ($row['note'] ?? '')), + ]; + }) + ->filter() + ->values() + ->all(); + } + + private function normalizeStabileAccessRow(array $row): ?array + { + $stabileId = (int) ($row['stabile_id'] ?? 0); + if ($stabileId <= 0) { + return null; + } + + $normalized = [ + 'stabile_id' => $stabileId, + 'chi_suonare' => trim((string) ($row['chi_suonare'] ?? '')), + 'contatto_nome' => trim((string) ($row['contatto_nome'] ?? '')), + 'contatto_telefono' => trim((string) ($row['contatto_telefono'] ?? '')), + 'dove_sono_chiavi' => trim((string) ($row['dove_sono_chiavi'] ?? '')), + 'istruzioni_accesso' => trim((string) ($row['istruzioni_accesso'] ?? '')), + 'fascia_reperibilita' => trim((string) ($row['fascia_reperibilita'] ?? '')), + 'note_urgenza' => trim((string) ($row['note_urgenza'] ?? '')), + 'is_priority' => (bool) ($row['is_priority'] ?? false), + ]; + + $hasContent = collect($normalized) + ->except(['stabile_id', 'is_priority']) + ->contains(fn($value): bool => is_string($value) && $value !== ''); + + return $hasContent ? $normalized : null; + } + + private function normalizePreferredMaterialRow(array $row): ?array + { + $label = trim((string) ($row['label'] ?? '')); + $brandModel = trim((string) ($row['brand_model'] ?? '')); + $barcode = trim((string) ($row['barcode'] ?? '')); + $internalCode = trim((string) ($row['internal_code'] ?? '')); + + if ($label === '' && $brandModel === '' && $barcode === '' && $internalCode === '') { + return null; + } + + return [ + 'label' => $label, + 'brand_model' => $brandModel, + 'barcode' => $barcode, + 'internal_code' => $internalCode, + 'preferred_source' => trim((string) ($row['preferred_source'] ?? '')), + 'preferred_url' => trim((string) ($row['preferred_url'] ?? '')), + 'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? max(0.01, round((float) $row['default_qty'], 2)) : 1.0, + 'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : null, + 'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR', + 'note' => trim((string) ($row['note'] ?? '')), + ]; + } + /** * @return array|null */ diff --git a/app/Filament/Pages/Fornitore/RubricaClienti.php b/app/Filament/Pages/Fornitore/RubricaClienti.php index 4437960..8f61acb 100644 --- a/app/Filament/Pages/Fornitore/RubricaClienti.php +++ b/app/Filament/Pages/Fornitore/RubricaClienti.php @@ -147,6 +147,8 @@ public function getProdottiUrl(): string public function getGoogleConnectUrl(): string { return route('oauth.google.redirect', [ + 'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0), + 'label' => trim((string) ($this->fornitoreLabel ?? '')) ?: ('Fornitore #' . (int) ($this->fornitoreId ?? 0)), 'return_to' => request()->getRequestUri(), ]); } @@ -154,6 +156,7 @@ public function getGoogleConnectUrl(): string public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ + 'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0), 'return_to' => request()->getRequestUri(), ]); } diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index bf414d5..ab213dd 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -8,6 +8,7 @@ use App\Filament\Pages\Gescon\StabileScheda; use App\Models\Fornitore; use App\Models\FornitoreDipendente; +use App\Models\FornitoreStabileImpostazione; use App\Models\Product; use App\Models\ProductIdentifier; use App\Models\ProductOffer; @@ -86,6 +87,12 @@ class TicketInterventoScheda extends Page /** @var array> */ public array $materialSuggestions = []; + /** @var array> */ + public array $preferredMaterialRows = []; + + /** @var array> */ + public array $historicalMaterialRows = []; + public string $apparatoMarca = ''; public string $apparatoModello = ''; @@ -124,6 +131,13 @@ class TicketInterventoScheda extends Page /** @var array|null */ public ?array $googleWorkspaceStatus = null; + /** @var array> */ + public array $accessSummaryRows = []; + + public string $dispatchBoardNote = ''; + + public bool $operationalConfigAvailable = false; + public bool $sessionTrackingAvailable = false; public static function canAccess(): bool @@ -439,6 +453,66 @@ public function removeMateriale(int $index): void $this->materialiUtilizzati = array_values($this->materialiUtilizzati); } + public function addPreferredMaterial(int $index): void + { + $row = $this->preferredMaterialRows[$index] ?? null; + if (! is_array($row)) { + return; + } + + $this->appendMaterialFromMemory($row, 'Materiale preferito aggiunto'); + } + + public function addHistoricalMaterial(int $index): void + { + $row = $this->historicalMaterialRows[$index] ?? null; + if (! is_array($row)) { + return; + } + + $this->appendMaterialFromMemory($row, 'Materiale storico aggiunto'); + } + + public function saveCurrentMaterialAsPreferred(int $index): void + { + if (! $this->fornitore instanceof Fornitore) { + return; + } + + if (! Schema::hasColumn('fornitori', 'operational_config')) { + Notification::make()->title('Migrazione richiesta')->body('Esegui la migrazione che aggiunge operational_config ai fornitori per attivare i preferiti materiali.')->warning()->send(); + return; + } + + $current = $this->materialiUtilizzati[$index] ?? null; + if (! is_array($current)) { + return; + } + + $normalized = $this->normalizePreferredMaterialConfig([ + 'label' => (string) ($current['descrizione'] ?? ''), + 'brand_model' => '', + 'barcode' => (string) ($current['barcode'] ?? ''), + 'internal_code' => '', + 'preferred_source' => (string) ($current['source_label'] ?? ''), + 'preferred_url' => (string) ($current['external_url'] ?? ''), + 'default_qty' => isset($current['qty']) ? (float) $current['qty'] : 1, + 'last_price' => isset($current['unit_price']) ? (float) $current['unit_price'] : null, + 'currency' => (string) ($current['currency'] ?? 'EUR'), + 'note' => 'Salvato dalla scheda intervento #' . (int) $this->intervento->ticket_id, + ]); + + if ($normalized === null) { + Notification::make()->title('Materiale non valido')->warning()->send(); + return; + } + + $this->persistPreferredMaterial($normalized); + $this->loadMaterialMemory(); + + Notification::make()->title('Materiale salvato nei preferiti')->body((string) ($normalized['label'] ?? 'Materiale'))->success()->send(); + } + public function saveRapporto(): void { $validated = $this->validate([ @@ -708,6 +782,8 @@ public function getProdottiUrl(): string public function getGoogleConnectUrl(): string { return route('oauth.google.redirect', [ + 'account_key' => 'fornitore-' . (int) $this->fornitore->id, + 'label' => trim((string) ($this->fornitore->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) $this->fornitore->id), 'return_to' => request()->getRequestUri(), ]); } @@ -715,6 +791,7 @@ public function getGoogleConnectUrl(): string public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ + 'account_key' => 'fornitore-' . (int) $this->fornitore->id, 'return_to' => request()->getRequestUri(), ]); } @@ -815,6 +892,7 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin protected function reloadIntervento(): void { $this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni'); + $this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config'); $relations = [ 'ticket.stabile', @@ -919,6 +997,8 @@ protected function reloadIntervento(): void ] : null; $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); + $this->loadAccessSummary(); + $this->loadMaterialMemory(); $this->qrToken = ''; } @@ -1012,6 +1092,8 @@ protected function formatGeoPoint(mixed $lat, mixed $lng, mixed $accuracy): stri protected function refreshMaterialSuggestions(): void { $term = trim($this->materialSearch); + $this->loadMaterialMemory(); + if ($term === '') { $this->materialSuggestions = []; return; @@ -1060,6 +1142,251 @@ protected function refreshMaterialSuggestions(): void })->all(); } + protected function loadAccessSummary(): void + { + $this->accessSummaryRows = []; + $this->dispatchBoardNote = ''; + + if (! $this->fornitore instanceof Fornitore) { + return; + } + + $config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : []; + $this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? '')); + + $stabileId = (int) ($this->intervento->ticket?->stabile_id ?? 0); + if ($stabileId <= 0) { + return; + } + + $setting = FornitoreStabileImpostazione::query() + ->with('stabile:id,codice_stabile,denominazione') + ->where('fornitore_id', (int) $this->fornitore->id) + ->where('stabile_id', $stabileId) + ->first(); + + if (! $setting instanceof FornitoreStabileImpostazione) { + return; + } + + $meta = is_array($setting->meta ?? null) ? $setting->meta : []; + $access = is_array($meta['access_operativo'] ?? null) ? $meta['access_operativo'] : null; + + if (! is_array($access)) { + return; + } + + $this->accessSummaryRows = [[ + 'stabile_label' => trim((string) (($setting->stabile?->codice_stabile ?: ('#' . $stabileId)) . ' - ' . ($setting->stabile?->denominazione ?: 'Stabile'))), + 'chi_suonare' => trim((string) ($access['chi_suonare'] ?? '')), + 'contatto_nome' => trim((string) ($access['contatto_nome'] ?? '')), + 'contatto_telefono' => trim((string) ($access['contatto_telefono'] ?? '')), + 'dove_sono_chiavi' => trim((string) ($access['dove_sono_chiavi'] ?? '')), + 'istruzioni_accesso' => trim((string) ($access['istruzioni_accesso'] ?? '')), + 'fascia_reperibilita' => trim((string) ($access['fascia_reperibilita'] ?? '')), + 'note_urgenza' => trim((string) ($access['note_urgenza'] ?? '')), + 'is_priority' => (bool) ($access['is_priority'] ?? false), + ]]; + } + + protected function loadMaterialMemory(): void + { + $term = trim($this->materialSearch); + $normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? ''; + + $preferredRows = $this->operationalConfigAvailable + ? collect((array) ($this->fornitore->operational_config_safe['preferred_materials'] ?? [])) + : collect(); + + $this->preferredMaterialRows = $preferredRows + ->map(fn($row): ?array => is_array($row) ? $this->normalizePreferredMaterialConfig($row) : null) + ->filter() + ->filter(fn(array $row): bool => $this->matchesMaterialSearch($row, $term, $normalized)) + ->values() + ->all(); + + $recentInterventi = TicketIntervento::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->where('id', '!=', (int) $this->intervento->id) + ->whereNotNull('materiali_utilizzati') + ->latest('updated_at') + ->limit(80) + ->get(['id', 'ticket_id', 'materiali_utilizzati', 'updated_at']); + + $aggregated = []; + + foreach ($recentInterventi as $intervento) { + foreach ((array) ($intervento->materiali_utilizzati ?? []) as $item) { + if (! is_array($item)) { + continue; + } + + $memory = $this->normalizePreferredMaterialConfig([ + 'label' => (string) ($item['descrizione'] ?? ''), + 'brand_model' => '', + 'barcode' => (string) ($item['barcode'] ?? ''), + 'internal_code' => '', + 'preferred_source' => (string) ($item['source_label'] ?? ''), + 'preferred_url' => (string) ($item['external_url'] ?? ''), + 'default_qty' => isset($item['qty']) ? (float) $item['qty'] : 1, + 'last_price' => isset($item['unit_price']) ? (float) $item['unit_price'] : null, + 'currency' => (string) ($item['currency'] ?? 'EUR'), + 'note' => '', + ]); + + if ($memory === null || ! $this->matchesMaterialSearch($memory, $term, $normalized)) { + continue; + } + + $key = $this->buildMaterialMemoryKey($memory); + if (! isset($aggregated[$key])) { + $aggregated[$key] = array_merge($memory, [ + 'usage_count' => 0, + 'last_used_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', + 'ticket_id' => (int) $intervento->ticket_id, + ]); + } + + $aggregated[$key]['usage_count']++; + } + } + + $preferredKeys = collect($this->preferredMaterialRows) + ->map(fn(array $row): string => $this->buildMaterialMemoryKey($row)) + ->all(); + + $this->historicalMaterialRows = collect($aggregated) + ->reject(fn(array $row, string $key): bool => in_array($key, $preferredKeys, true)) + ->sortByDesc(fn(array $row): int => (int) ($row['usage_count'] ?? 0)) + ->take(8) + ->values() + ->all(); + } + + protected function appendMaterialFromMemory(array $row, string $notificationTitle): void + { + $material = [ + 'product_id' => isset($row['product_id']) && is_numeric($row['product_id']) ? (int) $row['product_id'] : null, + 'descrizione' => trim((string) ($row['label'] ?? '')), + 'barcode' => trim((string) ($row['barcode'] ?? '')), + 'qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? (float) $row['default_qty'] : 1, + 'unit_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : null, + 'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR', + 'source_type' => 'memory', + 'source_label' => trim((string) ($row['preferred_source'] ?? 'Memoria fornitore')) ?: 'Memoria fornitore', + 'external_url' => trim((string) ($row['preferred_url'] ?? '')), + ]; + + if ($material['descrizione'] === '') { + return; + } + + $this->materialiUtilizzati[] = $material; + + Notification::make() + ->title($notificationTitle) + ->body($material['descrizione']) + ->success() + ->send(); + } + + protected function persistPreferredMaterial(array $row): void + { + if (! $this->fornitore instanceof Fornitore || ! Schema::hasColumn('fornitori', 'operational_config')) { + return; + } + + $config = $this->fornitore->operational_config_safe; + $rows = collect((array) ($config['preferred_materials'] ?? [])) + ->map(fn($item): ?array => is_array($item) ? $this->normalizePreferredMaterialConfig($item) : null) + ->filter() + ->values(); + + $key = $this->buildMaterialMemoryKey($row); + $updatedRows = $rows + ->reject(fn(array $item): bool => $this->buildMaterialMemoryKey($item) === $key) + ->prepend($row) + ->take(20) + ->values() + ->all(); + + $config['preferred_materials'] = $updatedRows; + $this->fornitore->operational_config = $config; + $this->fornitore->save(); + $this->fornitore->refresh(); + } + + protected function normalizePreferredMaterialConfig(array $row): ?array + { + $label = trim((string) ($row['label'] ?? '')); + $brandModel = trim((string) ($row['brand_model'] ?? '')); + $barcode = trim((string) ($row['barcode'] ?? '')); + $internalCode = trim((string) ($row['internal_code'] ?? '')); + + if ($label === '' && $brandModel === '' && $barcode === '' && $internalCode === '') { + return null; + } + + return [ + 'label' => $label !== '' ? $label : $brandModel, + 'brand_model' => $brandModel, + 'barcode' => $barcode, + 'internal_code' => $internalCode, + 'preferred_source' => trim((string) ($row['preferred_source'] ?? $row['source_label'] ?? '')), + 'preferred_url' => trim((string) ($row['preferred_url'] ?? $row['external_url'] ?? '')), + 'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? max(0.01, round((float) $row['default_qty'], 2)) : 1.0, + 'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : (isset($row['unit_price']) && is_numeric($row['unit_price']) ? round((float) $row['unit_price'], 2) : null), + 'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR', + 'note' => trim((string) ($row['note'] ?? '')), + ]; + } + + protected function buildMaterialMemoryKey(array $row): string + { + $parts = [ + trim((string) ($row['barcode'] ?? '')), + trim((string) ($row['internal_code'] ?? '')), + preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['label'] ?? ''))) ?: '', + ]; + + foreach ($parts as $part) { + if ($part !== '') { + return $part; + } + } + + return 'mat-' . md5(json_encode($row)); + } + + protected function matchesMaterialSearch(array $row, string $term, string $normalized): bool + { + if ($term === '') { + return true; + } + + $haystack = mb_strtolower(implode(' ', array_filter([ + (string) ($row['label'] ?? ''), + (string) ($row['brand_model'] ?? ''), + (string) ($row['barcode'] ?? ''), + (string) ($row['internal_code'] ?? ''), + (string) ($row['preferred_source'] ?? ''), + (string) ($row['note'] ?? ''), + ]))); + + if (str_contains($haystack, mb_strtolower($term))) { + return true; + } + + if ($normalized === '') { + return false; + } + + $barcode = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['barcode'] ?? ''))) ?? ''; + $internalCode = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['internal_code'] ?? ''))) ?? ''; + + return $barcode === $normalized || str_contains($barcode, $normalized) || $internalCode === $normalized || str_contains($internalCode, $normalized); + } + protected function buildAmazonSearchUrl(string $term): string { $query = ['k' => trim($term)]; diff --git a/app/Filament/Pages/Gescon/FornitoriArchivio.php b/app/Filament/Pages/Gescon/FornitoriArchivio.php index 5558d59..15c036c 100644 --- a/app/Filament/Pages/Gescon/FornitoriArchivio.php +++ b/app/Filament/Pages/Gescon/FornitoriArchivio.php @@ -884,6 +884,9 @@ private function searchFornitoreMatches(): void return; } + $digits = preg_replace('/\D+/', '', $raw) ?: ''; + $canonicalNeedles = $this->normalizeSearchTags($raw); + $query = $this->getTableQuery() ->withCount('dipendenti') ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)]) @@ -891,9 +894,134 @@ private function searchFornitoreMatches(): void ->orderBy('cognome') ->orderBy('nome'); - $this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw) - ->limit(12) - ->get(); + $matches = $this->applyFornitoreSearch($query, $raw) + ->limit(60) + ->get() + ->sortByDesc(function (Fornitore $fornitore) use ($raw, $digits, $canonicalNeedles): int { + return $this->scoreFornitoreMatch($fornitore, $raw, $digits, $canonicalNeedles); + }) + ->take(12) + ->values(); + + if ((int) ($this->selectedFornitoreId ?? 0) > 0 && ! $matches->contains('id', (int) $this->selectedFornitoreId)) { + $selected = $this->getTableQuery()->find((int) $this->selectedFornitoreId); + if ($selected instanceof Fornitore) { + $matches->prepend($selected); + $matches = $matches->unique('id')->take(12)->values(); + } + } + + $this->fornitoreMatches = $matches; + } + + /** + * @param array $canonicalNeedles + */ + private function scoreFornitoreMatch(Fornitore $fornitore, string $raw, string $digits, array $canonicalNeedles): int + { + $score = (int) ($fornitore->id === (int) ($this->selectedFornitoreId ?? 0) ? 1000 : 0); + + $label = $this->getFornitoreLabel($fornitore); + $phone = (string) ($fornitore->telefono ?: $fornitore->cellulare); + $tags = $this->splitFornitoreTags((string) ($fornitore->tags ?? '')); + + $haystack = mb_strtolower(implode(' ', array_filter([ + $label, + (string) ($fornitore->email ?? ''), + $phone, + implode(', ', $tags), + (string) ($fornitore->note ?? ''), + (string) ($fornitore->partita_iva ?? ''), + (string) ($fornitore->codice_fiscale ?? ''), + ]))); + + if ($raw !== '' && str_contains($haystack, mb_strtolower($raw))) { + $score += 60; + } + + if ($digits !== '') { + $phoneDigits = preg_replace('/\D+/', '', $phone) ?: ''; + if ($phoneDigits !== '' && str_contains($phoneDigits, $digits)) { + $score += 45; + } + } + + $rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), $tags); + foreach ($canonicalNeedles as $canonicalNeedle) { + foreach ($rowTags as $rowTag) { + if ($rowTag === $canonicalNeedle) { + $score += 90; + } elseif (str_contains($rowTag, $canonicalNeedle) || str_contains($canonicalNeedle, $rowTag)) { + $score += 55; + } + } + } + + return $score; + } + + /** + * @return array + */ + private function normalizeSearchTags(string $input): array + { + $tags = []; + + foreach ($this->splitFornitoreTags($input) as $tag) { + $normalized = $this->canonicalizeFornitoreTag($tag); + if ($normalized !== null) { + $tags[] = $normalized; + } + } + + return array_values(array_unique($tags)); + } + + /** + * @return array + */ + private function splitFornitoreTags(string $value): array + { + $parts = preg_split('/[,;|\n\r\/]+/', $value) ?: []; + + return array_values(array_filter(array_map(function (string $part): string { + $clean = trim($part); + $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; + + return $clean; + }, $parts), fn(string $part): bool => $part !== '')); + } + + private function canonicalizeFornitoreTag(string $raw): ?string + { + $clean = trim(mb_strtolower($raw)); + if ($clean === '' || mb_strlen($clean) < 3) { + return null; + } + + $map = [ + 'idr' => 'idraulico', + 'idraul' => 'idraulico', + 'elett' => 'elettricista', + 'elettric' => 'elettricista', + 'ascens' => 'ascensorista', + 'puliz' => 'pulizie', + 'giardin' => 'giardiniere', + 'assicur' => 'assicurazione', + 'manut' => 'manutenzione', + 'spurgh' => 'spurghi', + 'fogn' => 'spurghi', + 'serr' => 'serrature', + 'cald' => 'caldaia', + ]; + + foreach ($map as $prefix => $normalized) { + if (str_starts_with($clean, $prefix)) { + return $normalized; + } + } + + return $clean; } private function cleanNullable(?string $value): ?string diff --git a/app/Models/Fornitore.php b/app/Models/Fornitore.php index e84e1c2..5b9a6c3 100755 --- a/app/Models/Fornitore.php +++ b/app/Models/Fornitore.php @@ -38,6 +38,7 @@ class Fornitore extends Model 'modalita_pagamento_predefinita', 'escludi_righe_fe', 'fe_features', + 'operational_config', 'telefono', 'telefono_country_code', 'cellulare', @@ -58,6 +59,7 @@ class Fornitore extends Model 'updated_at' => 'datetime', 'escludi_righe_fe' => 'boolean', 'fe_features' => 'array', + 'operational_config' => 'array', 'is_tecnorepair_primary' => 'boolean', ]; @@ -77,6 +79,22 @@ public function getFeFeaturesSafeAttribute(): array return is_array($this->fe_features ?? null) ? $this->fe_features : []; } + /** + * @return array + */ + public function getOperationalConfigSafeAttribute(): array + { + try { + if (! Schema::hasColumn('fornitori', 'operational_config')) { + return []; + } + } catch (\Throwable) { + return []; + } + + return is_array($this->operational_config ?? null) ? $this->operational_config : []; + } + protected static function booted(): void { static::creating(function (Fornitore $fornitore) { diff --git a/app/Support/GoogleAccountStore.php b/app/Support/GoogleAccountStore.php index 3551314..71c6a8e 100644 --- a/app/Support/GoogleAccountStore.php +++ b/app/Support/GoogleAccountStore.php @@ -103,8 +103,8 @@ public function get(Amministratore $amministratore, ?string $accountKey = null): } $resolvedKey = $this->normalizeAccountKey((string) $accountKey); - if ($resolvedKey !== '' && isset($accounts[$resolvedKey])) { - return $accounts[$resolvedKey]; + if ($resolvedKey !== '') { + return $accounts[$resolvedKey] ?? null; } foreach ($accounts as $account) { diff --git a/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php b/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php new file mode 100644 index 0000000..ee7fea8 --- /dev/null +++ b/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php @@ -0,0 +1,26 @@ +json('operational_config')->nullable()->after('fe_features'); + } + }); + } + + public function down(): void + { + Schema::table('fornitori', function (Blueprint $table): void { + if (Schema::hasColumn('fornitori', 'operational_config')) { + $table->dropColumn('operational_config'); + } + }); + } +}; \ No newline at end of file diff --git a/resources/views/filament/pages/fornitore/impostazioni-archivio.blade.php b/resources/views/filament/pages/fornitore/impostazioni-archivio.blade.php index 6c62ade..c128c65 100644 --- a/resources/views/filament/pages/fornitore/impostazioni-archivio.blade.php +++ b/resources/views/filament/pages/fornitore/impostazioni-archivio.blade.php @@ -66,6 +66,152 @@
{{ $lastImportOutput }}
@endif + +
+
+
+
Chiavi, accesso e reperibilità
+
Per ogni stabile del fornitore puoi memorizzare chi suonare, dove sono le chiavi e le istruzioni rapide da mostrare nella scheda intervento.
+
+ Aggiungi stabile +
+ + + +
+ @forelse($stabileAccessRows as $index => $row) +
+
+ + + + + + + + +
+
+ + +
+
+ @empty +
Nessuna istruzione di accesso configurata. Aggiungi almeno lo stabile dove serve sapere chi suonare o dove recuperare le chiavi.
+ @endforelse +
+
+ +
+
+
+
Memoria materiali ricorrenti
+
Preferiti per lampade, ricambi e prodotti che il fornitore usa spesso. Verranno proposti nella scheda intervento.
+
+ Aggiungi materiale +
+ + @if(! $operationalConfigAvailable) +
+ La memoria materiali richiede la migrazione del database che aggiunge il campo operational_config ai fornitori. +
+ @endif + +
+ @forelse($preferredMaterialRows as $index => $row) +
+
+ + + + + + + +
+ + +
+ +
+
+ +
+
+ @empty +
Nessun materiale preferito ancora salvato.
+ @endforelse +
+ +
+ Salva workflow operativo +
+
diff --git a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php index d27f48d..5387d67 100644 --- a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php +++ b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php @@ -157,6 +157,60 @@
{{ $ticket->descrizione }}
+ @if(count($accessSummaryRows) > 0 || filled($dispatchBoardNote)) +
+
Accesso, chiavi e reperibilità
+ @if(filled($dispatchBoardNote)) +
{{ $dispatchBoardNote }}
+ @endif + +
+ @foreach($accessSummaryRows as $row) +
+
+
{{ $row['stabile_label'] }}
+ @if($row['is_priority']) + Prioritario + @endif +
+
+
+
Chi suonare
+
{{ $row['chi_suonare'] !== '' ? $row['chi_suonare'] : '-' }}
+
+
+
Referente
+
{{ $row['contatto_nome'] !== '' ? $row['contatto_nome'] : '-' }}
+ @if($row['contatto_telefono'] !== '') + {{ $row['contatto_telefono'] }} + @endif +
+
+
Dove sono le chiavi
+
{{ $row['dove_sono_chiavi'] !== '' ? $row['dove_sono_chiavi'] : '-' }}
+
+
+
Fascia reperibilità
+
{{ $row['fascia_reperibilita'] !== '' ? $row['fascia_reperibilita'] : '-' }}
+
+ @if($row['istruzioni_accesso'] !== '') +
+
Istruzioni accesso
+
{{ $row['istruzioni_accesso'] }}
+
+ @endif + @if($row['note_urgenza'] !== '') +
+ Nota urgenza: {{ $row['note_urgenza'] }} +
+ @endif +
+
+ @endforeach +
+
+ @endif +
Operativo e rapportino
@@ -256,6 +310,54 @@
+ @if(count($preferredMaterialRows) > 0) +
+
Preferiti fornitore
+
+ @foreach($preferredMaterialRows as $index => $row) +
+
{{ $row['label'] }}
+ @if($row['brand_model'] !== '') +
{{ $row['brand_model'] }}
+ @endif +
Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'n/d' }} · Qty tipica: {{ rtrim(rtrim(number_format((float) $row['default_qty'], 2, '.', ''), '0'), '.') }}
+
Fonte: {{ $row['preferred_source'] !== '' ? $row['preferred_source'] : 'Memoria fornitore' }}@if($row['last_price'] !== null) · Prezzo: {{ number_format((float) $row['last_price'], 2, ',', '.') }} {{ $row['currency'] }}@endif
+ @if($row['note'] !== '') +
{{ $row['note'] }}
+ @endif +
+ + @if($row['preferred_url'] !== '') + Apri riferimento + @endif +
+
+ @endforeach +
+
+ @endif + + @if(count($historicalMaterialRows) > 0) +
+
Storico materiali usati
+
+ @foreach($historicalMaterialRows as $index => $row) +
+
{{ $row['label'] }}
+
Usato {{ $row['usage_count'] }} volte · ultimo ticket #{{ $row['ticket_id'] }} · {{ $row['last_used_at'] }}
+
Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'n/d' }}@if($row['preferred_source'] !== '') · Fonte: {{ $row['preferred_source'] }}@endif
+
+ + @if($row['preferred_url'] !== '') + Apri riferimento + @endif +
+
+ @endforeach +
+
+ @endif + @if(count($materialSuggestions) > 0)
@foreach($materialSuggestions as $suggestion) @@ -315,7 +417,10 @@ @else Nessun riferimento esterno @endif - +
+ + +
@endforeach