From 8bc7783ce3af4ecce9b01acc051a6f39ab50980d Mon Sep 17 00:00:00 2001 From: michele Date: Wed, 22 Apr 2026 15:45:25 +0000 Subject: [PATCH] feat: add protocol dashboard and local archive labels --- README.md | 31 +- .../Pages/Strumenti/DocumentiArchivio.php | 1032 ++++++++++++++++- .../Filament/DocumentiPrintController.php | 194 +++- app/Models/Documento.php | 2 + app/Models/DocumentoArchivioFisicoItem.php | 184 +++ ...d_metadati_archivio_to_documenti_table.php | 30 + ..._documento_archivio_fisico_items_table.php | 52 + docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md | 81 +- resources/css/filament/admin/theme.css | 20 +- .../components/topbar-context.blade.php | 117 +- .../strumenti/archivio-fisico-show.blade.php | 112 ++ .../strumenti/documenti-archivio.blade.php | 454 +++++++- .../print/etichetta-archivio-fisico.blade.php | 104 ++ routes/web.php | 5 + 14 files changed, 2245 insertions(+), 173 deletions(-) create mode 100644 app/Models/DocumentoArchivioFisicoItem.php create mode 100644 database/migrations/2026_04_22_140200_add_metadati_archivio_to_documenti_table.php create mode 100644 database/migrations/2026_04_22_170000_create_documento_archivio_fisico_items_table.php create mode 100644 resources/views/filament/pages/strumenti/archivio-fisico-show.blade.php create mode 100644 resources/views/filament/print/etichetta-archivio-fisico.blade.php diff --git a/README.md b/README.md index 719d676..79527b9 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,27 @@ # NetGesCon (Laravel + Filament) ## Panoramica + Gestione condominiale con moduli contabili, anagrafiche e archivi legacy. Stack principale: Laravel + Filament + Livewire. ## Requisiti + - PHP 8.3+ - Composer - Node.js + npm - MySQL/MariaDB ## Avvio rapido (sviluppo) -1) Crea il file `.env` da `.env.example` e configura DB/APP_URL -2) Installa dipendenze - - `composer install` - - `npm install` -3) Genera la chiave app - - `php artisan key:generate` -4) Migra il DB - - `php artisan migrate` -5) Asset Filament - - `php artisan filament:assets` -6) Avvio - - `php artisan serve` + +1. Crea il file `.env` da `.env.example` e configura DB/APP_URL. +2. Installa le dipendenze: `composer install` e `npm install`. +3. Genera la chiave applicativa: `php artisan key:generate`. +4. Migra il database: `php artisan migrate`. +5. Pubblica gli asset Filament: `php artisan filament:assets`. +6. Avvia l'applicazione: `php artisan serve`. ## Distribuzione Open Source (linee guida) + - Aggiungi una licenza: `LICENSE` (MIT, Apache-2.0, AGPL, ecc.) - Compila la documentazione essenziale: - `README.md` (setup, requisiti, run) @@ -35,7 +33,16 @@ ## Distribuzione Open Source (linee guida) - Versiona con tag semantici: `vX.Y.Z` ## Struttura documentazione + - `docs/` contiene i flussi, import, troubleshooting e procedure operative. +- Documento archivio e acquisizione: `docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md` + +## Stato operativo recente + +- Strumenti > Documenti apre ora una dashboard `Protocollo comunicazioni` con ricerca, filtri e anteprima PDF. +- Le etichette archivio fisico generano il QR in locale e supportano i formati DYMO `11354` e `99014`. +- La topbar contestuale Filament usa layout a capo, senza scrollbar orizzontale. ## Contribuire + Vedi `CONTRIBUTING.md` (da aggiungere) per le regole di collaborazione. diff --git a/app/Filament/Pages/Strumenti/DocumentiArchivio.php b/app/Filament/Pages/Strumenti/DocumentiArchivio.php index 88aadac..cf9cc02 100644 --- a/app/Filament/Pages/Strumenti/DocumentiArchivio.php +++ b/app/Filament/Pages/Strumenti/DocumentiArchivio.php @@ -3,6 +3,7 @@ use App\Models\Documento; use App\Models\DocumentoArchivioCartella; +use App\Models\DocumentoArchivioFisicoItem; use App\Models\MovimentoContabile; use App\Models\Stabile; use App\Models\User; @@ -18,6 +19,7 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Tables\Columns\BadgeColumn; @@ -39,7 +41,24 @@ class DocumentiArchivio extends Page implements HasTable use InteractsWithTable; #[Url( as : 'tab')] - public string $hubTab = 'archivio-digitale'; + public string $hubTab = 'protocollo-comunicazioni'; + + public string $protocolloSearch = ''; + + public string $protocolloCategory = ''; + + public string $protocolloView = 'cards'; + + #[Url(as: 'folder')] + public ?int $currentFolderId = null; + + public array $physicalArchiveForm = []; + + public array $physicalArchiveMoveForm = []; + + public string $physicalArchiveSearch = ''; + + public string $physicalArchiveTypeFilter = ''; protected static ?string $navigationLabel = 'Documenti (PDF)'; @@ -67,12 +86,23 @@ public static function canAccess(): bool public function mount(): void { $this->hubTab = $this->normalizeHubTab($this->hubTab); + $this->syncInvoiceFolders(); + $this->initializePhysicalArchiveForms(); $this->mountInteractsWithTable(); } public function selectHubTab(string $tab): void { $this->hubTab = $this->normalizeHubTab($tab); + + if ($this->hubTab === 'archivio-digitale') { + $this->syncInvoiceFolders(); + } + } + + public function setProtocolloView(string $view): void + { + $this->protocolloView = in_array($view, ['cards', 'list'], true) ? $view : 'cards'; } protected function getHeaderActions(): array @@ -123,8 +153,24 @@ protected function getTableQuery(): Builder return Documento::query()->whereRaw('1 = 0'); } - return $this->getVisibilityService() + $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 @@ -137,14 +183,21 @@ public function table(Table $table): Table ->label('Protocollo') ->copyable() ->searchable(), - TextColumn::make('nome') - ->label('Titolo') + TextColumn::make('nome_file') + ->label('Nome archivio') + ->state(fn(Documento $record): string => trim((string) ($record->nome_file ?: $record->nome ?: '—'))) ->searchable() - ->limit(50), + ->limit(65) + ->wrap(), BadgeColumn::make('tipo_documento') ->label('Categoria') ->toggleable() ->formatStateUsing(fn($state, Documento $record) => $state ?: ($record->tipologia ?: '—')), + TextColumn::make('archive_reference') + ->label('Percorso archivio') + ->limit(55) + ->wrap() + ->toggleable(), TextColumn::make('descrizione') ->label('Descrizione') ->limit(50) @@ -159,6 +212,18 @@ public function table(Table $table): Table ->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) @@ -188,8 +253,9 @@ public function table(Table $table): Table ]) ->actions([ Action::make('visualizza_pdf') - ->label('Vedi 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') @@ -205,21 +271,93 @@ public function table(Table $table): Table ->modalSubmitAction(false) ->modalCancelActionLabel('Chiudi'), Action::make('scarica_pdf') - ->label('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); + $record->metadati_archivio = $meta; + $record->save(); + + Notification::make() + ->title('Archivio fisico aggiornato') + ->success() + ->send(); + }), Action::make('etichetta_11354') - ->label('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') - ->label('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') - ->label('Etichetta 99014') + ->hiddenLabel() ->icon('heroicon-o-tag') + ->tooltip('Etichetta 99014') ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=99014&autoprint=1', false), ]); } @@ -230,6 +368,14 @@ private function getCategorieOptions(): array '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)', @@ -249,6 +395,108 @@ public function getHubStatsProperty(): array ]; } + 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 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 { @@ -285,7 +533,314 @@ public function getAudienceGroupsProperty(): array /** @return array */ public function getArchivioFisicoTypesProperty(): array { - return ['Faldone', 'Scatola', 'Cartella', 'Busta']; + 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['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 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; + } + + $source->forceFill([ + 'parent_id' => (int) $target->id, + 'updated_by' => Auth::id(), + ])->save(); + + $this->physicalArchiveMoveForm = [ + 'source_code' => '', + 'target_code' => '', + ]; + + Notification::make() + ->title('Movimentazione registrata') + ->body($source->codice_univoco . ' → ' . $target->codice_univoco) + ->success() + ->send(); } public function getVisibleFolderRowsProperty(): array @@ -303,23 +858,23 @@ public function getVisibleFolderRowsProperty(): array ->orderBy('nome') ->get() ->map(function (DocumentoArchivioCartella $folder): array { - $roles = collect($folder->visibility_roles ?? [])->map(fn($value): string => (string) $value)->filter()->values()->all(); + $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(); + $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, + '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(), ]; }) @@ -342,6 +897,81 @@ public function getMnemonicCodeHintProperty(): ?string return $prefix . $suffix; } + public function getProtocolloComunicazioniStatsProperty(): array + { + $query = $this->getProtocolloComunicazioniQuery(); + + return [ + 'totale' => (clone $query)->count(), + 'con_pdf' => (clone $query) + ->where(function (Builder $builder): void { + $builder->whereNotNull('percorso_file') + ->orWhereNotNull('path_file'); + }) + ->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 ?? '')); + + 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_reference' => trim((string) ($documento->archive_reference ?? '')), + 'has_pdf' => $this->hasPdf($documento), + 'preview_url' => $openUrl, + 'download_url' => route('filament.documenti.download', $documento), + 'print_url' => $openUrl, + ]; + }) + ->all(); + } + private function getDocumentoFormSchema(): array { return [ @@ -354,13 +984,18 @@ private function getDocumentoFormSchema(): array ->required(), Select::make('cartella_archivio_id') ->label('Cartella archivio') - ->options(fn(callable $get): array => $this->getArchivioFolderOptions((int) ($get('stabile_id') ?: 0))) + ->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() @@ -394,6 +1029,31 @@ private function getDocumentoFormSchema(): array ->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()) @@ -429,14 +1089,14 @@ private function getArchivioFolderFormSchema(): array return [ Select::make('stabile_id') ->label('Stabile') - ->options(fn(): array => $this->getStabiliOptions()) + ->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))) + ->options(fn(callable $get): array=> $this->getArchivioFolderOptions((int) ($get('stabile_id') ?: 0))) ->searchable() ->nullable(), TextInput::make('nome') @@ -497,6 +1157,31 @@ private function getStabiliOptions(): array ->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' => '', + ]; + } + private function getDefaultStabileId(): ?int { $user = Auth::user(); @@ -520,8 +1205,8 @@ private function getVisibilityService(): DocumentoArchivioVisibilityService private function getVisibilityScopeOptions(): array { return [ - 'interno' => 'Interno studio', - 'studio' => 'Studio completo', + 'interno' => 'Interno studio', + 'studio' => 'Studio completo', 'restricted' => 'Limitato a ruoli / utenti', ]; } @@ -529,10 +1214,10 @@ private function getVisibilityScopeOptions(): array private function getVisibilityRoleOptions(): array { return [ - 'super-admin' => 'Super admin', - 'admin' => 'Admin', + 'super-admin' => 'Super admin', + 'admin' => 'Admin', 'amministratore' => 'Amministratore', - 'collaboratore' => 'Collaboratore', + 'collaboratore' => 'Collaboratore', ]; } @@ -574,11 +1259,158 @@ private function getArchivioFolderOptions(int $stabileId): array ->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 = ['archivio-digitale', 'template-drive', 'archivio-fisico', 'permessi']; + $allowed = ['protocollo-comunicazioni', 'archivio-digitale', 'template-drive', 'archivio-fisico', 'permessi']; - return in_array($tab, $allowed, true) ? $tab : 'archivio-digitale'; + return in_array($tab, $allowed, true) ? $tab : 'protocollo-comunicazioni'; + } + + 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 @@ -649,7 +1481,7 @@ private function createArchivioFolder(array $data): void } $parentId = isset($data['parent_id']) && is_numeric($data['parent_id']) ? (int) $data['parent_id'] : null; - $parent = $parentId + $parent = $parentId ? DocumentoArchivioCartella::query()->where('stabile_id', $stabileId)->find($parentId) : null; @@ -664,19 +1496,19 @@ private function createArchivioFolder(array $data): void ])->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'] ?? []), + '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, + '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(); @@ -740,10 +1572,11 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void return; } + $resolvedFolderId = $this->resolveDefaultFolderIdForDocument($stabileId, $categoria, $year, $data['cartella_archivio_id'] ?? null); + // Rinomina il file su storage in modo ordinato - $safeTitle = Str::slug($titolo); $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; - $finalName = $protocollo . '_' . ($safeTitle !== '' ? $safeTitle : 'documento') . '.pdf'; + $finalName = $this->buildDocumentStorageFilename($categoria, $dataDocumento, $protocollo, (string) ($data['fornitore'] ?? ''), $titolo); $finalPath = $finalDir . '/' . $finalName; try { @@ -772,12 +1605,14 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void } $tipologia = $this->mapCategoriaToTipologia($categoria); + $archivioClasse = trim((string) ($data['archivio_classe'] ?? '')); + $archiveReference = $this->buildArchiveReference($stabileId, $categoria, $year, $data); $doc = Documento::query()->create([ 'documentable_type' => null, 'documentable_id' => null, 'stabile_id' => $stabileId, - 'cartella_archivio_id' => $this->resolveFolderIdForDocument($stabileId, $data['cartella_archivio_id'] ?? null), + 'cartella_archivio_id' => $resolvedFolderId, 'utente_id' => (int) $user->id, 'nome' => $titolo, 'descrizione' => (string) ($data['descrizione'] ?? ''), @@ -789,12 +1624,27 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void '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, + 'qr_ready' => true, + 'nfc_ready' => true, + ], 'visibility_scope' => (string) ($data['visibility_scope'] ?? 'interno'), 'visibility_roles' => $this->normalizeStringArray($data['visibility_roles'] ?? []), 'visibility_groups' => $this->normalizeStringArray($data['visibility_groups'] ?? []), @@ -813,7 +1663,7 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void Notification::make() ->title('Documento registrato') - ->body('Protocollo: ' . $protocollo) + ->body('Protocollo: ' . $protocollo . ' · Archivio: ' . $archiveReference) ->success() ->send(); } @@ -913,6 +1763,14 @@ private function generateProtocollo(string $categoria, int $year): string '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. @@ -947,6 +1805,58 @@ private function generateProtocollo(string $categoria, int $year): string 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 ?: '')); @@ -982,6 +1892,25 @@ private function resolveFolderIdForDocument(int $stabileId, mixed $folderId): ?i 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)) { @@ -1009,4 +1938,11 @@ private function normalizeIntArray(mixed $values): array ->values() ->all(); } + + private function normalizeNullableText(mixed $value): ?string + { + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } } diff --git a/app/Http/Controllers/Filament/DocumentiPrintController.php b/app/Http/Controllers/Filament/DocumentiPrintController.php index a939fb7..ce50381 100644 --- a/app/Http/Controllers/Filament/DocumentiPrintController.php +++ b/app/Http/Controllers/Filament/DocumentiPrintController.php @@ -1,16 +1,18 @@ path($selectedPath); + + if ($request->boolean('watermark')) { + $watermarkedPath = $this->buildWatermarkedPdf($absolutePath, $documento); + if (is_string($watermarkedPath) && is_file($watermarkedPath)) { + $response = response()->download($watermarkedPath, $filename); + if ($request->boolean('inline')) { + $response->setContentDisposition('inline', $filename); + } + + return $response->deleteFileAfterSend(true); + } + } + if ($request->boolean('inline')) { - return response()->file(Storage::disk($selectedDisk)->path($selectedPath), [ + return response()->file($absolutePath, [ 'Content-Disposition' => 'inline; filename="' . $filename . '"', ]); } @@ -115,10 +131,10 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi $faldone = $this->extractFaldoneFromNote((string) ($documento->note ?? '')); } - $anno = $documento->data_documento?->format('Y') ?: now()->format('Y'); + $anno = $documento->data_documento?->format('Y') ?: now()->format('Y'); $protocollo = (string) ($documento->numero_protocollo ?: ('DOC-' . $anno . '-' . str_pad((string) $documento->id, 3, '0', STR_PAD_LEFT))); - $stabileId = (int) ($documento->stabile_id ?: 0); + $stabileId = (int) ($documento->stabile_id ?: 0); $codiceUnico = $stabileId > 0 ? ('STB-' . $stabileId . '-' . $protocollo) : $protocollo; if ($faldone !== '') { $codiceUnico .= '-F' . Str::upper($faldone); @@ -127,17 +143,71 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi $openUrl = route('filament.documenti.download', $documento); return view('filament.print.etichetta-documento', [ - 'documento' => $documento, + 'documento' => $documento, + 'format' => $format, + 'dymoFix' => $dymoFix, + 'dymoRot' => $dymoRot, + 'dymoDx' => $dymoDx, + 'dymoDy' => $dymoDy, + 'faldone' => $faldone, + 'codiceUnico' => $codiceUnico, + 'openUrl' => $openUrl, + 'anno' => $anno, + 'protocollo' => $protocollo, + ]); + } + + public function etichettaArchivioFisico(Request $request, DocumentoArchivioFisicoItem $item) + { + $user = Auth::user(); + if (! $user instanceof User || ! $this->canAccessPhysicalArchiveItem($user, $item)) { + abort(403); + } + + $format = $request->query('format', '11354'); + $format = in_array($format, ['11354', '99014'], true) ? $format : '11354'; + $defaults = $this->getDymoDefaultsForUser($format); + + $dymoFix = $request->query('dymo_fix'); + if ($dymoFix === null) { + $dymoFix = (bool) ($defaults['fix'] ?? false); + } else { + $dymoFix = filter_var($dymoFix, FILTER_VALIDATE_BOOL); + } + + $dymoRot = $request->has('dymo_rot') + ? (int) $request->query('dymo_rot') + : (int) ($defaults['rot'] ?? 0); + $dymoDx = $request->has('dymo_dx') ? (float) $request->query('dymo_dx') : (float) ($defaults['dx'] ?? 0); + $dymoDy = $request->has('dymo_dy') ? (float) $request->query('dymo_dy') : (float) ($defaults['dy'] ?? 0); + + return view('filament.print.etichetta-archivio-fisico', [ + 'item' => $item->load(['parent', 'children', 'stabile', 'documento']), 'format' => $format, 'dymoFix' => $dymoFix, 'dymoRot' => $dymoRot, 'dymoDx' => $dymoDx, 'dymoDy' => $dymoDy, - 'faldone' => $faldone, - 'codiceUnico' => $codiceUnico, - 'openUrl' => $openUrl, - 'anno' => $anno, - 'protocollo' => $protocollo, + 'publicUrl' => $item->qr_code_data, + ]); + } + + public function showPublicArchivioFisicoItem(Request $request, DocumentoArchivioFisicoItem $item) + { + if (! $request->hasValidSignature()) { + abort(403); + } + + $chain = collect(); + $current = $item; + while ($current instanceof DocumentoArchivioFisicoItem) { + $chain->prepend($current); + $current = $current->parent; + } + + return view('filament.pages.strumenti.archivio-fisico-show', [ + 'item' => $item->load(['children.children', 'stabile', 'documento', 'parent']), + 'chain' => $chain, ]); } @@ -162,24 +232,24 @@ private function getDymoDefaultsForUser(string $format): array return [ 'fix' => false, 'rot' => 0, - 'dx' => 0.0, - 'dy' => 3.0, + 'dx' => 0.0, + 'dy' => 3.0, ]; } return [ 'fix' => false, 'rot' => 0, - 'dx' => 0.0, - 'dy' => 0.0, + 'dx' => 0.0, + 'dy' => 0.0, ]; } return [ 'fix' => (bool) ($cfg['fix'] ?? false), 'rot' => isset($cfg['rot']) ? (int) $cfg['rot'] : null, - 'dx' => isset($cfg['dx']) ? (float) $cfg['dx'] : 0, - 'dy' => isset($cfg['dy']) ? (float) $cfg['dy'] : 0, + 'dx' => isset($cfg['dx']) ? (float) $cfg['dx'] : 0, + 'dy' => isset($cfg['dy']) ? (float) $cfg['dy'] : 0, ]; } @@ -197,4 +267,96 @@ private function extractFaldoneFromNote(string $note): string return ''; } + + private function canAccessPhysicalArchiveItem(User $user, DocumentoArchivioFisicoItem $item): bool + { + if ($user->hasAnyRole(['super-admin', 'admin'])) { + return true; + } + + return StabileContext::accessibleStabili($user) + ->pluck('id') + ->map(fn($value): int => (int) $value) + ->contains((int) $item->stabile_id); + } + + private function buildWatermarkedPdf(string $inputPath, Documento $documento): ?string + { + $ghostscript = trim((string) shell_exec('command -v gs 2>/dev/null')); + if ($ghostscript === '' || ! is_file($inputPath)) { + return null; + } + + $watermarkText = $this->buildWatermarkText($documento); + if ($watermarkText === '') { + return null; + } + + $outputPath = tempnam(sys_get_temp_dir(), 'doc-wm-'); + if (! is_string($outputPath) || $outputPath === '') { + return null; + } + + $pdfOutputPath = $outputPath . '.pdf'; + @unlink($outputPath); + + $postScript = '<< /BeginPage { gsave 0.90 setgray /Helvetica-Bold findfont 14 scalefont setfont 140 90 translate 38 rotate 0 0 moveto (' + . $this->escapePostScriptString($watermarkText) + . ') show grestore } >> setpagedevice'; + + $process = new Process([ + $ghostscript, + '-q', + '-dNOPAUSE', + '-dBATCH', + '-sDEVICE=pdfwrite', + '-dCompatibilityLevel=1.4', + '-o', + $pdfOutputPath, + '-c', + $postScript, + '-f', + $inputPath, + ]); + + $process->run(); + + if (! $process->isSuccessful() || ! is_file($pdfOutputPath)) { + @unlink($pdfOutputPath); + return null; + } + + return $pdfOutputPath; + } + + private function buildWatermarkText(Documento $documento): string + { + $archiveReference = trim((string) ($documento->archive_reference ?? '')); + $meta = is_array($documento->metadati_archivio ?? null) ? $documento->metadati_archivio : []; + + $physicalParts = array_filter([ + trim((string) ($meta['supporto_fisico'] ?? '')), + trim((string) ($meta['contenitore_numero'] ?? '')), + trim((string) ($meta['busta_numero'] ?? '')) !== '' ? 'B' . trim((string) $meta['busta_numero']) : null, + trim((string) ($meta['fascicolo_numero'] ?? '')) !== '' ? 'F' . trim((string) $meta['fascicolo_numero']) : null, + ]); + + $physicalReference = implode(' ', $physicalParts); + + return trim(implode(' | ', array_filter([ + $archiveReference !== '' ? 'DIG ' . $archiveReference : null, + $physicalReference !== '' ? 'FIS ' . $physicalReference : null, + ]))); + } + + private function escapePostScriptString(string $value): string + { + $ascii = (string) Str::of($value)->ascii()->replaceMatches('/\s+/', ' ')->trim(); + + return str_replace( + ['\\', '(', ')'], + ['\\\\', '\\(', '\\)'], + $ascii, + ); + } } diff --git a/app/Models/Documento.php b/app/Models/Documento.php index 0b34f6b..948c14e 100755 --- a/app/Models/Documento.php +++ b/app/Models/Documento.php @@ -58,6 +58,7 @@ class Documento extends Model 'estensione', 'contenuto_ocr', 'metadati_ocr', + 'metadati_archivio', 'visibility_scope', 'visibility_groups', 'visibility_roles', @@ -77,6 +78,7 @@ class Documento extends Model protected $casts = [ 'xml_data' => 'array', 'metadati_ocr' => 'array', + 'metadati_archivio' => 'array', 'visibility_groups' => 'array', 'visibility_roles' => 'array', 'allowed_user_ids' => 'array', diff --git a/app/Models/DocumentoArchivioFisicoItem.php b/app/Models/DocumentoArchivioFisicoItem.php new file mode 100644 index 0000000..6771b34 --- /dev/null +++ b/app/Models/DocumentoArchivioFisicoItem.php @@ -0,0 +1,184 @@ + 'date', + 'sort_order' => 'integer', + 'metadati' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public const TIPI = [ + 'documento' => 'Documento cartaceo', + 'busta_trasparente' => 'Busta trasparente con anelli da archiviare', + 'cartella_tre_lembi' => 'Cartella a tre lembi', + 'faldone_anelli' => 'Faldone con anelli', + 'faldone_lacci' => 'Faldone con lacci', + 'scatola' => 'Scatola', + ]; + + public function stabile(): BelongsTo + { + return $this->belongsTo(Stabile::class, 'stabile_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order')->orderBy('titolo'); + } + + public function documento(): BelongsTo + { + return $this->belongsTo(Documento::class, 'documento_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + public function getTipoLabelAttribute(): string + { + return self::TIPI[$this->tipo_item] ?? $this->tipo_item; + } + + public function getPercorsoFisicoAttribute(): string + { + $parts = []; + $current = $this; + + while ($current instanceof self) { + $parts[] = trim($current->codice_univoco . ' ' . $current->titolo); + $current = $current->parent; + } + + return implode(' / ', array_reverse(array_filter($parts))); + } + + public function getQrCodeImageAttribute(): string + { + $data = trim((string) $this->qr_code_data); + + if ($data !== '' && 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; + } + } + + return str_contains($svg, '