hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void { $this->hubTab = $this->normalizeHubTab($this->hubTab); $this->syncInvoiceFolders(); $this->initializePhysicalArchiveForms(); $this->initializeScannerAcquisitionForm(); $this->initializeGenericLabelForm(); $this->initializeDriveTemplateForm(); if (trim($this->archiveLookupCode) !== '') { $this->applyArchiveLookupInput($this->archiveLookupCode); } $this->mountInteractsWithTable(); } public function selectHubTab(string $tab): void { $this->hubTab = $this->normalizeHubTab($tab); if ($this->hubTab === 'archivio-digitale') { $this->syncInvoiceFolders(); } if ($this->hubTab === 'scansione-documenti') { $this->initializeScannerAcquisitionForm(); } if ($this->hubTab === 'template-drive') { $this->initializeDriveTemplateForm(); } if ($this->hubTab === 'lettore-codici' && trim($this->archiveLookupCode) !== '') { $this->applyArchiveLookupInput($this->archiveLookupCode); } } public function updatedArchiveLookupCode(string $value): void { $value = trim($value); if ($value === '') { $this->archiveLookupSelectedId = null; return; } $this->applyArchiveLookupInput($value); } public function openArchiveLookupFromCode(): void { $this->applyArchiveLookupInput($this->archiveLookupCode); } public function selectArchiveLookupItem(int $itemId): void { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); $item = DocumentoArchivioFisicoItem::query() ->when($stabileId > 0, fn(Builder $query) => $query->where('stabile_id', $stabileId)) ->find($itemId); if (! $item instanceof DocumentoArchivioFisicoItem) { Notification::make()->title('Voce archivio non trovata')->danger()->send(); return; } $this->archiveLookupSelectedId = (int) $item->id; $this->archiveLookupCode = (string) $item->codice_univoco; $this->hubTab = 'lettore-codici'; } public function setProtocolloView(string $view): void { $this->protocolloView = in_array($view, ['cards', 'list'], true) ? $view : 'cards'; } public function getScannerAcquisitionChannelOptions(): array { return [ 'scanner_upload' => 'Upload PDF da scanner', 'scanner_rete' => 'Scanner Epson verso cartella/rete', 'scanner_email' => 'Scanner via email', 'mobile_upload' => 'Upload mobile o camera', 'import_manuale' => 'Acquisizione manuale', ]; } public function getScannerAcquisitionStabiliOptionsProperty(): array { return $this->getStabiliOptions(); } public function getScannerAcquisitionFolderOptionsProperty(): array { return $this->getArchivioFolderOptions((int) ($this->scannerAcquisitionForm['stabile_id'] ?? 0)); } public function getScannerCategoryOptionsProperty(): array { return $this->getCategorieOptions(); } public function getScannerVisibilityScopeOptionsProperty(): array { return $this->getVisibilityScopeOptions(); } public function saveScannedDocument(): void { $uploadedPdf = $this->scannerPdf; if (! is_object($uploadedPdf) || ! method_exists($uploadedPdf, 'store')) { Notification::make()->title('PDF scansione obbligatorio')->danger()->send(); return; } $stabileId = (int) ($this->scannerAcquisitionForm['stabile_id'] ?? 0); if ($stabileId <= 0) { Notification::make()->title('Stabile obbligatorio')->danger()->send(); return; } $storedPath = $uploadedPdf->store('tmp/scanner-acquisizione', 'local'); $riferimentoAcquisizione = trim((string) ($this->scannerAcquisitionForm['riferimento_acquisizione'] ?? '')); if ($riferimentoAcquisizione === '') { $riferimentoAcquisizione = 'SCAN:' . $stabileId . ':' . now()->format('YmdHis'); } $this->createDocumentoFromForm([ 'stabile_id' => $stabileId, 'cartella_archivio_id' => $this->scannerAcquisitionForm['cartella_archivio_id'] ?? null, 'categoria' => (string) ($this->scannerAcquisitionForm['categoria'] ?? 'altro'), 'archivio_classe' => (string) ($this->scannerAcquisitionForm['archivio_classe'] ?? 'altro'), 'titolo' => (string) ($this->scannerAcquisitionForm['titolo'] ?? ''), 'descrizione' => (string) ($this->scannerAcquisitionForm['descrizione'] ?? ''), 'fornitore' => (string) ($this->scannerAcquisitionForm['fornitore'] ?? ''), 'data_documento' => $this->scannerAcquisitionForm['data_documento'] ?? now()->toDateString(), 'data_scadenza' => $this->scannerAcquisitionForm['data_scadenza'] ?? null, 'importo' => $this->scannerAcquisitionForm['importo'] ?? null, 'movimento_contabile_id' => null, 'faldone' => (string) ($this->scannerAcquisitionForm['faldone'] ?? ''), 'supporto_fisico' => $this->scannerAcquisitionForm['supporto_fisico'] ?? null, 'modello_etichetta' => $this->scannerAcquisitionForm['modello_etichetta'] ?? '11354', 'busta_numero' => $this->scannerAcquisitionForm['busta_numero'] ?? null, 'fascicolo_numero' => $this->scannerAcquisitionForm['fascicolo_numero'] ?? null, 'contenitore_numero' => $this->scannerAcquisitionForm['contenitore_numero'] ?? null, 'periodo_riferimento_da' => $this->scannerAcquisitionForm['periodo_riferimento_da'] ?? null, 'periodo_riferimento_a' => $this->scannerAcquisitionForm['periodo_riferimento_a'] ?? null, 'visibility_scope' => (string) ($this->scannerAcquisitionForm['visibility_scope'] ?? 'interno'), 'visibility_roles' => [], 'visibility_groups' => [], 'allowed_user_ids' => [], 'pdf' => $storedPath, 'canale_acquisizione' => (string) ($this->scannerAcquisitionForm['canale_acquisizione'] ?? 'scanner_upload'), 'scanner_profile' => (string) ($this->scannerAcquisitionForm['scanner_profile'] ?? ''), 'riferimento_acquisizione' => $riferimentoAcquisizione, 'scanner_note' => (string) ($this->scannerAcquisitionForm['scanner_note'] ?? ''), 'origine_documento' => 'scanner_documenti_hub', ], false); $this->initializeScannerAcquisitionForm(); $this->scannerPdf = null; } protected function getHeaderActions(): array { return [ Action::make('registra_documento') ->label('Registra documento (PDF)') ->icon('heroicon-o-plus') ->form($this->getDocumentoFormSchema()) ->action(function (array $data): void { $this->createDocumentoFromForm($data, false); }), Action::make('crea_demo_contratto_ascensori') ->label('Crea demo: Contratto Ascensori') ->icon('heroicon-o-sparkles') ->form([ Select::make('stabile_id') ->label('Stabile') ->options(fn() => $this->getStabiliOptions()) ->default(fn() => $this->getDefaultStabileId()) ->searchable() ->required(), TextInput::make('faldone') ->label('Faldone (opzionale)') ->placeholder('Es. CONTR-2024') ->maxLength(50), ]) ->action(function (array $data): void { $this->createDocumentoDemoContrattoAscensori((int) $data['stabile_id'], (string) ($data['faldone'] ?? '')); }), Action::make('nuova_cartella_archivio') ->label('Nuova cartella archivio') ->icon('heroicon-o-folder-plus') ->color('gray') ->form($this->getArchivioFolderFormSchema()) ->action(function (array $data): void { $this->createArchivioFolder($data); }), ]; } protected function getTableQuery(): Builder { $user = Auth::user(); if (! $user instanceof User) { return Documento::query()->whereRaw('1 = 0'); } $query = $this->getVisibilityService() ->scopeVisibleDocumenti(Documento::query()->with('cartellaArchivio'), $user); if ($this->hubTab === 'archivio-digitale') { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId > 0) { $query->where('stabile_id', $stabileId) ->where('tipo_documento', 'fattura'); if ($this->currentFolderId) { $query->where('cartella_archivio_id', $this->currentFolderId); } else { $query->whereRaw('1 = 0'); } } } return $query; } public function table(Table $table): Table { return $table ->striped() ->defaultSort('created_at', 'desc') ->columns([ TextColumn::make('numero_protocollo') ->label('Protocollo') ->copyable() ->searchable(), TextColumn::make('nome_file') ->label('Nome archivio') ->state(fn(Documento $record): string => trim((string) ($record->nome_file ?: $record->nome ?: '—'))) ->searchable() ->limit(65) ->wrap(), BadgeColumn::make('tipo_documento') ->label('Categoria') ->toggleable() ->formatStateUsing(fn($state, Documento $record) => $state ?: ($record->tipologia ?: '—')), TextColumn::make('archive_reference') ->label('Codice archivio') ->state(fn(Documento $record): string => $record->resolveArchiveDisplayReference()) ->limit(55) ->wrap() ->toggleable(), TextColumn::make('descrizione') ->label('Descrizione') ->limit(50) ->toggleable(isToggledHiddenByDefault: true) ->searchable(), TextColumn::make('fornitore') ->label('Fornitore/Ente') ->searchable() ->limit(35) ->toggleable(), TextColumn::make('cartellaArchivio.nome') ->label('Cartella') ->toggleable() ->wrap(), TextColumn::make('metadati_archivio.classe') ->label('Classe archivio') ->state(fn(Documento $record): string => (string) data_get($record->metadati_archivio ?? [], 'classe_label', '—')) ->toggleable(), TextColumn::make('metadati_archivio.supporto_fisico') ->label('Supporto') ->state(fn(Documento $record): string => (string) data_get($record->metadati_archivio ?? [], 'supporto_fisico', '—')) ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('metadati_archivio.posizione_fisica') ->label('Posizione fisica') ->state(fn(Documento $record): string => $this->formatPhysicalArchiveLocation($record)) ->toggleable(), TextColumn::make('contenuto_ocr') ->label('Testo (OCR)') ->limit(50) ->toggleable(isToggledHiddenByDefault: true) ->searchable(), TextColumn::make('data_documento') ->label('Data') ->date('d-m-Y') ->toggleable(), TextColumn::make('data_scadenza') ->label('Scadenza') ->date('d-m-Y') ->toggleable(), TextColumn::make('importo_collegato') ->label('Importo') ->money('EUR') ->toggleable(), IconColumn::make('archiviato') ->label('Arch.') ->boolean() ->toggleable(), ]) ->filters([ SelectFilter::make('tipo_documento') ->label('Categoria') ->options($this->getCategorieOptions()), ]) ->actions([ Action::make('visualizza_pdf') ->hiddenLabel() ->icon('heroicon-o-eye') ->tooltip('Visualizza PDF') ->visible(fn(Documento $record) => $this->hasPdf($record)) ->modalHeading(fn(Documento $record): string => trim((string) ($record->nome ?: 'Documento PDF'))) ->modalWidth('7xl') ->modalContent(function (Documento $record) { $openUrl = route('filament.documenti.download', $record); return view('filament.modals.pdf-viewer', [ 'url' => $openUrl . '?inline=1', 'openUrl' => $openUrl, 'title' => trim((string) ($record->nome ?: 'Documento PDF')), ]); }) ->modalSubmitAction(false) ->modalCancelActionLabel('Chiudi'), Action::make('scarica_pdf') ->hiddenLabel() ->icon('heroicon-o-arrow-down-tray') ->tooltip('Scarica PDF') ->url(fn(Documento $record) => route('filament.documenti.download', $record), true) ->visible(fn(Documento $record) => $this->hasPdf($record)), Action::make('stampa_pdf') ->hiddenLabel() ->icon('heroicon-o-printer') ->tooltip('Apri per stampa') ->url(fn(Documento $record) => route('filament.documenti.download', $record) . '?inline=1', true) ->visible(fn(Documento $record) => $this->hasPdf($record)), Action::make('stampa_pdf_watermark') ->hiddenLabel() ->icon('heroicon-o-document-duplicate') ->tooltip('Apri con watermark archivio') ->url(fn(Documento $record) => route('filament.documenti.download', $record) . '?inline=1&watermark=1', true) ->visible(fn(Documento $record) => $this->hasPdf($record)), Action::make('archivia_fisico') ->hiddenLabel() ->icon('heroicon-o-archive-box') ->tooltip('Collega archivio fisico') ->fillForm(fn(Documento $record): array=> [ 'supporto_fisico' => data_get($record->metadati_archivio ?? [], 'supporto_fisico'), 'modello_etichetta' => data_get($record->metadati_archivio ?? [], 'modello_etichetta', '11354'), 'busta_numero' => data_get($record->metadati_archivio ?? [], 'busta_numero'), 'fascicolo_numero' => data_get($record->metadati_archivio ?? [], 'fascicolo_numero'), 'contenitore_numero' => data_get($record->metadati_archivio ?? [], 'contenitore_numero'), 'periodo_riferimento_da' => data_get($record->metadati_archivio ?? [], 'periodo_da'), 'periodo_riferimento_a' => data_get($record->metadati_archivio ?? [], 'periodo_a'), 'watermark_predefinito' => (bool) data_get($record->metadati_archivio ?? [], 'watermark_predefinito', false), ]) ->form([ Select::make('supporto_fisico') ->label('Supporto fisico') ->options(array_combine($this->archivioFisicoTypes, $this->archivioFisicoTypes)) ->searchable(), Select::make('modello_etichetta') ->label('Modello etichetta') ->options($this->getModelliEtichettaOptions()) ->default('11354') ->native(false), TextInput::make('contenitore_numero') ->label('Contenitore / faldone'), TextInput::make('busta_numero') ->label('Busta n.'), TextInput::make('fascicolo_numero') ->label('Fascicolo n.'), DatePicker::make('periodo_riferimento_da') ->label('Periodo dal'), DatePicker::make('periodo_riferimento_a') ->label('Periodo al'), Toggle::make('watermark_predefinito') ->label('Watermark predefinito in stampa') ->default(false), ]) ->action(function (Documento $record, array $data): void { $meta = is_array($record->metadati_archivio ?? null) ? $record->metadati_archivio : []; $meta['supporto_fisico'] = $this->normalizeNullableText($data['supporto_fisico'] ?? null); $meta['modello_etichetta'] = $this->normalizeNullableText($data['modello_etichetta'] ?? null); $meta['contenitore_numero'] = $this->normalizeNullableText($data['contenitore_numero'] ?? null); $meta['busta_numero'] = $this->normalizeNullableText($data['busta_numero'] ?? null); $meta['fascicolo_numero'] = $this->normalizeNullableText($data['fascicolo_numero'] ?? null); $meta['periodo_da'] = $data['periodo_riferimento_da'] ?? null; $meta['periodo_a'] = $data['periodo_riferimento_a'] ?? null; $meta['watermark_predefinito'] = (bool) ($data['watermark_predefinito'] ?? false); if (trim((string) ($meta['codice_univoco_documento'] ?? '')) === '') { $meta['codice_univoco_documento'] = $record->resolveArchiveCode(); } $record->metadati_archivio = $meta; $record->save(); Notification::make() ->title('Archivio fisico aggiornato') ->success() ->send(); }), Action::make('etichetta_11354') ->hiddenLabel() ->icon('heroicon-o-tag') ->tooltip('Etichetta 11354') ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=3&autoprint=1', false), Action::make('etichetta_11354_driver_verticale') ->hiddenLabel() ->icon('heroicon-o-tag') ->tooltip('Etichetta 11354 verticale') ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=1&dymo_rot=-90&dymo_dx=0&dymo_dy=0&autoprint=1', false), Action::make('etichetta_99014') ->hiddenLabel() ->icon('heroicon-o-tag') ->tooltip('Etichetta 99014') ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=99014&autoprint=1', false), ]); } private function getCategorieOptions(): array { return [ 'contratto' => 'Contratti (CONTR)', 'assicurazione' => 'Assicurazioni (ASSIC)', 'certificato' => 'Certificati (CERT)', 'planimetria' => 'Planimetrie (PLAN)', 'fascicolo' => 'Fascicolo fabbricato (FASC)', 'anagrafica' => 'Anagrafica condominiale (ANAG)', 'tabella' => 'Tabelle millesimali (TABM)', 'regolamento' => 'Regolamento (REGO)', 'scritture' => 'Scritture contabili (CONT)', 'chiavi' => 'Chiavi stabile (CHIA)', 'timbri' => 'Timbri stabile (TIMB)', 'preventivo' => 'Preventivi (PREV)', 'fattura' => 'Fatture (FATT)', 'verbale' => 'Verbali (VERB)', 'altro' => 'Altri (DOC)', ]; } public function getHubStatsProperty(): array { $query = $this->getTableQuery(); return [ 'documenti' => (clone $query)->count(), 'archiviati' => (clone $query)->where('archiviato', true)->count(), 'in_scadenza' => (clone $query)->whereNotNull('data_scadenza')->whereDate('data_scadenza', '<=', now()->addDays(30)->toDateString())->count(), 'template_drive' => count($this->getDriveTemplateFoldersProperty()), ]; } public function openDigitalFolder(?int $folderId = null): void { $this->syncInvoiceFolders(); if ($folderId === null) { $this->currentFolderId = null; return; } $folder = DocumentoArchivioCartella::query()->find($folderId); if (! $folder instanceof DocumentoArchivioCartella) { $this->currentFolderId = null; return; } $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId > 0 && (int) $folder->stabile_id !== $stabileId) { return; } $this->currentFolderId = (int) $folder->id; } public function getDigitalArchiveRootLabelProperty(): string { $stabile = $this->resolveActiveStabile(); return trim((string) ($stabile?->denominazione ?: 'Stabile attivo')); } public function getDigitalArchiveCurrentFolderProperty(): ?DocumentoArchivioCartella { if (! $this->currentFolderId) { return null; } return DocumentoArchivioCartella::query()->find($this->currentFolderId); } public function getDigitalArchiveCurrentFolderDocumentCountProperty(): int { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0 || ! $this->currentFolderId) { return 0; } return (int) Documento::query() ->where('stabile_id', $stabileId) ->where('tipo_documento', 'fattura') ->where('cartella_archivio_id', $this->currentFolderId) ->count(); } public function getDigitalArchiveCurrentFolderHintProperty(): string { $folder = $this->digitalArchiveCurrentFolder; if (! $folder instanceof DocumentoArchivioCartella) { return 'Seleziona la cartella annuale per vedere l’elenco dei PDF già ordinati per protocollo e data.'; } return trim((string) ($folder->percorso_logico ?: $folder->nome)); } public function getDigitalArchiveFoldersProperty(): array { $stabile = $this->resolveActiveStabile(); if (! $stabile instanceof Stabile) { return []; } $root = $this->ensureInvoiceRootFolder((int) $stabile->id); if (! $root instanceof DocumentoArchivioCartella) { return []; } if (! $this->currentFolderId) { return [[ 'id' => (int) $root->id, 'nome' => (string) $root->nome, 'descrizione' => 'Cartella fatture PDF', 'conteggio' => (int) Documento::query() ->where('stabile_id', $stabile->id) ->where('tipo_documento', 'fattura') ->count(), ]]; } return DocumentoArchivioCartella::query() ->where('parent_id', $this->currentFolderId) ->where('is_active', true) ->orderBy('sort_order') ->orderBy('nome') ->get() ->map(fn(DocumentoArchivioCartella $folder): array=> [ 'id' => (int) $folder->id, 'nome' => (string) $folder->nome, 'descrizione' => (string) ($folder->percorso_logico ?: $folder->nome), 'conteggio' => (int) $folder->documenti()->count(), ]) ->all(); } public function getDigitalArchiveBreadcrumbsProperty(): array { $items = [[ 'label' => $this->digitalArchiveRootLabel, 'folder_id' => null, ]]; $current = $this->digitalArchiveCurrentFolder; if (! $current instanceof DocumentoArchivioCartella) { return $items; } $chain = collect(); while ($current instanceof DocumentoArchivioCartella) { $chain->prepend([ 'label' => (string) $current->nome, 'folder_id' => (int) $current->id, ]); $current = $current->parent; } return array_merge($items, $chain->all()); } /** @return array */ public function getDriveTemplateFoldersProperty(): array { $settings = $this->getArchiveHubSettings(); $items = $settings['drive_template_folders'] ?? []; $yearRoot = trim((string) ($settings['drive_template_year_root'] ?? '')); $yearModel = trim((string) ($settings['drive_template_year_model'] ?? '')); $folders = is_array($items) ? array_values(array_filter(array_map('strval', $items), fn(string $item) : bool => trim($item) !== '')) : []; if ($yearRoot !== '' && $yearModel !== '') { foreach ($folders as $folder) { $folders[] = $yearRoot . '/' . $yearModel . '/' . $folder; } } return array_values(array_unique($folders)); } public function getDriveTemplateSourceProperty(): string { return $this->hasStoredArchiveHubSettings() ? 'database' : 'config'; } public function getCanManageDriveTemplatesProperty(): bool { $user = Auth::user(); return $user instanceof User && $user->hasRole('super-admin'); } public function getGenericLabelRowsProperty(): array { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { return []; } return DocumentoArchivioFisicoItem::query() ->where('stabile_id', $stabileId) ->latest('id') ->take(24) ->get() ->filter(fn(DocumentoArchivioFisicoItem $item): bool => (bool) data_get($item->metadati, 'generic_label', false)) ->map(function (DocumentoArchivioFisicoItem $item): array { $printOptions = $this->buildGenericLabelPrintOptions((int) $item->id); $defaultPrintOption = ($item->modello_etichetta ?: '11354') === '99014' ? '99014' : '11354'; return [ 'id' => (int) $item->id, 'codice_univoco' => (string) $item->codice_univoco, 'titolo' => (string) $item->titolo, 'tipo' => (string) $item->tipo_label, 'percorso' => (string) $item->percorso_fisico, 'ubicazione' => trim(implode(' · ', array_filter([ $this->normalizeNullableText($item->magazzino), $this->normalizeNullableText($item->scaffale), $this->normalizeNullableText($item->ripiano), $this->normalizeNullableText($item->ubicazione_dettaglio), ]))), 'linea_secondaria' => (string) data_get($item->metadati, 'linea_secondaria', ''), 'amazon_url' => (string) data_get($item->metadati, 'amazon_url', ''), 'preset_label' => (string) ($this->genericLabelPresetOptions[data_get($item->metadati, 'generic_label_template.preset_key', '')] ?? 'Custom'), 'template_label' => (string) ($this->genericLabelTemplateOptions[data_get($item->metadati, 'generic_label_template.template_name', '')] ?? 'Layout personalizzato'), 'print_options' => $printOptions, 'default_print' => array_key_exists($defaultPrintOption, $printOptions) ? $defaultPrintOption : array_key_first($printOptions), ]; }) ->values() ->all(); } /** @return array */ private function buildGenericLabelPrintOptions(int $itemId): array { $baseUrl = route('filament.archivio-fisico.etichetta', ['item' => $itemId]); return [ '11354' => [ 'label' => 'DYMO 11354 orizzontale', 'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=-1', ], '11354_vertical' => [ 'label' => 'DYMO 11354 verticale', 'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=1&dymo_rot=90&dymo_dx=0&dymo_dy=0', ], '99014' => [ 'label' => 'DYMO 99014 larga', 'url' => $baseUrl . '?format=99014&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=3.5&dymo_dy=3.5', ], ]; } /** @return array */ public function getGenericLabelPresetOptionsProperty(): array { return collect($this->getGenericLabelPresetDefinitions()) ->mapWithKeys(fn(array $preset, string $key): array=> [$key => (string) ($preset['label'] ?? Str::headline($key))]) ->all(); } /** @return array> */ public function getGenericLabelSavedTemplatesProperty(): array { return collect($this->getStoredGenericLabelPresetDefinitions()) ->map(function (array $preset, string $key): array { $templateName = (string) ($preset['template_name'] ?? 'classic-right'); return [ 'key' => $key, 'label' => (string) ($preset['label'] ?? Str::headline($key)), 'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'), 'modello' => (string) ($preset['modello_etichetta'] ?? '11354'), 'template_name' => $templateName, 'template_label' => (string) ($this->genericLabelTemplateOptions[$templateName] ?? 'Template'), ]; }) ->values() ->all(); } /** @return array */ public function getGenericLabelTemplateOptionsProperty(): array { return [ 'archive-99014' => 'Archivio 99014 con righe manuali', 'classic-right' => 'Classica QR a destra', 'classic-bottom' => 'Classica QR in basso', 'band-top' => 'Fascia alta + QR laterale', 'compact-grid' => 'Compatta a griglia', ]; } /** @return array */ public function getGenericLabelTemplateFieldOptionsProperty(): array { return [ 'mnemonic_code' => 'Codice mnemonico', 'titolo' => 'Titolo etichetta', 'linea_secondaria' => 'Seconda riga', 'tipo_label' => 'Tipologia contenitore', 'supporto_fisico' => 'Supporto', 'percorso_compatto' => 'Percorso archivio', 'ubicazione' => 'Ubicazione', 'note' => 'Nota breve', 'stabile' => 'Stabile attivo', 'stabile_code' => 'Codice stabile', 'stabile_address' => 'Indirizzo stabile', 'stabile_summary' => 'Stabile compatto', 'codice_univoco' => 'Codice univoco', 'amazon_url' => 'Link acquisto', 'none' => 'Nessun campo', ]; } /** @return array */ public function getGenericLabelStableFieldOptionsProperty(): array { return [ 'stabile' => 'Nome stabile', 'stabile_code' => 'Codice stabile', 'stabile_address' => 'Indirizzo stabile', 'stabile_summary' => 'Stabile compatto', 'none' => 'Nessuna riga', ]; } /** @return array */ public function getGenericLabelLineModeOptionsProperty(): array { return [ 'source' => 'Campo archivio', 'text' => 'Testo libero', 'blank_line' => 'Riga bianca scrivibile', 'none' => 'Nessuna riga', ]; } /** @return array */ public function getGenericLabelFontSizeOptionsProperty(): array { return [ 'xs' => 'Extra piccolo', 'sm' => 'Piccolo', 'md' => 'Medio', 'lg' => 'Grande', 'xl' => 'Extra grande', ]; } /** @return array */ public function getGenericLabelPreviewProperty(): array { $templateName = trim((string) ($this->genericLabelForm['template_name'] ?? 'classic-right')); $template = $this->getGenericLabelTemplateDefinition($templateName); $stabile = $this->resolveActiveStabile(); $mnemonicCode = trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')); if ($mnemonicCode === '') { $mnemonicCode = (string) ($this->mnemonicCodeHint ?? 'GER00079'); } $stabileCode = ''; $stabileAddress = ''; if ($stabile instanceof Stabile) { $stabileCode = trim((string) ($stabile->codice_stabile ?? '')); $stabileAddress = trim((string) ($stabile->indirizzo_completo ?? '')); } $amazonUrl = trim((string) ($this->normalizeAmazonPurchaseUrl($this->genericLabelForm['amazon_url'] ?? null) ?? '')); $qrProfiles = ArchiveQrXmlPayload::buildProfiles([ 'id' => 'AF-QR-PREVIEW', 'legacy' => 'AF:AF-QR-PREVIEW', 'type' => Str::upper((string) ($this->genericLabelForm['tipo_item'] ?? 'SCATOLA')), 'stabile_id' => (string) ((int) ($this->resolveActiveStabile()?->id ?? 0)), 'parent' => trim((string) ($this->genericLabelForm['parent_id'] ?? '')) !== '' ? 'COLLEGATO' : 'RADICE', 'code' => $mnemonicCode, 'title' => trim((string) ($this->genericLabelForm['titolo'] ?? '')), 'archive_class' => 'GENERIC_LABEL', 'location' => trim(implode('/', array_filter([ $this->genericLabelForm['magazzino'] ?? null, $this->genericLabelForm['scaffale'] ?? null, $this->genericLabelForm['ripiano'] ?? null, $this->genericLabelForm['ubicazione_dettaglio'] ?? null, ]))), ]); $payload = [ 'mnemonic_code' => $mnemonicCode, 'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')), 'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')), 'tipo_label' => DocumentoArchivioFisicoItem::TIPI[(string) ($this->genericLabelForm['tipo_item'] ?? 'scatola')] ?? 'Contenitore', 'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')), 'percorso_compatto' => trim(implode(' / ', array_filter([ $this->resolveActiveStabile()?->denominazione, $this->genericLabelForm['supporto_fisico'] ?? null, $this->genericLabelForm['titolo'] ?? null, ]))), 'ubicazione' => trim(implode(' · ', array_filter([ $this->genericLabelForm['magazzino'] ?? null, $this->genericLabelForm['scaffale'] ?? null, $this->genericLabelForm['ripiano'] ?? null, $this->genericLabelForm['ubicazione_dettaglio'] ?? null, ]))), 'note' => trim((string) ($this->genericLabelForm['note'] ?? '')), 'stabile' => trim((string) ($this->resolveActiveStabile()?->denominazione ?? 'Stabile attivo')), 'stabile_code' => $stabileCode, 'stabile_address' => $stabileAddress, 'stabile_summary' => trim(implode(' · ', array_filter([ trim((string) ($this->resolveActiveStabile()?->denominazione ?? '')), $stabileCode, $stabileAddress, ]))), 'codice_univoco' => 'AF-QR-PREVIEW', 'qr_payload' => (string) ($qrProfiles['xml_open'] ?? ''), 'qr_payload_zip' => (string) ($qrProfiles['xml_zip'] ?? ''), 'nfc_payload' => (string) ($qrProfiles['xml_nfc'] ?? ''), 'qr_payload_length' => (int) ($qrProfiles['xml_open_length'] ?? 0), 'nfc_payload_length' => (int) ($qrProfiles['xml_nfc_length'] ?? 0), 'qr_markup' => $this->buildGenericLabelQrMarkup((string) ($qrProfiles['xml_open'] ?? '')), 'amazon_url' => $amazonUrl, ]; $stableLines = $this->buildGenericLabelLineRows($payload); return [ 'template_name' => $templateName, 'template' => $template, 'format' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'), 'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? ($template['qr_position'] ?? 'right-center')), 'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'), 'slots' => [ 'title' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['title_source'] ?? 'titolo'), $payload), 'subtitle' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'), $payload), 'meta' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['meta_source'] ?? 'percorso_compatto'), $payload), 'note' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['note_source'] ?? 'note'), $payload), ], 'payload' => $payload, 'mnemonic_code' => $mnemonicCode, 'font_sizes' => [ 'mnemonic' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')), 'title' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')), 'subtitle' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')), 'lines' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')), 'note' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')), ], 'stable_lines' => $stableLines, 'blank_lines' => [], 'amazon_url' => $amazonUrl, ]; } private function buildGenericLabelQrMarkup(string $data): string { $data = trim($data); if ($data === '') { return ''; } if (class_exists(\chillerlan\QRCode\QROptions::class) && class_exists(\chillerlan\QRCode\QRCode::class)) { $options = new \chillerlan\QRCode\QROptions([ 'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG, 'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M, 'addQuietzone' => true, 'quietzoneSize' => 1, 'connectPaths' => true, ]); $svg = (new \chillerlan\QRCode\QRCode($options))->render($data); if (str_starts_with($svg, 'data:image/svg+xml;base64,')) { $decoded = base64_decode(substr($svg, strlen('data:image/svg+xml;base64,')), true); if (is_string($decoded) && $decoded !== '') { $svg = $decoded; } } if (str_contains($svg, ''; } /** @return array */ public function getAudienceGroupsProperty(): array { return [ 'Condomini', 'Inquilini', 'Fornitori', 'Collaboratori interni', 'Amministratore', 'Studio completo', ]; } /** @return array */ public function getArchivioFisicoTypesProperty(): array { return [ 'Busta trasparente con anelli da archiviare', 'Cartella a tre lembi', 'Faldone con anelli', 'Faldone con lacci', 'Scatola', ]; } /** @return array */ public function getArchivioClasseOptions(): array { return [ 'libro_verbali' => 'Libro verbali', 'planimetria_stabile' => 'Planimetria stabile', 'elaborato_planimetrico' => 'Elaborato planimetrico', 'scheda_anagrafica_condominiale' => 'Scheda anagrafica condominiale', 'fascicolo_fabbricato' => 'Fascicolo fabbricato', 'polizza_fabbricato' => 'Polizza global fabbricato', 'certificazioni_impianti' => 'Certificazioni e conformita impianti', 'decreti_vincolo' => 'Decreti di vincolo', 'tabelle_millesimali' => 'Tabelle millesimali', 'regolamento_condominio' => 'Regolamento di condominio', 'rendiconti_approvati' => 'Rendiconti approvati', 'raccolta_contratti' => 'Raccolta contratti stipulati', 'servitu_diritti_terzi' => 'Servitu e diritti di terzi', 'giustificativi_spesa' => 'Giustificativi di spesa', 'scritture_contabili' => 'Scritture contabili e sostituto imposta', 'codice_fiscale' => 'Codice fiscale', 'partita_iva_amministratore' => 'Partita IVA amministratore', 'chiavi_stabile' => 'Chiavi stabile', 'timbri_stabile' => 'Timbri stabile', 'altro' => 'Altro contenuto archivio', ]; } /** @return array */ public function getModelliEtichettaOptions(): array { return [ '11354' => 'DYMO 11354', '99014' => 'DYMO 99014', ]; } /** @return array */ public function getArchivioObbligatorioRowsProperty(): array { return [ ['numero' => 1, 'titolo' => 'Libro dei verbali eventualmente vidimato'], ['numero' => 2, 'titolo' => 'Planimetria dello stabile aggiornata'], ['numero' => 3, 'titolo' => 'Elaborato planimetrico parti comuni'], ['numero' => 4, 'titolo' => 'Scheda anagrafica condominiale e riferimenti catastali'], ['numero' => 5, 'titolo' => 'Schede tecniche impianti e beni comuni'], ['numero' => 15, 'titolo' => 'Polizza global fabbricato'], ['numero' => 16, 'titolo' => 'Attestati di conformita e nulla osta'], ['numero' => 18, 'titolo' => 'Tabelle millesimali ed elaborati di calcolo'], ['numero' => 19, 'titolo' => 'Regolamento di condominio'], ['numero' => 20, 'titolo' => 'Rendiconti approvati ultimi cinque anni'], ['numero' => 21, 'titolo' => 'Raccolta contratti stipulati'], ['numero' => 22, 'titolo' => 'Servitu e diritti di terzi'], ['numero' => 23, 'titolo' => 'Giustificativi di spesa a conservazione lunga'], ['numero' => 24, 'titolo' => 'Scritture contabili e sostituto imposta'], ['numero' => 25, 'titolo' => 'Codice fiscale'], ['numero' => 26, 'titolo' => 'Partita IVA amministratore'], ['numero' => 27, 'titolo' => 'Chiavi dello stabile'], ['numero' => 28, 'titolo' => 'Timbri dello stabile'], ]; } public function getPhysicalArchiveTypeOptionsProperty(): array { return DocumentoArchivioFisicoItem::TIPI; } public function getPhysicalArchiveParentOptionsProperty(): array { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { return []; } return DocumentoArchivioFisicoItem::query() ->where('stabile_id', $stabileId) ->whereIn('tipo_item', ['busta_trasparente', 'cartella_tre_lembi', 'faldone_anelli', 'faldone_lacci', 'scatola']) ->orderBy('codice_univoco') ->get(['id', 'codice_univoco', 'titolo']) ->mapWithKeys(fn(DocumentoArchivioFisicoItem $item): array=> [ (string) $item->id => trim($item->codice_univoco . ' · ' . $item->titolo), ]) ->all(); } public function getPhysicalArchiveDocumentOptionsProperty(): array { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { return []; } return Documento::query() ->where('stabile_id', $stabileId) ->orderByDesc('data_documento') ->orderByDesc('id') ->limit(200) ->get(['id', 'nome_file', 'nome', 'data_documento']) ->mapWithKeys(function (Documento $documento): array { $label = trim((string) ($documento->nome_file ?: $documento->nome ?: ('Documento #' . $documento->id))); if ($documento->data_documento) { $label .= ' · ' . $documento->data_documento->format('d-m-Y'); } return [(string) $documento->id => $label]; }) ->all(); } public function getPhysicalArchiveRowsProperty(): array { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { return []; } $query = DocumentoArchivioFisicoItem::query() ->with(['parent', 'documento']) ->where('stabile_id', $stabileId) ->orderByDesc('data_archiviazione') ->orderByDesc('id'); if (trim($this->physicalArchiveSearch) !== '') { $search = trim($this->physicalArchiveSearch); $query->where(function (Builder $builder) use ($search): void { $builder->where('titolo', 'like', '%' . $search . '%') ->orWhere('codice_univoco', 'like', '%' . $search . '%') ->orWhere('voce_titolo', 'like', '%' . $search . '%') ->orWhere('note', 'like', '%' . $search . '%'); }); } if (trim($this->physicalArchiveTypeFilter) !== '') { $query->where('tipo_item', $this->physicalArchiveTypeFilter); } return $query ->limit(150) ->get() ->map(function (DocumentoArchivioFisicoItem $item): array { return [ 'id' => (int) $item->id, 'codice_univoco' => (string) $item->codice_univoco, 'tipo' => (string) $item->tipo_label, 'titolo' => (string) $item->titolo, 'voce' => trim(collect([$item->voce_numero ? ('Voce ' . $item->voce_numero) : null, $item->voce_titolo])->filter()->implode(' · ')), 'data_archiviazione' => $item->data_archiviazione?->format('d-m-Y') ?: '—', 'parent' => $item->parent ? trim($item->parent->codice_univoco . ' · ' . $item->parent->titolo) : 'Radice', 'percorso' => (string) $item->percorso_fisico, 'documento' => $item->documento ? trim((string) ($item->documento->nome_file ?: $item->documento->nome ?: ('Documento #' . $item->documento->id))): null, 'ubicazione' => collect([$item->magazzino, $item->scaffale, $item->ripiano, $item->ubicazione_dettaglio])->filter()->implode(' · '), 'modello_etichetta' => (string) ($item->modello_etichetta ?: '11354'), ]; }) ->all(); } public function getPhysicalArchiveContainerTreeProperty(): array { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { return []; } return DocumentoArchivioFisicoItem::query() ->where('stabile_id', $stabileId) ->whereNull('parent_id') ->orderBy('codice_univoco') ->get() ->map(function (DocumentoArchivioFisicoItem $item): array { return [ 'id' => (int) $item->id, 'codice_univoco' => (string) $item->codice_univoco, 'titolo' => (string) $item->titolo, 'tipo' => (string) $item->tipo_label, 'children_count' => (int) $item->children()->count(), ]; }) ->all(); } public function savePhysicalArchiveEntry(): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { Notification::make()->title('Stabile attivo non disponibile')->danger()->send(); return; } $titolo = trim((string) ($this->physicalArchiveForm['titolo'] ?? '')); $tipoItem = trim((string) ($this->physicalArchiveForm['tipo_item'] ?? 'documento')); if ($titolo === '' || ! array_key_exists($tipoItem, DocumentoArchivioFisicoItem::TIPI)) { Notification::make()->title('Titolo e tipologia sono obbligatori')->danger()->send(); return; } $parentId = is_numeric($this->physicalArchiveForm['parent_id'] ?? null) ? (int) $this->physicalArchiveForm['parent_id'] : null; $parent = $parentId ? DocumentoArchivioFisicoItem::query()->where('stabile_id', $stabileId)->find($parentId) : null; $voceNumero = is_numeric($this->physicalArchiveForm['voce_numero'] ?? null) ? (int) $this->physicalArchiveForm['voce_numero'] : null; $voceTitolo = trim((string) ($this->physicalArchiveForm['voce_titolo'] ?? '')); if ($voceNumero && $voceTitolo === '') { $voceTitolo = collect($this->archivioObbligatorioRows) ->firstWhere('numero', $voceNumero)['titolo'] ?? ''; } $item = DocumentoArchivioFisicoItem::query()->create([ 'stabile_id' => $stabileId, 'parent_id' => $parent?->id, 'documento_id' => is_numeric($this->physicalArchiveForm['documento_id'] ?? null) ? (int) $this->physicalArchiveForm['documento_id'] : null, 'voce_numero' => $voceNumero, 'voce_titolo' => $voceTitolo !== '' ? $voceTitolo : null, 'tipo_item' => $tipoItem, 'titolo' => $titolo, 'note' => $this->normalizeNullableText($this->physicalArchiveForm['note'] ?? null), 'data_archiviazione' => $this->physicalArchiveForm['data_archiviazione'] ?? now()->toDateString(), 'supporto_fisico' => $this->normalizeNullableText($this->physicalArchiveForm['supporto_fisico'] ?? null), 'modello_etichetta' => $this->normalizeNullableText($this->physicalArchiveForm['modello_etichetta'] ?? null) ?: '11354', 'magazzino' => $this->normalizeNullableText($this->physicalArchiveForm['magazzino'] ?? null), 'scaffale' => $this->normalizeNullableText($this->physicalArchiveForm['scaffale'] ?? null), 'ripiano' => $this->normalizeNullableText($this->physicalArchiveForm['ripiano'] ?? null), 'ubicazione_dettaglio' => $this->normalizeNullableText($this->physicalArchiveForm['ubicazione_dettaglio'] ?? null), 'created_by' => (int) $user->id, 'updated_by' => (int) $user->id, 'metadati' => [ 'origine_digitale' => is_numeric($this->physicalArchiveForm['documento_id'] ?? null), ], ]); if ($item->documento instanceof Documento) { $meta = is_array($item->documento->metadati_archivio ?? null) ? $item->documento->metadati_archivio : []; $meta['physical_archive_item_id'] = (int) $item->id; $meta['physical_archive_code'] = (string) $item->codice_univoco; $meta['codice_univoco_documento'] = (string) $item->codice_univoco; $meta['contenitore_numero'] = $parent?->codice_univoco; $meta['supporto_fisico'] = $item->supporto_fisico; $item->documento->forceFill([ 'metadati_archivio' => $meta, ])->save(); } $this->initializePhysicalArchiveForms(); Notification::make() ->title('Voce archivio fisico registrata') ->body('Codice: ' . $item->codice_univoco) ->success() ->send(); } public function saveGenericArchiveLabel(): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { Notification::make()->title('Stabile attivo non disponibile')->danger()->send(); return; } $titolo = trim((string) ($this->genericLabelForm['titolo'] ?? '')); $tipoItem = trim((string) ($this->genericLabelForm['tipo_item'] ?? 'scatola')); if ($titolo === '' || ! array_key_exists($tipoItem, DocumentoArchivioFisicoItem::TIPI)) { Notification::make()->title('Titolo e tipologia sono obbligatori')->danger()->send(); return; } $parentId = is_numeric($this->genericLabelForm['parent_id'] ?? null) ? (int) $this->genericLabelForm['parent_id'] : null; $parent = $parentId ? DocumentoArchivioFisicoItem::query()->where('stabile_id', $stabileId)->find($parentId) : null; $amazonUrl = $this->normalizeAmazonPurchaseUrl($this->genericLabelForm['amazon_url'] ?? null); $item = DocumentoArchivioFisicoItem::query()->create([ 'stabile_id' => $stabileId, 'parent_id' => $parent?->id, 'tipo_item' => $tipoItem, 'titolo' => $titolo, 'note' => $this->normalizeNullableText($this->genericLabelForm['note'] ?? null), 'data_archiviazione' => now()->toDateString(), 'supporto_fisico' => $this->normalizeNullableText($this->genericLabelForm['supporto_fisico'] ?? null), 'modello_etichetta' => $this->normalizeNullableText($this->genericLabelForm['modello_etichetta'] ?? null) ?: '11354', 'magazzino' => $this->normalizeNullableText($this->genericLabelForm['magazzino'] ?? null), 'scaffale' => $this->normalizeNullableText($this->genericLabelForm['scaffale'] ?? null), 'ripiano' => $this->normalizeNullableText($this->genericLabelForm['ripiano'] ?? null), 'ubicazione_dettaglio' => $this->normalizeNullableText($this->genericLabelForm['ubicazione_dettaglio'] ?? null), 'created_by' => (int) $user->id, 'updated_by' => (int) $user->id, 'metadati' => [ 'generic_label' => true, 'linea_secondaria' => $this->normalizeNullableText($this->genericLabelForm['linea_secondaria'] ?? null), 'amazon_url' => $amazonUrl, 'generic_label_template' => [ 'preset_key' => $this->normalizeNullableText($this->genericLabelForm['preset_key'] ?? null), 'template_name' => $this->normalizeNullableText($this->genericLabelForm['template_name'] ?? null) ?: 'classic-right', 'mnemonic_code' => $this->normalizeNullableText($this->genericLabelForm['mnemonic_code'] ?? null) ?: $this->mnemonicCodeHint, 'title_source' => $this->normalizeNullableText($this->genericLabelForm['title_source'] ?? null) ?: 'titolo', 'subtitle_source' => $this->normalizeNullableText($this->genericLabelForm['subtitle_source'] ?? null) ?: 'linea_secondaria', 'meta_source' => $this->normalizeNullableText($this->genericLabelForm['meta_source'] ?? null) ?: 'percorso_compatto', 'note_source' => $this->normalizeNullableText($this->genericLabelForm['note_source'] ?? null) ?: 'note', 'stable_line_one_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_one_source'] ?? null) ?: 'stabile_summary', 'stable_line_two_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_two_source'] ?? null) ?: 'blank_line', 'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))), 'qr_position' => $this->normalizeNullableText($this->genericLabelForm['qr_position'] ?? null) ?: 'right-center', 'qr_scale' => $this->normalizeNullableText($this->genericLabelForm['qr_scale'] ?? null) ?: 'md', 'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')), 'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')), 'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')), 'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')), 'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')), 'line_rows' => $this->serializeGenericLabelLineRows(), ], ], ]); $this->initializeGenericLabelForm(); Notification::make() ->title('Etichetta generica pronta') ->body('Codice: ' . $item->codice_univoco) ->success() ->send(); } public function saveCurrentGenericLabelAsTemplate(): void { $label = trim($this->genericLabelTemplateLabel); if ($label === '') { Notification::make()->title('Inserisci un nome template')->danger()->send(); return; } $settings = $this->getArchiveHubSettings(); $stored = is_array($settings['generic_label_templates'] ?? null) ? $settings['generic_label_templates'] : []; $existingKey = collect($stored)->search(function (mixed $template) use ($label): bool { return is_array($template) && trim((string) ($template['label'] ?? '')) === $label; }); $templateKey = is_string($existingKey) ? $existingKey : $this->makeGenericLabelTemplateKey($label, $stored); $stored[$templateKey] = $this->buildGenericLabelTemplatePayload($label); $settings['generic_label_templates'] = $stored; $this->upsertArchiveHubSetting( json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 'Impostazioni archivio hub: template drive, link acquisto e template etichette.' ); $this->genericLabelForm['preset_key'] = $templateKey; Notification::make() ->title('Template etichetta salvato') ->body('Template: ' . $label) ->success() ->send(); } public function saveDriveTemplateSettings(): void { if (! $this->canManageDriveTemplates) { Notification::make()->title('Solo super-admin puo aggiornare il template')->danger()->send(); return; } $folders = collect(preg_split('/\r\n|\r|\n/', (string) ($this->driveTemplateForm['folders_text'] ?? '')) ?: []) ->map(fn(string $item): string => trim($item)) ->filter(fn(string $item): bool => $item !== '') ->unique() ->values() ->all(); $settings = [ 'drive_template_folders' => $folders, 'drive_template_year_root' => trim((string) ($this->driveTemplateForm['year_root'] ?? '')), 'drive_template_year_model' => trim((string) ($this->driveTemplateForm['year_model'] ?? '')), 'amazon_affiliate_tag' => trim((string) ($this->driveTemplateForm['amazon_affiliate_tag'] ?? '')), 'amazon_base_url' => trim((string) ($this->driveTemplateForm['amazon_base_url'] ?? '')), ]; $this->upsertArchiveHubSetting( json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 'Impostazioni archivio hub: template drive e link acquisto etichette.' ); $this->initializeDriveTemplateForm(); Notification::make() ->title('Template Drive aggiornato') ->body('La struttura ora viene letta dal database.') ->success() ->send(); } public function movePhysicalArchiveItem(): void { $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId <= 0) { Notification::make()->title('Stabile attivo non disponibile')->danger()->send(); return; } $sourceCode = trim((string) ($this->physicalArchiveMoveForm['source_code'] ?? '')); $targetCode = trim((string) ($this->physicalArchiveMoveForm['target_code'] ?? '')); if ($sourceCode === '' || $targetCode === '') { Notification::make()->title('Codice sorgente e destinazione obbligatori')->danger()->send(); return; } $source = DocumentoArchivioFisicoItem::query()->where('stabile_id', $stabileId)->where('codice_univoco', $sourceCode)->first(); $target = DocumentoArchivioFisicoItem::query()->where('stabile_id', $stabileId)->where('codice_univoco', $targetCode)->first(); if (! $source instanceof DocumentoArchivioFisicoItem || ! $target instanceof DocumentoArchivioFisicoItem) { Notification::make()->title('Codici archivio non trovati')->danger()->send(); return; } if ((int) $source->id === (int) $target->id) { Notification::make()->title('La destinazione deve essere diversa dalla sorgente')->danger()->send(); return; } $supportoFisico = $this->normalizeNullableText($this->physicalArchiveMoveForm['supporto_fisico'] ?? null); $magazzino = $this->normalizeNullableText($this->physicalArchiveMoveForm['magazzino'] ?? null) ?? $target->magazzino ?? $source->magazzino; $scaffale = $this->normalizeNullableText($this->physicalArchiveMoveForm['scaffale'] ?? null) ?? $target->scaffale ?? $source->scaffale; $ripiano = $this->normalizeNullableText($this->physicalArchiveMoveForm['ripiano'] ?? null) ?? $target->ripiano ?? $source->ripiano; $ubicazioneDettaglio = $this->normalizeNullableText($this->physicalArchiveMoveForm['ubicazione_dettaglio'] ?? null) ?? $target->ubicazione_dettaglio ?? $source->ubicazione_dettaglio; $payload = [ 'parent_id' => (int) $target->id, 'updated_by' => Auth::id(), 'magazzino' => $magazzino, 'scaffale' => $scaffale, 'ripiano' => $ripiano, 'ubicazione_dettaglio' => $ubicazioneDettaglio, ]; if ($supportoFisico !== null) { $payload['supporto_fisico'] = $supportoFisico; } $source->forceFill($payload)->save(); $ubicazione = collect([$magazzino, $scaffale, $ripiano, $ubicazioneDettaglio])->filter()->implode(' · '); $this->physicalArchiveMoveForm = [ 'source_code' => '', 'target_code' => '', 'supporto_fisico' => '', 'magazzino' => '', 'scaffale' => '', 'ripiano' => '', 'ubicazione_dettaglio' => '', ]; Notification::make() ->title('Movimentazione registrata') ->body(trim($source->codice_univoco . ' → ' . $target->codice_univoco . ($ubicazione !== '' ? ' · ' . $ubicazione : ''))) ->success() ->send(); } public function getArchiveLookupResultsProperty(): array { $search = trim($this->archiveLookupSearch); $codeInput = trim($this->archiveLookupCode); $needle = $search !== '' ? $search : $codeInput; $exactCode = $this->extractArchiveCodeFromInput($codeInput !== '' ? $codeInput : $search); $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); $query = DocumentoArchivioFisicoItem::query() ->with(['parent:id,codice_univoco,titolo', 'documento:id,nome,nome_file,archive_reference']) ->withCount('children') ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) ->orderByDesc('updated_at') ->orderByDesc('id'); if ($needle !== '') { $query->where(function (Builder $builder) use ($needle, $exactCode): void { $builder->where('codice_univoco', 'like', '%' . $needle . '%') ->orWhere('titolo', 'like', '%' . $needle . '%') ->orWhere('note', 'like', '%' . $needle . '%') ->orWhere('nfc_reference', 'like', '%' . $needle . '%') ->orWhere('qr_code_data', 'like', '%' . $needle . '%'); if ($exactCode !== '') { $builder->orWhere('codice_univoco', $exactCode) ->orWhere('qr_code_data', 'like', '%AF:' . $exactCode . '%'); } }); } return $query ->limit($needle !== '' ? 18 : 12) ->get() ->map(fn(DocumentoArchivioFisicoItem $item): array=> [ 'id' => (int) $item->id, 'codice_univoco' => (string) $item->codice_univoco, 'titolo' => (string) $item->titolo, 'tipo' => (string) $item->tipo_label, 'parent' => $item->parent ? trim($item->parent->codice_univoco . ' · ' . $item->parent->titolo) : 'Radice', 'children_count' => (int) $item->children_count, 'documento' => $item->documento ? trim((string) ($item->documento->nome_file ?: $item->documento->nome ?: ('Documento #' . $item->documento->id))): null, 'percorso' => (string) $item->percorso_fisico, 'ubicazione' => collect([$item->magazzino, $item->scaffale, $item->ripiano, $item->ubicazione_dettaglio])->filter()->implode(' · '), ]) ->all(); } public function getArchiveLookupSelectedItemProperty(): ?DocumentoArchivioFisicoItem { $selectedId = (int) ($this->archiveLookupSelectedId ?? 0); if ($selectedId <= 0) { return null; } $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); return DocumentoArchivioFisicoItem::query() ->with([ 'parent.parent', 'children.parent', 'children.documento', 'documento', 'stabile', ]) ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) ->find($selectedId); } public function getArchiveLookupSelectedChainProperty(): array { $item = $this->archiveLookupSelectedItem; if (! $item instanceof DocumentoArchivioFisicoItem) { return []; } $chain = []; $current = $item; while ($current instanceof DocumentoArchivioFisicoItem) { array_unshift($chain, [ 'id' => (int) $current->id, 'codice_univoco' => (string) $current->codice_univoco, 'titolo' => (string) $current->titolo, 'tipo' => (string) $current->tipo_label, ]); $current = $current->parent; } return $chain; } public function getVisibleFolderRowsProperty(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = $this->resolveActiveStabile()?->id; return $this->getVisibilityService() ->scopeVisibleCartelle(DocumentoArchivioCartella::query()->with('parent', 'stabile'), $user, $stabileId ? (int) $stabileId : null) ->orderBy('sort_order') ->orderBy('nome') ->get() ->map(function (DocumentoArchivioCartella $folder): array { $roles = collect($folder->visibility_roles ?? [])->map(fn($value): string => (string) $value)->filter()->values()->all(); $groups = collect($folder->visibility_groups ?? [])->map(fn($value): string => (string) $value)->filter()->values()->all(); $users = collect($folder->allowed_user_ids ?? [])->filter(fn($value): bool => is_numeric($value) && (int) $value > 0)->map(fn($value): int => (int) $value)->all(); $userNames = $users === [] ? [] : User::query()->whereIn('id', $users)->orderBy('name')->pluck('name')->map(fn($value): string => (string) $value)->all(); return [ 'id' => (int) $folder->id, 'nome' => (string) $folder->nome, 'path' => (string) ($folder->display_path ?: $folder->nome), 'stabile' => (string) ($folder->stabile?->denominazione ?: ('Stabile #' . (int) $folder->stabile_id)), 'scope' => (string) ($folder->visibility_scope ?: 'interno'), 'roles' => $roles, 'groups' => $groups, 'users' => $userNames, 'documents_count' => (int) $folder->documenti()->count(), ]; }) ->all(); } public function getMnemonicCodeHintProperty(): ?string { $stabile = $this->resolveActiveStabile(); if (! $stabile instanceof Stabile) { return null; } $label = Str::upper((string) ($stabile->denominazione ?? 'STABILE')); $letters = preg_replace('/[^A-Z]/', '', Str::ascii($label)) ?: 'STB'; $digits = preg_replace('/\D/', '', $label); $prefix = str_pad(substr($letters, 0, 3), 3, 'X'); $suffix = str_pad(substr($digits !== '' ? $digits : (string) $stabile->id, -5), 5, '0', STR_PAD_LEFT); return $prefix . $suffix; } public function getProtocolloComunicazioniStatsProperty(): array { $query = $this->getProtocolloComunicazioniQuery(); return [ 'totale' => (clone $query)->count(), 'con_file' => (clone $query) ->get() ->filter(fn(Documento $documento): bool => $this->hasStoredFile($documento)) ->count(), 'questo_mese' => (clone $query) ->whereDate('created_at', '>=', now()->startOfMonth()->toDateString()) ->count(), 'categorie' => (clone $query) ->reorder() ->select('tipo_documento') ->distinct() ->count('tipo_documento'), ]; } public function getProtocolloComunicazioniCategoryOptionsProperty(): array { $visibleCategories = (clone $this->getProtocolloComunicazioniQuery()) ->reorder() ->select('tipo_documento') ->distinct() ->pluck('tipo_documento') ->map(fn($value): string => trim((string) $value)) ->filter() ->values() ->all(); $labels = $this->getCategorieOptions(); return collect($visibleCategories) ->mapWithKeys(fn(string $value): array=> [$value => $labels[$value] ?? Str::headline($value)]) ->all(); } public function getProtocolloComunicazioniRowsProperty(): array { $limit = $this->protocolloView === 'list' ? 80 : 18; $labels = $this->getCategorieOptions(); return $this->getProtocolloComunicazioniQuery() ->with('cartellaArchivio') ->limit($limit) ->get() ->map(function (Documento $documento) use ($labels): array { $openUrl = route('filament.documenti.download', $documento) . '?inline=1'; $tipo = trim((string) ($documento->tipo_documento ?? '')); $fileMeta = $this->resolveDocumentoMediaMeta($documento); return [ 'id' => (int) $documento->id, 'protocollo' => trim((string) ($documento->numero_protocollo ?: 'DOC-' . $documento->id)), 'titolo' => trim((string) ($documento->nome ?: $documento->nome_file ?: ('Documento #' . $documento->id))), 'descrizione' => trim((string) ($documento->descrizione ?: Str::limit((string) ($documento->contenuto_ocr ?? ''), 140, '...'))), 'categoria' => $labels[$tipo] ?? ($tipo !== '' ? Str::headline($tipo) : 'Protocollo generale'), 'fornitore' => trim((string) ($documento->fornitore ?? '')), 'data' => $documento->data_documento?->format('d/m/Y') ?: ($documento->created_at?->format('d/m/Y') ?: '—'), 'cartella' => trim((string) ($documento->cartellaArchivio?->nome ?? 'Protocollo generale')), 'archive_code' => $documento->resolveArchiveCode(), 'archive_reference' => trim((string) ($documento->archive_reference ?? '')), 'has_file' => $fileMeta['has_file'], 'has_pdf' => $fileMeta['is_pdf'], 'can_print' => $fileMeta['can_print'], 'file_extension' => $fileMeta['extension'], 'file_kind' => $fileMeta['kind_label'], 'file_badge' => $fileMeta['badge_label'], 'preview_url' => $fileMeta['has_file'] ? $openUrl : null, 'download_url' => $fileMeta['has_file'] ? route('filament.documenti.download', $documento) : null, 'print_url' => $fileMeta['can_print'] ? $openUrl : null, ]; }) ->all(); } private function getDocumentoFormSchema(): array { return [ Select::make('stabile_id') ->label('Stabile') ->options(fn() => $this->getStabiliOptions()) ->default(fn() => $this->getDefaultStabileId()) ->searchable() ->live() ->required(), Select::make('cartella_archivio_id') ->label('Cartella archivio') ->options(fn(callable $get): array=> $this->getArchivioFolderOptions((int) ($get('stabile_id') ?: 0))) ->searchable() ->nullable(), Select::make('categoria') ->label('Categoria protocollo') ->options($this->getCategorieOptions()) ->required(), Select::make('archivio_classe') ->label('Classe archivio condominiale') ->options($this->getArchivioClasseOptions()) ->searchable() ->required(), TextInput::make('titolo') ->label('Titolo') ->required() ->maxLength(255), Textarea::make('descrizione') ->label('Descrizione') ->rows(4), TextInput::make('fornitore') ->label('Fornitore / Ente / Compagnia') ->maxLength(255), DatePicker::make('data_documento') ->label('Data documento') ->default(now()) ->required(), DatePicker::make('data_scadenza') ->label('Data scadenza') ->nullable(), TextInput::make('importo') ->label('Importo (opzionale)') ->numeric() ->step(0.01) ->minValue(0) ->nullable(), Select::make('movimento_contabile_id') ->label('Movimento contabile (opzionale)') ->options(fn(callable $get) => $this->getMovimentiOptions((int) ($get('stabile_id') ?: 0))) ->searchable() ->nullable(), TextInput::make('faldone') ->label('Faldone (archiviazione fisica)') ->placeholder('Es. CONTR-2024') ->maxLength(50) ->nullable(), Select::make('supporto_fisico') ->label('Supporto fisico') ->options(array_combine($this->archivioFisicoTypes, $this->archivioFisicoTypes)) ->searchable() ->nullable(), Select::make('modello_etichetta') ->label('Modello etichetta') ->options($this->getModelliEtichettaOptions()) ->default('11354') ->native(false) ->nullable(), TextInput::make('busta_numero') ->label('Busta n.') ->maxLength(30), TextInput::make('fascicolo_numero') ->label('Fascicolo n.') ->maxLength(30), TextInput::make('contenitore_numero') ->label('Contenitore / faldone') ->placeholder('Es. 12') ->maxLength(30), DatePicker::make('periodo_riferimento_da') ->label('Periodo dal'), DatePicker::make('periodo_riferimento_a') ->label('Periodo al'), Select::make('visibility_scope') ->label('Visibilità') ->options($this->getVisibilityScopeOptions()) ->default('interno') ->native(false) ->required(), Select::make('visibility_roles') ->label('Ruoli ammessi') ->options($this->getVisibilityRoleOptions()) ->multiple() ->native(false), Select::make('visibility_groups') ->label('Gruppi audience') ->options(array_combine($this->audienceGroups, $this->audienceGroups)) ->multiple() ->native(false), Select::make('allowed_user_ids') ->label('Utenti specifici ammessi') ->options($this->getCollaboratorUserOptions()) ->multiple() ->native(false) ->searchable(), FileUpload::make('pdf') ->label('PDF') ->disk('local') ->acceptedFileTypes(['application/pdf']) ->required(), ]; } private function getArchivioFolderFormSchema(): array { return [ Select::make('stabile_id') ->label('Stabile') ->options(fn(): array=> $this->getStabiliOptions()) ->default(fn(): ?int => $this->getDefaultStabileId()) ->searchable() ->live() ->required(), Select::make('parent_id') ->label('Cartella padre') ->options(fn(callable $get): array=> $this->getArchivioFolderOptions((int) ($get('stabile_id') ?: 0))) ->searchable() ->nullable(), TextInput::make('nome') ->label('Nome cartella') ->required() ->maxLength(160), TextInput::make('codice') ->label('Codice cartella') ->maxLength(80), Select::make('visibility_scope') ->label('Visibilità') ->options($this->getVisibilityScopeOptions()) ->default('interno') ->native(false) ->required(), Select::make('visibility_roles') ->label('Ruoli ammessi') ->options($this->getVisibilityRoleOptions()) ->multiple() ->native(false), Select::make('visibility_groups') ->label('Gruppi audience') ->options(array_combine($this->audienceGroups, $this->audienceGroups)) ->multiple() ->native(false), Select::make('allowed_user_ids') ->label('Utenti specifici') ->options($this->getCollaboratorUserOptions()) ->multiple() ->native(false), Textarea::make('note') ->label('Note cartella') ->rows(3), ]; } private function getStabiliOptions(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } if ($user->hasAnyRole(['super-admin', 'admin'])) { return Stabile::query() ->orderBy('denominazione') ->limit(500) ->get(['id', 'denominazione']) ->mapWithKeys(fn(Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))]) ->all(); } $allowed = StabileContext::accessibleStabili($user); return $allowed ->sortBy(fn(Stabile $s) => $s->denominazione) ->take(500) ->mapWithKeys(fn(Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))]) ->all(); } private function initializePhysicalArchiveForms(): void { $this->physicalArchiveForm = [ 'data_archiviazione' => now()->toDateString(), 'voce_numero' => null, 'voce_titolo' => '', 'titolo' => '', 'note' => '', 'tipo_item' => 'documento', 'supporto_fisico' => 'Busta trasparente con anelli da archiviare', 'parent_id' => null, 'documento_id' => null, 'magazzino' => '', 'scaffale' => '', 'ripiano' => '', 'ubicazione_dettaglio' => '', 'modello_etichetta' => '11354', ]; $this->physicalArchiveMoveForm = [ 'source_code' => '', 'target_code' => '', 'supporto_fisico' => '', 'magazzino' => '', 'scaffale' => '', 'ripiano' => '', 'ubicazione_dettaglio' => '', ]; } private function initializeGenericLabelForm(): void { $this->genericLabelForm = [ 'preset_key' => '', 'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'), 'titolo' => '', 'linea_secondaria' => '', 'note' => '', 'tipo_item' => 'scatola', 'supporto_fisico' => 'Scatola', 'parent_id' => null, 'magazzino' => '', 'scaffale' => '', 'ripiano' => '', 'ubicazione_dettaglio' => '', 'modello_etichetta' => '11354', 'amazon_url' => '', 'template_name' => 'archive-99014', 'title_source' => 'titolo', 'subtitle_source' => 'linea_secondaria', 'meta_source' => 'percorso_compatto', 'note_source' => 'note', 'stable_line_one_source' => 'stabile_summary', 'stable_line_two_source' => 'blank_line', 'blank_lines_count' => 2, 'qr_position' => 'bottom-right', 'qr_scale' => 'lg', 'line_1_mode' => 'source', 'line_1_source' => 'stabile_summary', 'line_1_text' => '', 'line_2_mode' => 'blank_line', 'line_2_source' => 'none', 'line_2_text' => '', 'line_3_mode' => 'blank_line', 'line_3_source' => 'none', 'line_3_text' => '', 'line_4_mode' => 'none', 'line_4_source' => 'none', 'line_4_text' => '', 'mnemonic_font_size' => 'xl', 'title_font_size' => 'xl', 'subtitle_font_size' => 'md', 'lines_font_size' => 'md', 'note_font_size' => 'sm', ]; $this->genericLabelTemplateLabel = ''; } private function initializeScannerAcquisitionForm(): void { $this->scannerAcquisitionForm = [ 'stabile_id' => (int) ($this->resolveActiveStabile()?->id ?? $this->getDefaultStabileId() ?? 0), 'cartella_archivio_id' => $this->currentFolderId, 'categoria' => 'altro', 'archivio_classe' => 'altro', 'titolo' => '', 'descrizione' => '', 'fornitore' => '', 'data_documento' => now()->toDateString(), 'data_scadenza' => null, 'importo' => null, 'faldone' => '', 'supporto_fisico' => 'Busta trasparente con anelli da archiviare', 'modello_etichetta' => '11354', 'busta_numero' => '', 'fascicolo_numero' => '', 'contenitore_numero' => '', 'periodo_riferimento_da' => null, 'periodo_riferimento_a' => null, 'visibility_scope' => 'interno', 'canale_acquisizione' => 'scanner_upload', 'scanner_profile' => '', 'riferimento_acquisizione' => '', 'scanner_note' => '', ]; } /** @return array */ public function getScannerQrPreviewProperty(): array { $title = trim((string) ($this->scannerAcquisitionForm['titolo'] ?? '')); $supporto = trim((string) ($this->scannerAcquisitionForm['supporto_fisico'] ?? '')); $stabileId = (int) ($this->scannerAcquisitionForm['stabile_id'] ?? 0); $categoria = trim((string) ($this->scannerAcquisitionForm['categoria'] ?? 'altro')); $classe = trim((string) ($this->scannerAcquisitionForm['archivio_classe'] ?? 'altro')); $busta = trim((string) ($this->scannerAcquisitionForm['busta_numero'] ?? '')); $fascicolo = trim((string) ($this->scannerAcquisitionForm['fascicolo_numero'] ?? '')); $contenitore = trim((string) ($this->scannerAcquisitionForm['contenitore_numero'] ?? '')); $archiveCode = trim(implode('-', array_filter([ strtoupper($categoria !== '' ? $categoria : 'DOC'), now()->format('Y'), $busta !== '' ? 'B' . $busta : null, $fascicolo !== '' ? 'F' . $fascicolo : null, $contenitore !== '' ? 'C' . $contenitore : null, ]))); $profiles = ArchiveQrXmlPayload::buildProfiles([ 'id' => $archiveCode !== '' ? $archiveCode : 'DOC-PREVIEW', 'legacy' => 'DOC:' . ($archiveCode !== '' ? $archiveCode : 'DOC-PREVIEW'), 'type' => 'ATTO', 'stabile_id' => (string) $stabileId, 'parent' => $supporto !== '' ? strtoupper(Str::slug($supporto, '-')) : 'RADICE', 'code' => $archiveCode, 'title' => $title, 'document_code' => $archiveCode, 'archive_class' => strtoupper($classe), 'location' => $supporto, ]); return [ 'code' => $archiveCode, 'xml_open' => (string) ($profiles['xml_open'] ?? ''), 'xml_zip' => (string) ($profiles['xml_zip'] ?? ''), 'xml_nfc' => (string) ($profiles['xml_nfc'] ?? ''), 'xml_open_length' => (int) ($profiles['xml_open_length'] ?? 0), 'xml_nfc_length' => (int) ($profiles['xml_nfc_length'] ?? 0), ]; } private function initializeDriveTemplateForm(): void { $settings = $this->getArchiveHubSettings(); $this->driveTemplateForm = [ 'folders_text' => implode("\n", $settings['drive_template_folders'] ?? []), 'year_root' => (string) ($settings['drive_template_year_root'] ?? ''), 'year_model' => (string) ($settings['drive_template_year_model'] ?? ''), 'amazon_affiliate_tag' => (string) ($settings['amazon_affiliate_tag'] ?? ''), 'amazon_base_url' => (string) ($settings['amazon_base_url'] ?? 'https://www.amazon.it/dp'), ]; } private function getArchiveHubSettings(): array { $fallback = [ 'drive_template_folders' => config('netgescon.google.drive_template_folders', []), 'drive_template_year_root' => (string) config('netgescon.google.drive_template_year_root', ''), 'drive_template_year_model' => (string) config('netgescon.google.drive_template_year_model', ''), 'amazon_affiliate_tag' => '', 'amazon_base_url' => 'https://www.amazon.it/dp', 'generic_label_templates' => [], ]; $stored = $this->readArchiveHubSettingValue(); if (! is_string($stored) || trim($stored) === '') { return $fallback; } $decoded = json_decode($stored, true); if (! is_array($decoded)) { return $fallback; } return array_merge($fallback, $decoded); } private function hasStoredArchiveHubSettings(): bool { return $this->archiveHubSettingsQuery()->exists(); } private function readArchiveHubSettingValue(): ?string { $value = $this->archiveHubSettingsQuery()->value('valore'); return is_string($value) ? $value : null; } private function upsertArchiveHubSetting(string $value, string $description): void { $modelClass = $this->resolveArchiveHubSettingsModelClass(); if ($modelClass !== null) { $modelClass::query()->updateOrCreate( ['chiave' => self::ARCHIVE_HUB_SETTINGS_KEY], [ 'valore' => $value, 'descrizione' => $description, ] ); return; } DB::table('impostazioni')->updateOrInsert( ['chiave' => self::ARCHIVE_HUB_SETTINGS_KEY], [ 'valore' => $value, 'descrizione' => $description, ] ); } private function archiveHubSettingsQuery(): Builder | \Illuminate\Database\Query\Builder { $modelClass = $this->resolveArchiveHubSettingsModelClass(); return $modelClass !== null ? $modelClass::query()->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY) : DB::table('impostazioni')->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY); } private function resolveArchiveHubSettingsModelClass(): ?string { $class = Impostazione::class; if (! class_exists($class) || ! is_subclass_of($class, Model::class)) { return null; } return $class; } private function normalizeAmazonPurchaseUrl(mixed $value): ?string { $input = trim((string) $value); if ($input === '') { return null; } $settings = $this->getArchiveHubSettings(); $baseUrl = rtrim((string) ($settings['amazon_base_url'] ?? 'https://www.amazon.it/dp'), '/'); if (preg_match('/^[A-Z0-9]{10}$/i', $input) === 1) { $input = $baseUrl . '/' . strtoupper($input); } elseif (! preg_match('#^https?://#i', $input)) { $input = 'https://' . ltrim($input, '/'); } $tag = trim((string) ($settings['amazon_affiliate_tag'] ?? '')); if ($tag !== '' && ! str_contains($input, 'tag=')) { $input .= (str_contains($input, '?') ? '&' : '?') . 'tag=' . rawurlencode($tag); } return $input; } public function applyGenericLabelPreset(string $presetKey): void { $preset = $this->getGenericLabelPresetDefinitions()[$presetKey] ?? null; if (! is_array($preset)) { return; } $this->genericLabelForm = array_merge($this->genericLabelForm, [ 'preset_key' => $presetKey, 'mnemonic_code' => (string) ($preset['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079')), 'titolo' => (string) ($preset['titolo'] ?? ''), 'linea_secondaria' => (string) ($preset['linea_secondaria'] ?? ''), 'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'), 'supporto_fisico' => (string) ($preset['supporto_fisico'] ?? ''), 'modello_etichetta' => (string) ($preset['modello_etichetta'] ?? '11354'), 'template_name' => (string) ($preset['template_name'] ?? 'classic-right'), 'title_source' => (string) data_get($preset, 'sources.title', 'titolo'), 'subtitle_source' => (string) data_get($preset, 'sources.subtitle', 'linea_secondaria'), 'meta_source' => (string) data_get($preset, 'sources.meta', 'percorso_compatto'), 'note_source' => (string) data_get($preset, 'sources.note', 'note'), 'stable_line_one_source' => (string) ($preset['stable_line_one_source'] ?? 'stabile_summary'), 'stable_line_two_source' => (string) ($preset['stable_line_two_source'] ?? 'blank_line'), 'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))), 'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'), 'qr_scale' => (string) ($preset['qr_scale'] ?? 'md'), 'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['mnemonic_font_size'] ?? 'xl')), 'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['title_font_size'] ?? 'xl')), 'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['subtitle_font_size'] ?? 'md')), 'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['lines_font_size'] ?? 'md')), 'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['note_font_size'] ?? 'sm')), ]); $lineRows = is_array($preset['line_rows'] ?? null) ? $preset['line_rows'] : []; for ($index = 1; $index <= 4; $index++) { $row = $lineRows[$index - 1] ?? $this->getLegacyGenericLabelLineRow($index, $preset); $this->genericLabelForm['line_' . $index . '_mode'] = $this->normalizeGenericLabelLineMode((string) ($row['mode'] ?? 'none')); $this->genericLabelForm['line_' . $index . '_source'] = (string) ($row['source'] ?? 'none'); $this->genericLabelForm['line_' . $index . '_text'] = (string) ($row['text'] ?? ''); } } public function applySelectedGenericLabelPreset(): void { $this->applyGenericLabelPreset((string) ($this->genericLabelForm['preset_key'] ?? '')); } /** @return array> */ private function getGenericLabelPresetDefinitions(): array { return array_merge( $this->getBuiltInGenericLabelPresetDefinitions(), $this->getStoredGenericLabelPresetDefinitions(), ); } /** @return array> */ private function getBuiltInGenericLabelPresetDefinitions(): array { return [ 'foglio-singolo' => [ 'label' => 'Foglio / documento singolo', 'titolo' => 'FOGLIO ARCHIVIATO', 'linea_secondaria' => 'Documento cartaceo singolo', 'tipo_item' => 'foglio', 'supporto_fisico' => 'Foglio archiviato', 'modello_etichetta' => '11354', 'template_name' => 'compact-grid', 'qr_position' => 'right-top', 'qr_scale' => 'sm', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'codice_univoco', 'note' => 'percorso_compatto', ], ], 'cartellina-operativa' => [ 'label' => 'Cartellina', 'titolo' => 'CARTELLINA OPERATIVA', 'linea_secondaria' => 'Primo nodo sotto-documento', 'tipo_item' => 'cartellina', 'supporto_fisico' => 'Cartellina archivio', 'modello_etichetta' => '11354', 'template_name' => 'classic-right', 'qr_position' => 'right-center', 'qr_scale' => 'md', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'stabile', 'meta' => 'percorso_compatto', 'note' => 'ubicazione', ], ], 'busta-anelli' => [ 'label' => 'Busta ad anelli', 'titolo' => 'BUSTA AD ANELLI', 'linea_secondaria' => 'Nodo intermedio cartaceo', 'tipo_item' => 'busta_trasparente', 'supporto_fisico' => 'Busta trasparente ad anelli', 'modello_etichetta' => '11354', 'template_name' => 'compact-grid', 'qr_position' => 'right-top', 'qr_scale' => 'sm', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'tipo_label', 'note' => 'codice_univoco', ], ], 'scatola-contratti' => [ 'label' => 'Scatola contratti', 'titolo' => 'SCATOLA CONTRATTI', 'linea_secondaria' => 'Fornitori / manutenzioni / polizze', 'tipo_item' => 'scatola', 'supporto_fisico' => 'Scatola archivio', 'modello_etichetta' => '11354', 'template_name' => 'classic-right', 'qr_position' => 'right-center', 'qr_scale' => 'md', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'percorso_compatto', 'note' => 'ubicazione', ], ], 'raccoglitore-verbali' => [ 'label' => 'Raccoglitore verbali', 'titolo' => 'RACCOGLITORE VERBALI', 'linea_secondaria' => 'Assemblee / delibere / allegati', 'tipo_item' => 'faldone_anelli', 'supporto_fisico' => 'Raccoglitore ad anelli', 'modello_etichetta' => '99014', 'template_name' => 'band-top', 'qr_position' => 'bottom-right', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'stabile', 'meta' => 'linea_secondaria', 'note' => 'percorso_compatto', ], ], 'busta-documenti' => [ 'label' => 'Busta documenti', 'titolo' => 'BUSTA DOCUMENTI', 'linea_secondaria' => 'Protocollo operativo', 'tipo_item' => 'busta_trasparente', 'supporto_fisico' => 'Busta trasparente', 'modello_etichetta' => '11354', 'template_name' => 'compact-grid', 'qr_position' => 'right-top', 'qr_scale' => 'sm', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'tipo_label', 'note' => 'codice_univoco', ], ], 'trasferimento-amministratore' => [ 'label' => 'Trasferimento amministratore', 'titolo' => 'TRASFERIMENTO PROTOCOLLI', 'linea_secondaria' => 'Contenitore generale di passaggio consegne', 'tipo_item' => 'scatola', 'supporto_fisico' => 'Contenitore trasferimento amministratore', 'modello_etichetta' => '99014', 'template_name' => 'band-top', 'qr_position' => 'bottom-right', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'stabile', 'note' => 'percorso_compatto', ], ], 'magazzino-logistico' => [ 'label' => 'Nodo logistico magazzino', 'titolo' => 'NODO LOGISTICO', 'linea_secondaria' => 'Magazzino / scaffale / ripiano', 'tipo_item' => 'posto', 'supporto_fisico' => 'Nodo logistico', 'modello_etichetta' => '99014', 'template_name' => 'classic-bottom', 'qr_position' => 'bottom-center', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'ubicazione', 'meta' => 'stabile', 'note' => 'note', ], ], 'scaffale-archivio' => [ 'label' => 'Scaffale archivio', 'titolo' => 'SCAFFALE ARCHIVIO', 'linea_secondaria' => 'Contiene scatole e faldoni', 'tipo_item' => 'scaffale', 'supporto_fisico' => 'Scaffale', 'modello_etichetta' => '99014', 'template_name' => 'band-top', 'qr_position' => 'bottom-right', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'ubicazione', 'meta' => 'stabile', 'note' => 'percorso_compatto', ], ], 'posto-finale' => [ 'label' => 'Posto / ubicazione finale', 'titolo' => 'POSTO ARCHIVIO', 'linea_secondaria' => 'Ultimo nodo della catena QR', 'tipo_item' => 'posto', 'supporto_fisico' => 'Ubicazione finale', 'modello_etichetta' => '99014', 'template_name' => 'classic-bottom', 'qr_position' => 'bottom-center', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'ubicazione', 'meta' => 'stabile', 'note' => 'note', ], ], 'faldone-archivio-99014' => [ 'label' => 'Faldone archivio 99014', 'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'), 'titolo' => 'FALDONE ARCHIVIO', 'linea_secondaria' => 'Contenitore operativo con note manuali', 'tipo_item' => 'faldone_anelli', 'supporto_fisico' => 'Faldone con anelli', 'modello_etichetta' => '99014', 'template_name' => 'archive-99014', 'stable_line_one_source' => 'stabile_summary', 'stable_line_two_source' => 'blank_line', 'blank_lines_count' => 2, 'qr_position' => 'bottom-right', 'qr_scale' => 'lg', 'sources' => [ 'title' => 'titolo', 'subtitle' => 'linea_secondaria', 'meta' => 'stabile_summary', 'note' => 'note', ], ], ]; } /** @return array> */ private function getStoredGenericLabelPresetDefinitions(): array { $settings = $this->getArchiveHubSettings(); $stored = $settings['generic_label_templates'] ?? []; if (! is_array($stored)) { return []; } return collect($stored) ->filter(fn(mixed $template): bool => is_array($template)) ->map(function (array $template): array { $template['label'] = trim((string) ($template['label'] ?? 'Template personalizzato')); return $template; }) ->all(); } /** @param array> $stored */ private function makeGenericLabelTemplateKey(string $label, array $stored): string { $baseKey = 'custom-' . (Str::slug($label) ?: 'template'); $key = $baseKey; $suffix = 2; while (array_key_exists($key, $stored)) { $key = $baseKey . '-' . $suffix; $suffix++; } return $key; } /** @return array */ private function buildGenericLabelTemplatePayload(string $label): array { return [ 'label' => $label, 'mnemonic_code' => trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')), 'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')), 'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')), 'tipo_item' => (string) ($this->genericLabelForm['tipo_item'] ?? 'scatola'), 'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')), 'modello_etichetta' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'), 'template_name' => (string) ($this->genericLabelForm['template_name'] ?? 'classic-right'), 'stable_line_one_source' => (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary'), 'stable_line_two_source' => (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line'), 'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))), 'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'), 'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'), 'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')), 'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')), 'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')), 'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')), 'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')), 'line_rows' => $this->serializeGenericLabelLineRows(), 'sources' => [ 'title' => (string) ($this->genericLabelForm['title_source'] ?? 'titolo'), 'subtitle' => (string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'), 'meta' => (string) ($this->genericLabelForm['meta_source'] ?? 'percorso_compatto'), 'note' => (string) ($this->genericLabelForm['note_source'] ?? 'note'), ], ]; } /** @return array */ private function getGenericLabelTemplateDefinition(string $templateName): array { $templates = [ 'archive-99014' => ['qr_position' => 'bottom-right', 'accent' => 'slate', 'mode' => 'archive-99014'], 'classic-right' => ['qr_position' => 'right-center', 'accent' => 'slate'], 'classic-bottom' => ['qr_position' => 'bottom-center', 'accent' => 'sky'], 'band-top' => ['qr_position' => 'bottom-right', 'accent' => 'emerald'], 'compact-grid' => ['qr_position' => 'right-top', 'accent' => 'amber'], ]; return $templates[$templateName] ?? $templates['classic-right']; } /** @param array $payload */ private function resolveGenericLabelTemplateValue(string $fieldKey, array $payload): string { if ($fieldKey === 'none') { return ''; } return trim((string) ($payload[$fieldKey] ?? '')); } /** @param array $payload */ private function resolveGenericLabelStableLineValue(string $fieldKey, array $payload): string { if ($fieldKey === 'none') { return ''; } if ($fieldKey === 'blank_line') { return '__BLANK_LINE__'; } return trim((string) ($payload[$fieldKey] ?? '')); } /** @param array $payload * @return array */ private function buildGenericLabelLineRows(array $payload): array { $rows = []; for ($index = 1; $index <= 4; $index++) { $mode = $this->normalizeGenericLabelLineMode((string) ($this->genericLabelForm['line_' . $index . '_mode'] ?? 'none')); if ($mode === 'blank_line') { $rows[] = '__BLANK_LINE__'; continue; } if ($mode === 'text') { $rows[] = trim((string) ($this->genericLabelForm['line_' . $index . '_text'] ?? '')); continue; } if ($mode === 'source') { $rows[] = $this->resolveGenericLabelStableLineValue((string) ($this->genericLabelForm['line_' . $index . '_source'] ?? 'none'), $payload); } } return $rows; } /** @return array> */ private function serializeGenericLabelLineRows(): array { $rows = []; for ($index = 1; $index <= 4; $index++) { $rows[] = [ 'mode' => $this->normalizeGenericLabelLineMode((string) ($this->genericLabelForm['line_' . $index . '_mode'] ?? 'none')), 'source' => (string) ($this->genericLabelForm['line_' . $index . '_source'] ?? 'none'), 'text' => trim((string) ($this->genericLabelForm['line_' . $index . '_text'] ?? '')), ]; } return $rows; } /** @param array $preset * @return array */ private function getLegacyGenericLabelLineRow(int $index, array $preset): array { if ($index === 1) { return [ 'mode' => 'source', 'source' => (string) ($preset['stable_line_one_source'] ?? 'stabile_summary'), 'text' => '', ]; } if ($index === 2) { $source = (string) ($preset['stable_line_two_source'] ?? 'blank_line'); return [ 'mode' => $source === 'blank_line' ? 'blank_line' : 'source', 'source' => $source, 'text' => '', ]; } $blankCount = max(0, min(4, (int) ($preset['blank_lines_count'] ?? 0))); return [ 'mode' => $index <= ($blankCount + 2) ? 'blank_line' : 'none', 'source' => 'none', 'text' => '', ]; } private function normalizeGenericLabelLineMode(string $mode): string { return in_array($mode, ['source', 'text', 'blank_line', 'none'], true) ? $mode : 'none'; } private function normalizeGenericLabelFontSize(string $size): string { return in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'md'; } private function getDefaultStabileId(): ?int { $user = Auth::user(); if (! $user instanceof User) { return null; } $activeId = StabileContext::getActiveStabileId($user); if (is_int($activeId) && $activeId > 0) { return $activeId; } return StabileContext::accessibleStabili($user)->first()?->id; } private function getVisibilityService(): DocumentoArchivioVisibilityService { return $this->visibilityService ??= app(DocumentoArchivioVisibilityService::class); } private function getVisibilityScopeOptions(): array { return [ 'interno' => 'Interno studio', 'studio' => 'Studio completo', 'restricted' => 'Limitato a ruoli / utenti', ]; } private function getVisibilityRoleOptions(): array { return [ 'super-admin' => 'Super admin', 'admin' => 'Admin', 'amministratore' => 'Amministratore', 'collaboratore' => 'Collaboratore', ]; } private function getCollaboratorUserOptions(): array { return User::query() ->role(['super-admin', 'admin', 'amministratore', 'collaboratore']) ->orderBy('name') ->limit(300) ->get(['id', 'name', 'email']) ->mapWithKeys(function (User $user): array { $label = trim((string) $user->name); if (trim((string) $user->email) !== '') { $label .= ' <' . trim((string) $user->email) . '>'; } return [(string) $user->id => $label !== '' ? $label : ('Utente #' . (int) $user->id)]; }) ->all(); } private function getArchivioFolderOptions(int $stabileId): array { if ($stabileId <= 0) { return []; } return DocumentoArchivioCartella::query() ->where('stabile_id', $stabileId) ->where('is_active', true) ->orderBy('sort_order') ->orderBy('nome') ->get(['id', 'nome', 'percorso_logico']) ->mapWithKeys(function (DocumentoArchivioCartella $folder): array { $label = trim((string) ($folder->percorso_logico ?: $folder->nome)); return [(string) $folder->id => $label !== '' ? $label : ('Cartella #' . (int) $folder->id)]; }) ->all(); } private function syncInvoiceFolders(): void { $stabile = $this->resolveActiveStabile(); if (! $stabile instanceof Stabile) { $this->currentFolderId = null; return; } $root = $this->ensureInvoiceRootFolder((int) $stabile->id); if (! $root instanceof DocumentoArchivioCartella) { return; } Documento::query() ->where('stabile_id', $stabile->id) ->where('tipo_documento', 'fattura') ->orderBy('data_documento') ->orderBy('id') ->get(['id', 'data_documento', 'cartella_archivio_id']) ->each(function (Documento $documento) use ($stabile, $root): void { $year = (int) (($documento->data_documento?->format('Y')) ?: now()->format('Y')); $yearFolder = $this->ensureInvoiceYearFolder((int) $stabile->id, $root, $year); if (! $yearFolder instanceof DocumentoArchivioCartella) { return; } if ((int) ($documento->cartella_archivio_id ?? 0) !== (int) $yearFolder->id) { $documento->forceFill([ 'cartella_archivio_id' => (int) $yearFolder->id, ])->save(); } }); if ($this->currentFolderId) { $exists = DocumentoArchivioCartella::query() ->whereKey($this->currentFolderId) ->where('stabile_id', $stabile->id) ->exists(); if (! $exists) { $this->currentFolderId = null; } } } private function ensureInvoiceRootFolder(int $stabileId): ?DocumentoArchivioCartella { if ($stabileId <= 0) { return null; } return DocumentoArchivioCartella::query()->firstOrCreate( [ 'stabile_id' => $stabileId, 'parent_id' => null, 'slug' => 'fatture-pdf', ], [ 'nome' => 'fatture_PDF', 'codice' => 'FATTURE', 'percorso_logico' => 'fatture_PDF', 'visibility_scope' => 'interno', 'created_by' => Auth::id(), 'updated_by' => Auth::id(), ] ); } private function ensureInvoiceYearFolder(int $stabileId, DocumentoArchivioCartella $root, int $year): ?DocumentoArchivioCartella { if ($stabileId <= 0 || $year < 2000) { return null; } return DocumentoArchivioCartella::query()->firstOrCreate( [ 'stabile_id' => $stabileId, 'parent_id' => (int) $root->id, 'slug' => (string) $year, ], [ 'nome' => (string) $year, 'codice' => 'FATT-' . $year, 'percorso_logico' => trim((string) $root->nome) . '/' . $year, 'visibility_scope' => 'interno', 'created_by' => Auth::id(), 'updated_by' => Auth::id(), ] ); } private function formatPhysicalArchiveLocation(Documento $record): string { $meta = is_array($record->metadati_archivio ?? null) ? $record->metadati_archivio : []; $parts = array_filter([ $this->normalizeNullableText($meta['supporto_fisico'] ?? null), $this->normalizeNullableText($meta['contenitore_numero'] ?? null), $this->normalizeNullableText($meta['busta_numero'] ?? null) ? 'Busta ' . $this->normalizeNullableText($meta['busta_numero'] ?? null) : null, $this->normalizeNullableText($meta['fascicolo_numero'] ?? null) ? 'Fascicolo ' . $this->normalizeNullableText($meta['fascicolo_numero'] ?? null) : null, ]); return $parts === [] ? '—' : implode(' · ', $parts); } private function normalizeHubTab(string $tab): string { $allowed = ['protocollo-comunicazioni', 'archivio-digitale', 'scansione-documenti', 'etichette-generiche', 'movimentazione-codici', 'lettore-codici', 'template-drive', 'archivio-fisico', 'permessi']; return in_array($tab, $allowed, true) ? $tab : 'protocollo-comunicazioni'; } private function applyArchiveLookupInput(string $input): void { $value = trim($input); if ($value === '') { return; } $exactCode = $this->extractArchiveCodeFromInput($value); $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); $item = DocumentoArchivioFisicoItem::query() ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) ->where(function (Builder $builder) use ($value, $exactCode): void { $builder->where('codice_univoco', $value) ->orWhere('nfc_reference', $value) ->orWhere('qr_code_data', $value); if ($exactCode !== '') { $builder->orWhere('codice_univoco', $exactCode) ->orWhere('qr_code_data', 'like', '%AF:' . $exactCode . '%'); } }) ->orderByDesc('id') ->first(); if (! $item instanceof DocumentoArchivioFisicoItem) { return; } $this->archiveLookupSelectedId = (int) $item->id; $this->archiveLookupCode = $exactCode !== '' ? $exactCode : $value; } private function extractArchiveCodeFromInput(string $input): string { $input = trim($input); if ($input === '') { return ''; } if (preg_match('/AF:([A-Z0-9\-]+)/i', $input, $match) === 1) { return trim((string) ($match[1] ?? '')); } if (preg_match('/\b(AF-[A-Z0-9\-]+)\b/i', $input, $match) === 1) { return trim((string) ($match[1] ?? '')); } return $input; } private function getProtocolloComunicazioniQuery(): Builder { $user = Auth::user(); if (! $user instanceof User) { return Documento::query()->whereRaw('1 = 0'); } $query = $this->getVisibilityService() ->scopeVisibleDocumenti(Documento::query(), $user) ->where(function (Builder $builder): void { $builder->whereNull('tipo_documento') ->orWhere('tipo_documento', '!=', 'fattura'); }); $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); if ($stabileId > 0) { $query->where('stabile_id', $stabileId); } $category = trim($this->protocolloCategory); if ($category !== '') { $query->where('tipo_documento', $category); } $search = trim($this->protocolloSearch); if ($search !== '') { $query->where(function (Builder $builder) use ($search): void { $builder->where('numero_protocollo', 'like', '%' . $search . '%') ->orWhere('nome', 'like', '%' . $search . '%') ->orWhere('nome_file', 'like', '%' . $search . '%') ->orWhere('descrizione', 'like', '%' . $search . '%') ->orWhere('fornitore', 'like', '%' . $search . '%') ->orWhere('archive_reference', 'like', '%' . $search . '%'); }); } return $query ->orderByDesc('data_documento') ->orderByDesc('created_at') ->orderByDesc('id'); } private function resolveActiveStabile(): ?Stabile { $user = Auth::user(); if (! $user instanceof User) { return null; } return StabileContext::getActiveStabile($user); } private function getMovimentiOptions(int $stabileId): array { if ($stabileId <= 0) { return []; } if (! class_exists(MovimentoContabile::class)) { return []; } return MovimentoContabile::query() ->where('stabile_id', $stabileId) ->orderByDesc('data_movimento') ->orderByDesc('id') ->limit(200) ->get(['id', 'descrizione', 'data_movimento', 'importo']) ->mapWithKeys(function (MovimentoContabile $m) { $label = '#' . $m->id; if ($m->data_movimento) { $label .= ' ' . $m->data_movimento->format('d-m-Y'); } if (is_string($m->descrizione) && trim($m->descrizione) !== '') { $label .= ' — ' . trim($m->descrizione); } if ($m->importo !== null) { $label .= ' — €' . number_format((float) $m->importo, 2, ',', '.'); } return [(string) $m->id => $label]; }) ->all(); } private function createArchivioFolder(array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $stabileId = isset($data['stabile_id']) && is_numeric($data['stabile_id']) ? (int) $data['stabile_id'] : 0; if ($stabileId <= 0) { Notification::make()->title('Stabile obbligatorio')->danger()->send(); return; } $allowedIds = StabileContext::accessibleStabili($user)->pluck('id')->map(fn($value): int => (int) $value)->all(); if (! in_array($stabileId, $allowedIds, true) && ! $user->hasAnyRole(['super-admin', 'admin'])) { Notification::make()->title('Stabile non accessibile')->danger()->send(); return; } $name = trim((string) ($data['nome'] ?? '')); if ($name === '') { Notification::make()->title('Nome cartella obbligatorio')->danger()->send(); return; } $parentId = isset($data['parent_id']) && is_numeric($data['parent_id']) ? (int) $data['parent_id'] : null; $parent = $parentId ? DocumentoArchivioCartella::query()->where('stabile_id', $stabileId)->find($parentId) : null; $slug = Str::slug($name); if ($slug === '') { $slug = 'cartella-' . now()->format('YmdHis'); } $logicalPath = collect([ $parent?->percorso_logico, $name, ])->filter(fn($value): bool => is_string($value) && trim($value) !== '')->implode('/'); DocumentoArchivioCartella::query()->create([ 'stabile_id' => $stabileId, 'parent_id' => $parent?->id, 'nome' => $name, 'slug' => $slug, 'codice' => trim((string) ($data['codice'] ?? '')) ?: null, 'percorso_logico' => $logicalPath !== '' ? $logicalPath : $name, 'visibility_scope' => (string) ($data['visibility_scope'] ?? 'interno'), 'visibility_roles' => $this->normalizeStringArray($data['visibility_roles'] ?? []), 'visibility_groups' => $this->normalizeStringArray($data['visibility_groups'] ?? []), 'allowed_user_ids' => $this->normalizeIntArray($data['allowed_user_ids'] ?? []), 'note' => trim((string) ($data['note'] ?? '')) ?: null, 'created_by' => (int) $user->id, 'updated_by' => (int) $user->id, ]); Notification::make()->title('Cartella archivio creata')->success()->send(); } private function createDocumentoFromForm(array $data, bool $isDemo): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $stabileId = (int) ($data['stabile_id'] ?? 0); if ($stabileId <= 0) { Notification::make()->title('Stabile obbligatorio')->danger()->send(); return; } $categoria = (string) ($data['categoria'] ?? 'altro'); $titolo = (string) ($data['titolo'] ?? ''); if (trim($titolo) === '') { Notification::make()->title('Titolo obbligatorio')->danger()->send(); return; } $dataDocumento = $data['data_documento'] ?? null; $year = null; if ($dataDocumento instanceof \DateTimeInterface) { $year = (int) $dataDocumento->format('Y'); } elseif (is_string($dataDocumento) && trim($dataDocumento) !== '') { $raw = trim($dataDocumento); foreach (['Y-m-d', 'd-m-Y', 'd/m/Y', 'Y/m/d'] as $fmt) { try { $dt = Carbon::createFromFormat($fmt, $raw); if ($dt) { $year = (int) $dt->format('Y'); break; } } catch (\Throwable) { // try next } } if ($year === null) { try { $year = (int) Carbon::parse($raw)->format('Y'); } catch (\Throwable) { $year = null; } } } if (! is_int($year) || $year < 2000 || $year > 2100) { $year = (int) now()->format('Y'); } $protocollo = $this->generateProtocollo($categoria, $year); $storedPath = (string) ($data['pdf'] ?? ''); if ($storedPath === '') { Notification::make()->title('PDF obbligatorio')->danger()->send(); return; } $resolvedFolderId = $this->resolveDefaultFolderIdForDocument($stabileId, $categoria, $year, $data['cartella_archivio_id'] ?? null); // Rinomina il file su storage in modo ordinato $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; $finalName = $this->buildDocumentStorageFilename($categoria, $dataDocumento, $protocollo, (string) ($data['fornitore'] ?? ''), $titolo); $finalPath = $finalDir . '/' . $finalName; try { if (Storage::disk('local')->exists($storedPath)) { $bytes = Storage::disk('local')->get($storedPath); Storage::disk('local')->put($finalPath, $bytes); Storage::disk('local')->delete($storedPath); } } catch (\Throwable) { // best-effort } $size = null; try { if (Storage::disk('local')->exists($finalPath)) { $size = Storage::disk('local')->size($finalPath); } } catch (\Throwable) { // ignore } $note = ''; $faldone = trim((string) ($data['faldone'] ?? '')); if ($faldone !== '') { $note = 'Faldone: ' . $faldone; } $tipologia = $this->mapCategoriaToTipologia($categoria); $archivioClasse = trim((string) ($data['archivio_classe'] ?? '')); $archiveReference = $this->buildArchiveReference($stabileId, $categoria, $year, $data); $canaleAcquisizione = trim((string) ($data['canale_acquisizione'] ?? '')); $scannerProfile = trim((string) ($data['scanner_profile'] ?? '')); $riferimentoAcquisizione = trim((string) ($data['riferimento_acquisizione'] ?? '')); $scannerNote = trim((string) ($data['scanner_note'] ?? '')); $origineDocumento = trim((string) ($data['origine_documento'] ?? '')); $documentQrProfiles = ArchiveQrXmlPayload::buildProfiles([ 'id' => $archiveReference, 'legacy' => 'DOC:' . $archiveReference, 'type' => 'ATTO', 'stabile_id' => (string) $stabileId, 'parent' => trim((string) ($data['supporto_fisico'] ?? '')) !== '' ? strtoupper(Str::slug((string) $data['supporto_fisico'], '-')) : 'RADICE', 'code' => $archiveReference, 'title' => $titolo, 'document_code' => $protocollo, 'archive_class' => strtoupper($archivioClasse !== '' ? $archivioClasse : $categoria), 'location' => trim((string) ($data['supporto_fisico'] ?? '')), ]); if ($scannerNote !== '') { $note = trim($note . ($note !== '' ? "\n" : '') . 'Scanner: ' . $scannerNote); } $doc = Documento::query()->create([ 'documentable_type' => null, 'documentable_id' => null, 'stabile_id' => $stabileId, 'cartella_archivio_id' => $resolvedFolderId, 'utente_id' => (int) $user->id, 'nome' => $titolo, 'descrizione' => (string) ($data['descrizione'] ?? ''), 'fornitore' => (string) ($data['fornitore'] ?? ''), 'tipologia' => $tipologia, 'tipo_documento' => $categoria, 'data_documento' => $dataDocumento, 'data_scadenza' => $data['data_scadenza'] ?? null, 'importo_collegato' => $data['importo'] ?? null, 'movimento_contabile_id' => $data['movimento_contabile_id'] ?? null, 'numero_protocollo' => $protocollo, 'archive_reference' => $archiveReference, 'nome_file' => $finalName, 'mime_type' => 'application/pdf', 'path_file' => $finalPath, 'percorso_file' => $finalPath, 'estensione' => 'pdf', 'dimensione_file' => $size, 'metadati_archivio' => [ 'classe' => $archivioClasse !== '' ? $archivioClasse : null, 'classe_label' => $this->getArchivioClasseOptions()[$archivioClasse] ?? null, 'supporto_fisico' => $this->normalizeNullableText($data['supporto_fisico'] ?? null), 'modello_etichetta' => $this->normalizeNullableText($data['modello_etichetta'] ?? null), 'busta_numero' => $this->normalizeNullableText($data['busta_numero'] ?? null), 'fascicolo_numero' => $this->normalizeNullableText($data['fascicolo_numero'] ?? null), 'contenitore_numero' => $this->normalizeNullableText($data['contenitore_numero'] ?? null), 'periodo_da' => $data['periodo_riferimento_da'] ?? null, 'periodo_a' => $data['periodo_riferimento_a'] ?? null, 'codice_etichetta' => $archiveReference, 'codice_univoco_documento' => $archiveReference, 'canale_acquisizione' => $canaleAcquisizione !== '' ? $canaleAcquisizione : null, 'scanner_profile' => $scannerProfile !== '' ? $scannerProfile : null, 'riferimento_acquisizione' => $riferimentoAcquisizione !== '' ? $riferimentoAcquisizione : null, 'origine_documento' => $origineDocumento !== '' ? $origineDocumento : null, 'qr_ready' => true, 'nfc_ready' => true, 'qr_payload_xml' => (string) ($documentQrProfiles['xml_open'] ?? ''), 'qr_payload_zip' => (string) ($documentQrProfiles['xml_zip'] ?? ''), 'nfc_payload_xml' => (string) ($documentQrProfiles['xml_nfc'] ?? ''), ], 'visibility_scope' => (string) ($data['visibility_scope'] ?? 'interno'), 'visibility_roles' => $this->normalizeStringArray($data['visibility_roles'] ?? []), 'visibility_groups' => $this->normalizeStringArray($data['visibility_groups'] ?? []), 'allowed_user_ids' => $this->normalizeIntArray($data['allowed_user_ids'] ?? []), 'data_upload' => now(), 'note' => $note, 'is_demo' => $isDemo, ]); // Estrai testo per ricerca (best-effort) try { app(PdfTextExtractionService::class)->extractAndStore($doc, false); } catch (\Throwable) { // ignore } Notification::make() ->title('Documento registrato') ->body('Protocollo: ' . $protocollo . ' · Archivio: ' . $archiveReference) ->success() ->send(); } private function createDocumentoDemoContrattoAscensori(int $stabileId, string $faldone): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $year = 2024; $protocollo = $this->generateProtocollo('contratto', $year); $titolo = 'Contratto Manutenzione Ascensori 2024'; $descrizione = 'Contratto annuale per manutenzione impianti ascensore con Acme Elevators Srl.'; $html = '

' . e($titolo) . '

'; $html .= '

Protocollo: ' . e($protocollo) . '

'; $html .= '

' . e($descrizione) . '

'; $html .= '
'; $html .= '

Fornitore: Acme Elevators Srl

'; $html .= '

Dal: 01/01/2024 Al: 31/12/2024

'; $html .= '

Totale annuo: € 2.400,00

'; $html .= '

Modalità pagamento: Trimestrale

'; $html .= '

Data stipula: 15/12/2023

'; $dompdf = new Dompdf(); $dompdf->loadHtml('' . $html . ''); $dompdf->setPaper('A4'); $dompdf->render(); $pdfBytes = $dompdf->output(); $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; $finalName = $protocollo . '_contratto_ascensori_2024.pdf'; $finalPath = $finalDir . '/' . $finalName; Storage::disk('local')->put($finalPath, $pdfBytes); $size = null; try { $size = Storage::disk('local')->size($finalPath); } catch (\Throwable) { // ignore } $note = ''; $faldone = trim($faldone); if ($faldone !== '') { $note = 'Faldone: ' . $faldone; } else { $note = 'Faldone: CONTR-2024'; } $doc = Documento::query()->create([ 'stabile_id' => $stabileId, 'utente_id' => (int) $user->id, 'nome' => $titolo, 'descrizione' => $descrizione, 'fornitore' => 'Acme Elevators Srl', 'tipologia' => 'contratto', 'tipo_documento' => 'contratto', 'data_documento' => '2023-12-15', 'data_scadenza' => '2024-12-31', 'importo_collegato' => 2400.00, 'numero_protocollo' => $protocollo, 'nome_file' => $finalName, 'mime_type' => 'application/pdf', 'path_file' => $finalPath, 'percorso_file' => $finalPath, 'estensione' => 'pdf', 'dimensione_file' => $size, 'visibility_scope' => 'interno', 'data_upload' => now(), 'note' => $note, 'is_demo' => true, ]); try { app(PdfTextExtractionService::class)->extractAndStore($doc, true); } catch (\Throwable) { // ignore } Notification::make() ->title('Demo creato') ->body('Protocollo: ' . $protocollo) ->success() ->send(); } private function generateProtocollo(string $categoria, int $year): string { $categoria = strtolower(trim($categoria)); $prefissi = [ 'contratto' => 'CONTR', 'assicurazione' => 'ASSIC', 'certificato' => 'CERT', 'planimetria' => 'PLAN', 'fascicolo' => 'FASC', 'anagrafica' => 'ANAG', 'tabella' => 'TABM', 'regolamento' => 'REGO', 'scritture' => 'CONT', 'chiavi' => 'CHIA', 'timbri' => 'TIMB', 'preventivo' => 'PREV', // Importante: i protocolli fattura devono essere per-anno. // Usiamo FT come prefisso compatto e compatibile con l'archiviazione. 'fattura' => 'FT', 'verbale' => 'VERB', 'altro' => 'DOC', ]; $pref = $prefissi[$categoria] ?? 'DOC'; $ultimo = Documento::query() ->whereNotNull('numero_protocollo') ->where(function ($q) use ($pref, $year) { $q->where('numero_protocollo', 'like', $pref . '-' . $year . '-%'); // Compatibilità: alcuni documenti legacy potrebbero avere prefisso FATT. if ($pref === 'FT') { $q->orWhere('numero_protocollo', 'like', 'FATT-' . $year . '-%'); } }) ->orderBy('numero_protocollo', 'desc') ->first(['numero_protocollo']); $progressivo = 1; if ($ultimo && is_string($ultimo->numero_protocollo)) { $parts = explode('-', $ultimo->numero_protocollo); $last = (int) (end($parts) ?: 0); if ($last > 0) { $progressivo = $last + 1; } } return sprintf('%s-%d-%03d', $pref, $year, $progressivo); } private function buildArchiveReference(int $stabileId, string $categoria, int $year, array $data): string { $stabile = Stabile::query()->find($stabileId); $mnemonic = $this->mnemonicCodeHint; if (! $mnemonic && $stabile instanceof Stabile) { $label = Str::upper((string) ($stabile->denominazione ?? 'STABILE')); $letters = preg_replace('/[^A-Z]/', '', Str::ascii($label)) ?: 'STB'; $digits = preg_replace('/\D/', '', $label); $mnemonic = str_pad(substr($letters, 0, 3), 3, 'X') . str_pad(substr($digits !== '' ? $digits : (string) $stabile->id, -5), 5, '0', STR_PAD_LEFT); } $contenitore = $this->normalizeNullableText($data['contenitore_numero'] ?? $data['faldone'] ?? null) ?: '00'; $fascicolo = $this->normalizeNullableText($data['fascicolo_numero'] ?? null) ?: '00/' . $year; return implode(' · ', array_filter([ $mnemonic ?: 'STB00000', strtoupper($categoria), (string) $year, 'BUSTA ' . $contenitore, 'FASC ' . $fascicolo, ])); } private function buildDocumentStorageFilename(string $categoria, mixed $dataDocumento, string $protocollo, string $fornitore, string $titolo): string { $safeDate = '00000000'; try { if ($dataDocumento instanceof \DateTimeInterface) { $safeDate = $dataDocumento->format('Ymd'); } elseif (is_string($dataDocumento) && trim($dataDocumento) !== '') { $safeDate = Carbon::parse(trim($dataDocumento))->format('Ymd'); } } catch (\Throwable) { $safeDate = now()->format('Ymd'); } $sourceLabel = $categoria === 'fattura' ? ($fornitore !== '' ? $fornitore : $titolo) : $titolo; $safeLabel = trim((string) Str::of($sourceLabel)->ascii()->replaceMatches('/[^A-Za-z0-9]+/', ' ')->squish()); $safeLabel = str_replace(' ', '_', Str::limit($safeLabel !== '' ? $safeLabel : 'documento', 60, '')); $safeProtocollo = str_replace(['/', ' '], '-', trim($protocollo)); if ($categoria === 'fattura') { return $safeDate . '_' . $safeProtocollo . '_' . Str::lower($safeLabel) . '.pdf'; } return $safeProtocollo . '_' . Str::lower($safeLabel) . '.pdf'; } private function hasPdf(Documento $doc): bool { $p = (string) ($doc->percorso_file ?: ($doc->path_file ?: '')); return $p !== '' && str_ends_with(strtolower($p), '.pdf'); } private function hasStoredFile(Documento $doc): bool { foreach (array_filter([(string) ($doc->percorso_file ?? ''), (string) ($doc->path_file ?? '')]) as $candidate) { if (Storage::disk('local')->exists($candidate) || Storage::disk('public')->exists($candidate)) { return true; } } return false; } /** @return array{has_file:bool,is_pdf:bool,can_print:bool,extension:string,kind_label:string,badge_label:string} */ private function resolveDocumentoMediaMeta(Documento $documento): array { $filename = trim((string) ($documento->nome_file ?: basename((string) ($documento->percorso_file ?: $documento->path_file ?: '')))); $extension = strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)); $hasFile = $this->hasStoredFile($documento); $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; $textExtensions = ['txt', 'csv', 'md', 'json', 'xml', 'html']; $officeExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods']; $isPdf = $this->hasPdf($documento) || $extension === 'pdf'; $isImage = in_array($extension, $imageExtensions, true); $isText = in_array($extension, $textExtensions, true); $isOffice = in_array($extension, $officeExtensions, true); $kindLabel = match (true) { $isPdf => 'PDF', $isImage => 'Immagine', $isOffice => 'Office', $isText => 'Testo', $extension !== '' => strtoupper($extension), default => 'File', }; return [ 'has_file' => $hasFile, 'is_pdf' => $isPdf, 'can_print' => $hasFile && ($isPdf || $isImage || $isText), 'extension' => $extension !== '' ? strtoupper($extension) : 'FILE', 'kind_label' => $kindLabel, 'badge_label' => $kindLabel . ($extension !== '' && strtoupper($extension) !== $kindLabel ? ' · ' . strtoupper($extension) : ''), ]; } private function mapCategoriaToTipologia(string $categoria): string { $categoria = strtolower(trim($categoria)); // `documenti.tipologia` è un enum: manteniamoci sui valori previsti. return match ($categoria) { 'contratto' => 'contratto', 'certificato' => 'certificato', 'preventivo' => 'preventivo', 'fattura' => 'fattura', 'verbale' => 'verbale', default => 'altro', }; } private function resolveFolderIdForDocument(int $stabileId, mixed $folderId): ?int { if (! is_numeric($folderId)) { return null; } $folder = DocumentoArchivioCartella::query() ->where('stabile_id', $stabileId) ->whereKey((int) $folderId) ->first(['id']); return $folder ? (int) $folder->id : null; } private function resolveDefaultFolderIdForDocument(int $stabileId, string $categoria, int $year, mixed $folderId): ?int { $resolvedFolderId = $this->resolveFolderIdForDocument($stabileId, $folderId); if ($resolvedFolderId) { return $resolvedFolderId; } if ($categoria !== 'fattura') { return null; } $root = $this->ensureInvoiceRootFolder($stabileId); if (! $root instanceof DocumentoArchivioCartella) { return null; } return $this->ensureInvoiceYearFolder($stabileId, $root, $year)?->id; } private function normalizeStringArray(mixed $values): array { if (! is_array($values)) { return []; } return collect($values) ->map(fn($value): string => trim((string) $value)) ->filter(fn(string $value): bool => $value !== '') ->unique() ->values() ->all(); } private function normalizeIntArray(mixed $values): array { if (! is_array($values)) { return []; } return collect($values) ->filter(fn($value): bool => is_numeric($value) && (int) $value > 0) ->map(fn($value): int => (int) $value) ->unique() ->values() ->all(); } private function normalizeNullableText(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } }