*/ public array $box = []; /** @var array> */ public array $anteprimaAde = []; /** @var array> */ public array $anteprimaFe = []; /** @var array> */ public array $anteprimaContabilita = []; /** @var array> */ public array $fattureAssociate = []; /** @var array> */ public array $raVersateRows = []; public string $tagsInput = ''; /** @var array */ public array $tagSuggestions = []; /** @var array> */ public array $dipendentiRows = []; public string $nuovoDipendenteNome = ''; public string $nuovoDipendenteCognome = ''; public string $nuovoDipendenteEmail = ''; public string $nuovoDipendenteTelefono = ''; public ?string $lastGeneratedPassword = null; public function cleanDisplayValue(?string $value, string $fallback = '-'): string { $v = trim((string) $value); if ($v === '') { return $fallback; } $upper = strtoupper($v); if (in_array($upper, ['NULL', 'N/D', 'ND', 'N.A.', '-', '--', '[]', '{}'], true)) { return $fallback; } return $v; } public function mount(int | string $record): void { $user = Auth::user(); if (! $user instanceof User) { abort(403); } if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { abort(403); } $this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record); // Tenant guard: impedisce accesso cross-amministratore via URL. if (! $user->hasAnyRole(['super-admin', 'admin'])) { $allowedAdminIds = []; $activeStabile = StabileContext::getActiveStabile($user); $activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0); if ($activeAdminId > 0) { $allowedAdminIds[] = $activeAdminId; } $userAdminId = (int) ($user->amministratore?->id ?: 0); if ($userAdminId > 0) { $allowedAdminIds[] = $userAdminId; } $ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0); if ($ownerAdminId > 0) { $allowedAdminIds[] = $ownerAdminId; } // Collaboratori: includi anche gli amministratori degli stabili assegnati. try { $assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all(); foreach ($assigned as $aid) { $aid = (int) $aid; if ($aid > 0) { $allowedAdminIds[] = $aid; } } } catch (\Throwable) { // ignore } $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); $fornitoreAdminIds = array_values(array_unique(array_filter([ (int) ($this->fornitore->amministratore_id ?? 0), (int) ($this->fornitore->rubrica?->amministratore_id ?? 0), ], fn($v) => (int) $v > 0))); if ($fornitoreAdminIds === []) { if (! $user->hasAnyRole(['admin', 'amministratore'])) { abort(404); } } else { $hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0; if (! $hasIntersection) { abort(404); } } } $this->hydrateBoxData($user); $this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->loadTagSuggestions(); $this->refreshDipendentiRows(); } public function creaDipendenteFornitore(): void { $nome = trim($this->nuovoDipendenteNome); $email = mb_strtolower(trim($this->nuovoDipendenteEmail)); if ($nome === '' || $email === '') { Notification::make()->title('Nome ed email sono obbligatori')->warning()->send(); return; } $exists = FornitoreDipendente::query() ->where('fornitore_id', (int) $this->fornitore->id) ->whereRaw('LOWER(email) = ?', [$email]) ->exists(); if ($exists) { Notification::make()->title('Dipendente gia presente per questo fornitore')->warning()->send(); return; } FornitoreDipendente::query()->create([ 'fornitore_id' => (int) $this->fornitore->id, 'nome' => $nome, 'cognome' => trim($this->nuovoDipendenteCognome) ?: null, 'email' => $email, 'telefono' => trim($this->nuovoDipendenteTelefono) ?: null, 'attivo' => true, 'created_by_user_id' => Auth::id(), 'updated_by_user_id' => Auth::id(), ]); $this->nuovoDipendenteNome = ''; $this->nuovoDipendenteCognome = ''; $this->nuovoDipendenteEmail = ''; $this->nuovoDipendenteTelefono = ''; $this->refreshDipendentiRows(); Notification::make()->title('Dipendente aggiunto')->success()->send(); } public function abilitaAccessoDipendente(int $dipendenteId): void { $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $this->fornitore->id) ->find($dipendenteId); if (! $dipendente instanceof FornitoreDipendente) { Notification::make()->title('Dipendente non trovato')->danger()->send(); return; } $email = trim((string) ($dipendente->email ?? '')); if ($email === '') { Notification::make()->title('Email dipendente mancante')->warning()->send(); return; } $targetUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); $created = false; if (! $targetUser instanceof User) { $generatedPassword = Str::random(12); $targetUser = User::query()->create([ 'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')), 'email' => $email, 'password' => Hash::make($generatedPassword), 'email_verified_at' => now(), 'is_active' => true, ]); $this->lastGeneratedPassword = $generatedPassword; $created = true; } $targetUser->assignRole('fornitore'); $dipendente->user_id = (int) $targetUser->id; $dipendente->attivo = true; $dipendente->updated_by_user_id = Auth::id(); $dipendente->save(); $this->refreshDipendentiRows(); Notification::make() ->title('Accesso dipendente abilitato') ->body($created ? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)') : 'Accesso collegato ad utente esistente.') ->success() ->send(); } public function resetPasswordDipendenteUser(int $userId): void { $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $this->fornitore->id) ->where('user_id', $userId) ->first(); if (! $dipendente instanceof FornitoreDipendente) { Notification::make()->title('Utente non collegato a questo fornitore')->danger()->send(); return; } $target = User::query()->find($userId); if (! $target instanceof User) { Notification::make()->title('Utente non trovato')->danger()->send(); return; } $newPassword = Str::random(12); $target->password = Hash::make($newPassword); $target->save(); $this->lastGeneratedPassword = $newPassword; Notification::make() ->title('Password dipendente resettata') ->body('Nuova password temporanea: ' . $newPassword) ->success() ->send(); } public function toggleDipendenteAttivo(int $dipendenteId): void { $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $this->fornitore->id) ->find($dipendenteId); if (! $dipendente instanceof FornitoreDipendente) { Notification::make()->title('Dipendente non trovato')->danger()->send(); return; } $dipendente->attivo = ! (bool) $dipendente->attivo; $dipendente->updated_by_user_id = Auth::id(); $dipendente->save(); $this->refreshDipendentiRows(); Notification::make()->title('Stato dipendente aggiornato')->success()->send(); } public function saveTags(): void { if (! Schema::hasColumn('fornitori', 'tags')) { Notification::make()->title('Colonna tags non presente')->warning()->send(); return; } $normalized = $this->normalizeTags($this->tagsInput); $this->fornitore->tags = implode(', ', $normalized); $this->fornitore->save(); $this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->loadTagSuggestions(); Notification::make()->title('Tag fornitore salvati')->success()->send(); } public function importLegacyTags(): void { if (! Schema::hasColumn('fornitori', 'tags')) { Notification::make()->title('Colonna tags non presente')->warning()->send(); return; } try { Artisan::call('fornitori:import-legacy-tags', [ '--fornitore-id' => [(int) $this->fornitore->id], ]); } catch (\Throwable $e) { Notification::make()->title('Import tag legacy fallito')->body($e->getMessage())->danger()->send(); return; } $this->fornitore->refresh(); $this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->loadTagSuggestions(); Notification::make()->title('Tag legacy importati')->success()->send(); } public function syncRubricaFromFornitore(): void { $payload = [ 'tipo_contatto' => 'persona_giuridica', 'categoria' => 'fornitore', 'stato' => 'attivo', 'ragione_sociale' => $this->fornitore->ragione_sociale ?: trim(($this->fornitore->nome ?? '') . ' ' . ($this->fornitore->cognome ?? '')), 'nome' => $this->fornitore->nome, 'cognome' => $this->fornitore->cognome, 'partita_iva' => $this->fornitore->partita_iva, 'codice_fiscale' => $this->fornitore->codice_fiscale, 'indirizzo' => $this->fornitore->indirizzo, 'civico' => $this->fornitore->civico, 'cap' => $this->fornitore->cap, 'citta' => $this->fornitore->citta, 'provincia' => $this->fornitore->provincia, 'nazione' => $this->fornitore->nazione, 'email' => $this->fornitore->email, 'pec' => $this->fornitore->pec, 'telefono_ufficio' => $this->fornitore->telefono, 'telefono_cellulare' => $this->fornitore->cellulare, 'sito_web' => $this->fornitore->sito_web, 'note' => $this->fornitore->note, 'data_ultima_modifica' => now()->toDateString(), ]; $rubrica = null; if ((int) ($this->fornitore->rubrica_id ?? 0) > 0) { $rubrica = RubricaUniversale::query()->find((int) $this->fornitore->rubrica_id); } if (! $rubrica instanceof RubricaUniversale) { $rubrica = RubricaUniversale::query()->create(array_merge($payload, [ 'data_inserimento' => now()->toDateString(), ])); } else { $rubrica->fill($payload); $rubrica->save(); } if ((int) ($this->fornitore->rubrica_id ?? 0) !== (int) $rubrica->id) { $this->fornitore->rubrica_id = (int) $rubrica->id; $this->fornitore->save(); } $this->fornitore->refresh(); Notification::make()->title('Rubrica aggiornata dal fornitore')->success()->send(); } private function loadTagSuggestions(): void { if (! Schema::hasColumn('fornitori', 'tags')) { $this->tagSuggestions = []; return; } $all = Fornitore::query() ->whereNotNull('tags') ->pluck('tags') ->all(); $pool = []; foreach ($all as $row) { foreach ($this->splitTags((string) $row) as $tag) { $pool[] = $tag; } } $pool = array_values(array_unique($pool)); sort($pool, SORT_NATURAL | SORT_FLAG_CASE); $this->tagSuggestions = array_slice($pool, 0, 300); } private function refreshDipendentiRows(): void { $this->dipendentiRows = FornitoreDipendente::query() ->with('user:id,name,email') ->where('fornitore_id', (int) $this->fornitore->id) ->orderByDesc('attivo') ->orderBy('nome') ->orderBy('cognome') ->get() ->map(function (FornitoreDipendente $d): array { $userId = (int) ($d->user_id ?? 0); return [ 'id' => (int) $d->id, 'nome' => (string) ($d->nome_completo ?: 'Dipendente #' . $d->id), 'email' => (string) ($d->email ?? ''), 'telefono' => (string) ($d->telefono ?? ''), 'attivo' => (bool) $d->attivo, 'user_id' => $userId, 'user_label' => $userId > 0 ? ((string) ($d->user?->name ?: ('Utente #' . $userId))): '-', ]; }) ->all(); } /** * @return array */ private function normalizeTags(string $input): array { $tags = []; foreach ($this->splitTags($input) as $tag) { $normalized = $this->canonicalizeTag($tag); if ($normalized !== null) { $tags[] = $normalized; } } $tags = array_values(array_unique($tags)); sort($tags, SORT_NATURAL | SORT_FLAG_CASE); return $tags; } /** * @return array */ private function splitTags(string $value): array { $parts = preg_split('/[,;|\n\r\/]+/', $value) ?: []; return array_values(array_filter(array_map(function (string $part): string { $clean = trim($part); $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; return $clean; }, $parts), fn(string $p): bool => $p !== '')); } private function canonicalizeTag(string $raw): ?string { $clean = trim(mb_strtolower($raw)); if ($clean === '' || mb_strlen($clean) < 3) { return null; } $map = [ 'idr' => 'idraulico', 'idraul' => 'idraulico', 'elett' => 'elettricista', 'elettric' => 'elettricista', 'ascens' => 'ascensorista', 'puliz' => 'pulizie', 'giardin' => 'giardiniere', 'assicur' => 'assicurazione', 'manut' => 'manutenzione', ]; foreach ($map as $prefix => $canonical) { if (str_starts_with($clean, $prefix)) { return $canonical; } } return $clean; } private function hydrateBoxData(User $user): void { $this->fattureAssociate = []; $this->raVersateRows = []; $this->box = [ 'stabile_id' => null, 'fornitore_features' => [], 'fornitore_defaults' => [ 'voce_spesa_default_id' => null, 'conto_costo_default_id' => null, ], 'ade' => ['count' => 0, 'lordo' => 0.0], 'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0], 'contabilita' => [ 'aperte_count' => 0, 'aperte_netto' => 0.0, 'pagate_count' => 0, 'pagate_netto' => 0.0, ], 'da_pagare' => ['source' => null, 'importo' => 0.0], 'ra' => [ 'da_versare' => 0.0, 'versata' => 0.0, 'compensata' => 0.0, ], ]; $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { return; } $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($activeStabileId); $amministratoreId = (int) ($stabile?->amministratore_id ?: 0); if ($amministratoreId <= 0) { return; } $this->box['stabile_id'] = $activeStabileId; // Feature flags & defaults (per stabile) + fallback (per fornitore) $settings = null; if (Schema::hasTable('fornitore_stabile_impostazioni')) { $settings = FornitoreStabileImpostazione::query() ->where('stabile_id', (int) $activeStabileId) ->where('fornitore_id', (int) $this->fornitore->id) ->first(); if ($settings) { $this->box['fornitore_defaults']['voce_spesa_default_id'] = $settings->voce_spesa_default_id ? (int) $settings->voce_spesa_default_id : null; $this->box['fornitore_defaults']['conto_costo_default_id'] = $settings->conto_costo_default_id ? (int) $settings->conto_costo_default_id : null; } } $features = []; if (Schema::hasColumn('fornitori', 'fe_features')) { $features = is_array($this->fornitore->fe_features ?? null) ? $this->fornitore->fe_features : []; } elseif ($settings && is_array($settings->meta ?? null)) { $features = is_array($settings->meta['fe_features'] ?? null) ? $settings->meta['fe_features'] : []; } $this->box['fornitore_features'] = $features; $normalize = static function (?string $value): string { $value = strtoupper(trim((string) $value)); $value = str_replace(' ', '', $value); return $value; }; $ids = []; $piva = $normalize($this->fornitore->partita_iva); $cf = $normalize($this->fornitore->codice_fiscale); if ($piva !== '') { $ids[] = $piva; if (str_starts_with($piva, 'IT') && strlen($piva) > 2) { $ids[] = substr($piva, 2); } } if ($cf !== '') { $ids[] = $cf; } $ids = array_values(array_unique(array_filter($ids, fn($v) => $v !== ''))); // AdE (staging) if (! empty($ids) && Schema::hasTable('stg_fatture_ade')) { $adeBase = StgFatturaAde::query() ->where('amministratore_id', $amministratoreId) ->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($activeStabileId): void { $q->where('stabile_id', $activeStabileId)->orWhereNull('stabile_id'); }) ->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void { foreach ($ids as $id) { $q->orWhereRaw("REPLACE(UPPER(identificativo_fornitore), ' ', '') = ?", [$id]); } }); $this->box['ade']['count'] = (int) (clone $adeBase)->count(); $lordo = (float) (clone $adeBase)->selectRaw('COALESCE(SUM(imponibile + imposta), 0) as lordo')->value('lordo'); $this->box['ade']['lordo'] = $lordo; $this->anteprimaAde = (clone $adeBase) ->orderByDesc('data_emissione') ->orderByDesc('id') ->limit(8) ->get(['id', 'data_emissione', 'numero_documento', 'imponibile', 'imposta', 'sdi_file']) ->map(fn(StgFatturaAde $r) => [ 'id' => $r->id, 'data' => $r->data_emissione?->format('d/m/Y'), 'numero' => (string) ($r->numero_documento ?? ''), 'lordo' => (float) ($r->imponibile ?? 0) + (float) ($r->imposta ?? 0), 'sdi_file' => (string) ($r->sdi_file ?? ''), ]) ->all(); } // FE importate if (Schema::hasTable('fatture_elettroniche')) { $feBase = FatturaElettronica::query() ->where('stabile_id', $activeStabileId) ->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void { $q->where('fornitore_id', $this->fornitore->id); if (! empty($ids)) { $q->orWhere(function (\Illuminate\Database\Eloquent\Builder $qq) use ($ids): void { foreach ($ids as $id) { $qq->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$id]) ->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$id]); } }); } }); $this->box['fe']['count'] = (int) (clone $feBase)->count(); $this->box['fe']['totale'] = (float) (clone $feBase)->selectRaw('COALESCE(SUM(totale), 0) as totale')->value('totale'); $this->box['fe']['contabilizzate'] = (int) (clone $feBase) ->where(function (\Illuminate\Database\Eloquent\Builder $q): void { $q->whereNotNull('registrazione_contabile_id')->orWhere('stato', 'contabilizzata'); }) ->count(); $this->anteprimaFe = (clone $feBase) ->orderByDesc('data_fattura') ->orderByDesc('id') ->limit(8) ->get(['id', 'data_fattura', 'numero_fattura', 'totale', 'sdi_file', 'stato']) ->map(fn(FatturaElettronica $r) => [ 'id' => $r->id, 'data' => $r->data_fattura?->format('d/m/Y'), 'numero' => (string) ($r->numero_fattura ?? ''), 'totale' => (float) ($r->totale ?? 0), 'sdi_file' => (string) ($r->sdi_file ?? ''), 'stato' => (string) ($r->stato ?? ''), ]) ->all(); } // Contabilità (fatture fornitori) if (Schema::hasTable('contabilita_fatture_fornitori')) { $contabBase = ContabilitaFatturaFornitore::query() ->where('stabile_id', $activeStabileId) ->where('fornitore_id', (int) $this->fornitore->id); // Coerente con la logica della scheda contabile: aperto = totale - pagato. $totNetto = (float) (clone $contabBase)->selectRaw('COALESCE(SUM(netto_da_pagare), 0) as netto')->value('netto'); $this->box['contabilita']['pagate_count'] = (int) (clone $contabBase)->where('stato', 'pagato')->count(); $this->box['contabilita']['pagate_netto'] = (float) (clone $contabBase)->where('stato', 'pagato')->selectRaw('COALESCE(SUM(netto_da_pagare), 0) as netto')->value('netto'); $this->box['contabilita']['aperte_netto'] = round($totNetto - (float) $this->box['contabilita']['pagate_netto'], 2); $this->box['contabilita']['aperte_count'] = (int) (clone $contabBase)->where('stato', '!=', 'pagato')->count(); $this->anteprimaContabilita = (clone $contabBase) ->orderByDesc('data_documento') ->orderByDesc('id') ->limit(8) ->get(['id', 'data_documento', 'numero_documento', 'netto_da_pagare', 'stato']) ->map(fn(ContabilitaFatturaFornitore $r) => [ 'id' => $r->id, 'data' => $r->data_documento?->format('d/m/Y'), 'numero' => (string) ($r->numero_documento ?? ''), 'netto' => (float) ($r->netto_da_pagare ?? 0), 'stato' => (string) ($r->stato ?? ''), ]) ->all(); $this->fattureAssociate = (clone $contabBase) ->orderByDesc('data_documento') ->orderByDesc('id') ->limit(30) ->get(['id', 'data_documento', 'numero_documento', 'totale', 'netto_da_pagare', 'stato', 'fattura_elettronica_id']) ->map(fn(ContabilitaFatturaFornitore $r) => [ 'id' => (int) $r->id, 'data' => $r->data_documento?->format('d/m/Y'), 'numero' => (string) ($r->numero_documento ?? ''), 'totale' => (float) ($r->totale ?? 0), 'netto' => (float) ($r->netto_da_pagare ?? 0), 'stato' => (string) ($r->stato ?? ''), 'source' => ((int) ($r->fattura_elettronica_id ?? 0) > 0) ? 'FE' : 'manuale', ]) ->all(); } // Registro RA if (Schema::hasTable('registro_ritenute_acconto')) { $raBase = RegistroRitenuteAcconto::query() ->where('fornitore_id', (int) $this->fornitore->id) ->whereHas('gestione', fn(\Illuminate\Database\Eloquent\Builder $q) => $q->where('stabile_id', (int) $activeStabileId)); foreach (['da_versare', 'versata', 'compensata'] as $stato) { $this->box['ra'][$stato] = (float) (clone $raBase) ->where('stato_versamento', $stato) ->selectRaw('COALESCE(SUM(importo_ritenuta), 0) as ra') ->value('ra'); } $this->raVersateRows = (clone $raBase) ->whereIn('stato_versamento', ['versata', 'compensata']) ->orderByDesc('data_versamento') ->orderByDesc('id') ->limit(30) ->get(['id', 'data_versamento', 'importo_ritenuta', 'stato_versamento', 'codice_tributo', 'f24_riferimento']) ->map(fn(RegistroRitenuteAcconto $r) => [ 'id' => (int) $r->id, 'data_versamento' => $r->data_versamento?->format('d/m/Y'), 'importo' => (float) ($r->importo_ritenuta ?? 0), 'stato' => (string) ($r->stato_versamento ?? ''), 'tributo' => (string) ($r->codice_tributo ?? ''), 'f24' => (string) ($r->f24_riferimento ?? ''), ]) ->all(); } // Totale da pagare (priorità: contabilità → FE → AdE) if ((float) ($this->box['contabilita']['aperte_netto'] ?? 0) > 0) { $this->box['da_pagare'] = ['source' => 'contabilita', 'importo' => (float) $this->box['contabilita']['aperte_netto']]; } elseif ((int) ($this->box['fe']['count'] ?? 0) > 0) { $this->box['da_pagare'] = ['source' => 'fe', 'importo' => (float) ($this->box['fe']['totale'] ?? 0)]; } elseif ((int) ($this->box['ade']['count'] ?? 0) > 0) { $this->box['da_pagare'] = ['source' => 'ade', 'importo' => (float) ($this->box['ade']['lordo'] ?? 0)]; } } protected function getHeaderActions(): array { return [ Action::make('apri_rubrica') ->label('Apri anagrafica unica') ->icon('heroicon-o-user') ->visible(fn(): bool => (int) ($this->fornitore->rubrica_id ?? 0) > 0) ->url(fn() => RubricaUniversaleScheda::getUrl([ 'record' => (int) $this->fornitore->rubrica_id, ], panel: 'admin-filament')), Action::make('sync_rubrica') ->label('Sincronizza Rubrica') ->icon('heroicon-o-arrow-path') ->action(fn() => $this->syncRubricaFromFornitore()), Action::make('importa_tag_legacy') ->label('Importa TAG legacy') ->icon('heroicon-o-tag') ->action(fn() => $this->importLegacyTags()), Action::make('impostazioni_fe') ->label('Impostazioni FE') ->icon('heroicon-o-adjustments-horizontal') ->visible(fn(): bool => Schema::hasColumn('fornitori', 'escludi_righe_fe')) ->form([ Toggle::make('escludi_righe_fe') ->label('Escludi righe FE (import e visualizzazione)') ->helperText('Utile per utenze (acqua/luce/gas): si userà il PDF per i dati di consumo invece delle righe XML.') ->default((bool) ($this->fornitore->escludi_righe_fe ?? false)), Select::make('conto_costo_default_id') ->label('Voce spesa predefinita (sottoconto costo)') ->helperText('Valore suggerito per imputare i costi quando le righe FE non hanno sottoconto o sono assenti.') ->options(function (): array { if (! Schema::hasTable('contabilita_piano_conti')) { return []; } return PianoConti::query() ->orderBy('codice') ->limit(1000) ->get(['id', 'codice', 'descrizione']) ->mapWithKeys(fn(PianoConti $c) => [(string) $c->id => $c->codice . ' - ' . $c->descrizione]) ->all(); }) ->searchable() ->nullable() ->default(fn(): ?int => $this->box['fornitore_defaults']['conto_costo_default_id'] ? (int) $this->box['fornitore_defaults']['conto_costo_default_id'] : null), Select::make('voce_spesa_default_id') ->label('Voce di spesa predefinita (ripartizione)') ->helperText('Collegamento “logico” alla voce (utile per consumi/statistiche/ripartizione; es. acqua).') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! Schema::hasTable('voci_spesa')) { return []; } return VoceSpesa::query() ->where('stabile_id', (int) $stabileId) ->orderBy('descrizione') ->limit(800) ->get(['id', 'codice', 'descrizione']) ->mapWithKeys(function (VoceSpesa $v) { $label = trim((string) ($v->codice ?? '')); if ($label !== '') { $label .= ' — '; } $label .= (string) ($v->descrizione ?? ('Voce #' . $v->id)); return [(string) $v->id => $label]; }) ->all(); }) ->searchable() ->nullable() ->default(fn(): ?int => $this->box['fornitore_defaults']['voce_spesa_default_id'] ? (int) $this->box['fornitore_defaults']['voce_spesa_default_id'] : null), ]) ->action(function (array $data): void { if (! Schema::hasColumn('fornitori', 'escludi_righe_fe')) { Notification::make()->title('Colonna mancante: escludi_righe_fe')->warning()->send(); return; } $this->fornitore->escludi_righe_fe = (bool) ($data['escludi_righe_fe'] ?? false); $this->fornitore->save(); $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return; } if (! Schema::hasTable('fornitore_stabile_impostazioni')) { return; } $voceSpesaId = isset($data['voce_spesa_default_id']) && is_numeric($data['voce_spesa_default_id']) ? (int) $data['voce_spesa_default_id'] : null; $contoCostoId = isset($data['conto_costo_default_id']) && is_numeric($data['conto_costo_default_id']) ? (int) $data['conto_costo_default_id'] : null; FornitoreStabileImpostazione::query()->updateOrCreate( [ 'stabile_id' => (int) $stabileId, 'fornitore_id' => (int) $this->fornitore->id, ], [ 'voce_spesa_default_id' => $voceSpesaId ?: null, 'conto_costo_default_id' => $contoCostoId ?: null, ] ); $this->hydrateBoxData($user); }) ->successNotificationTitle('Impostazioni FE salvate'), Action::make('acqua_config') ->label('Configura acqua') ->icon('heroicon-o-beaker') ->form([ Toggle::make('acqua_enabled') ->label('Abilita acquisizione dati acqua (PDF)') ->default((bool) ((is_array($this->box['fornitore_features'] ?? null) ? ($this->box['fornitore_features']['acqua']['enabled'] ?? false) : false))) ->helperText('Attiva le funzioni acqua su questo fornitore (scan + letture/periodi).'), Toggle::make('acqua_scan_enabled') ->label('Abilita scansione massiva su FE già importate') ->default((bool) ((is_array($this->box['fornitore_features'] ?? null) ? ($this->box['fornitore_features']['acqua']['scan_enabled'] ?? false) : false))), Toggle::make('acqua_ticket_on_anomaly') ->label('Apri ticket su anomalie/mismatch') ->default(true) ->helperText('Se il parser non trova dati o cambia utenza/contatore, apre un ticket interno.'), Repeater::make('utenze_acqua') ->label('Utenze acqua (stabile attivo)') ->helperText('Compila i campi per ogni contatore/utenza (lo stabile può averne più di uno).') ->default(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! Schema::hasTable('stabile_servizi')) { return []; } return StabileServizio::query() ->where('stabile_id', (int) $stabileId) ->where('tipo', 'acqua') ->orderByDesc('attivo') ->orderBy('nome') ->limit(50) ->get([ 'id', 'nome', 'attivo', 'voce_spesa_id', 'contatore_matricola', 'codice_utenza', 'codice_cliente', 'codice_contratto', ]) ->map(function (StabileServizio $s): array { return [ 'id' => (int) $s->id, 'nome' => (string) ($s->nome ?? ''), 'attivo' => (bool) $s->attivo, 'voce_spesa_id' => $s->voce_spesa_id ? (int) $s->voce_spesa_id : null, 'contatore_matricola' => (string) ($s->contatore_matricola ?? ''), 'codice_utenza' => (string) ($s->codice_utenza ?? ''), 'codice_cliente' => (string) ($s->codice_cliente ?? ''), 'codice_contratto' => (string) ($s->codice_contratto ?? ''), ]; }) ->all(); }) ->schema([ Hidden::make('id'), TextInput::make('nome') ->label('Nome utenza') ->maxLength(255) ->nullable(), Toggle::make('attivo') ->label('Attiva') ->default(true), TextInput::make('contatore_matricola') ->label('Matricola contatore') ->maxLength(64) ->nullable(), TextInput::make('codice_utenza') ->label('Codice utenza') ->maxLength(64) ->nullable(), TextInput::make('codice_cliente') ->label('Codice cliente') ->maxLength(64) ->nullable(), TextInput::make('codice_contratto') ->label('Codice contratto') ->maxLength(64) ->nullable(), Select::make('voce_spesa_id') ->label('Voce di spesa (solo acqua)') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! Schema::hasTable('voci_spesa')) { return []; } return VoceSpesa::query() ->where('stabile_id', (int) $stabileId) ->where('categoria', 'acqua') ->orderBy('descrizione') ->limit(300) ->get(['id', 'codice', 'descrizione']) ->mapWithKeys(function (VoceSpesa $v) { $label = trim((string) ($v->codice ?? '')); if ($label !== '') { $label .= ' — '; } $label .= (string) ($v->descrizione ?? ('Voce #' . $v->id)); return [(string) $v->id => $label]; }) ->all(); }) ->searchable() ->nullable(), ]) ->addActionLabel('Aggiungi utenza acqua') ->reorderable(false) ->collapsed(false), ]) ->action(function (array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } // Salva toggle feature (preferenza: colonna fornitori.fe_features, fallback: meta per-stabile) $features = is_array($this->box['fornitore_features'] ?? null) ? $this->box['fornitore_features'] : []; $features['acqua'] = array_merge(is_array($features['acqua'] ?? null) ? $features['acqua'] : [], [ 'enabled' => (bool) ($data['acqua_enabled'] ?? false), 'scan_enabled' => (bool) ($data['acqua_scan_enabled'] ?? false), 'ticket_on_anomaly' => (bool) ($data['acqua_ticket_on_anomaly'] ?? true), ]); $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Seleziona uno stabile attivo')->warning()->send(); return; } if (Schema::hasColumn('fornitori', 'fe_features')) { $this->fornitore->fe_features = $features; $this->fornitore->save(); } else { if (! Schema::hasTable('fornitore_stabile_impostazioni')) { Notification::make() ->title('Impossibile salvare feature') ->body('Manca la colonna fornitori.fe_features e non esiste la tabella fornitore_stabile_impostazioni (eseguire migrazioni).') ->danger() ->send(); return; } $settings = FornitoreStabileImpostazione::query()->firstOrNew([ 'stabile_id' => (int) $stabileId, 'fornitore_id' => (int) $this->fornitore->id, ]); $meta = is_array($settings->meta ?? null) ? $settings->meta : []; $meta['fe_features'] = $features; $settings->meta = $meta; $settings->save(); } if (! Schema::hasTable('stabile_servizi')) { Notification::make()->title('Tabella servizi non presente')->warning()->send(); return; } $utenze = is_array($data['utenze_acqua'] ?? null) ? $data['utenze_acqua'] : []; foreach ($utenze as $row) { $rowId = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null; $nome = is_string($row['nome'] ?? null) ? trim((string) $row['nome']) : ''; $attivo = (bool) ($row['attivo'] ?? true); $voceSpesaId = isset($row['voce_spesa_id']) && is_numeric($row['voce_spesa_id']) ? (int) $row['voce_spesa_id'] : null; $matricola = is_string($row['contatore_matricola'] ?? null) ? trim((string) $row['contatore_matricola']) : ''; $utenzaCod = is_string($row['codice_utenza'] ?? null) ? trim((string) $row['codice_utenza']) : ''; $cliente = is_string($row['codice_cliente'] ?? null) ? trim((string) $row['codice_cliente']) : ''; $contratto = is_string($row['codice_contratto'] ?? null) ? trim((string) $row['codice_contratto']) : ''; $hasAny = ($nome !== '') || ($matricola !== '') || ($utenzaCod !== '') || ($cliente !== '') || ($contratto !== '') || ($voceSpesaId && $voceSpesaId > 0); if (! $hasAny) { continue; } $servizio = null; if ($rowId) { $servizio = StabileServizio::query() ->where('stabile_id', (int) $stabileId) ->where('tipo', 'acqua') ->find($rowId); } if (! $servizio) { $autoName = 'Acqua'; if ($matricola !== '') { $autoName .= ' - Contatore ' . $matricola; } elseif ($utenzaCod !== '') { $autoName .= ' - Utenza ' . $utenzaCod; } $servizio = new StabileServizio([ 'stabile_id' => (int) $stabileId, 'tipo' => 'acqua', 'nome' => $nome !== '' ? $nome : $autoName, 'attivo' => true, ]); } $servizio->fornitore_id = (int) $this->fornitore->id; $servizio->attivo = $attivo; if ($nome !== '') { $servizio->nome = $nome; } $servizio->voce_spesa_id = $voceSpesaId ?: null; $servizio->contatore_matricola = $matricola !== '' ? $matricola : null; $servizio->codice_utenza = $utenzaCod !== '' ? $utenzaCod : null; $servizio->codice_cliente = $cliente !== '' ? $cliente : null; $servizio->codice_contratto = $contratto !== '' ? $contratto : null; $servizio->save(); } $this->hydrateBoxData($user); Notification::make()->title('Configurazione acqua salvata')->success()->send(); }), Action::make('acqua_scan') ->label('Scansiona FE acqua') ->icon('heroicon-o-magnifying-glass') ->requiresConfirmation() ->form([ Toggle::make('solo_stabile_attivo') ->label('Solo stabile attivo') ->default(true), Toggle::make('solo_non_estratte') ->label('Solo FE non ancora estratte (acqua)') ->default(true) ->helperText('Processa solo fatture dove non risulta già salvato consumo acqua.'), Select::make('voce_spesa_id') ->label('Voce di spesa (opzionale)') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! Schema::hasTable('voci_spesa')) { return []; } return VoceSpesa::query() ->where('stabile_id', (int) $stabileId) ->orderBy('descrizione') ->where('categoria', 'acqua') ->limit(300) ->get(['id', 'codice', 'descrizione']) ->mapWithKeys(function (VoceSpesa $v) { $label = trim((string) ($v->codice ?? '')); if ($label !== '') { $label .= ' — '; } $label .= (string) ($v->descrizione ?? ('Voce #' . $v->id)); return [(string) $v->id => $label]; }) ->all(); }) ->searchable() ->nullable(), Select::make('force_servizio_id') ->label('Forza servizio acqua (opzionale)') ->helperText('Se impostato, blocca l’aggancio se il PDF indica un contatore/utenza diversa (apre ticket).') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! Schema::hasTable('stabile_servizi')) { return []; } return StabileServizio::query() ->where('stabile_id', (int) $stabileId) ->where('tipo', 'acqua') ->orderByDesc('attivo') ->orderBy('nome') ->limit(200) ->get(['id', 'nome', 'contatore_matricola', 'codice_utenza']) ->mapWithKeys(function (StabileServizio $s) { $label = (string) ($s->nome ?: ('Servizio #' . $s->id)); $bits = []; if (is_string($s->contatore_matricola) && $s->contatore_matricola !== '') { $bits[] = 'Contatore ' . $s->contatore_matricola; } if (is_string($s->codice_utenza) && $s->codice_utenza !== '') { $bits[] = 'Utenza ' . $s->codice_utenza; } if (count($bits) > 0) { $label .= ' — ' . implode(' · ', $bits); } return [(string) $s->id => $label]; }) ->all(); }) ->searchable() ->nullable(), TextInput::make('limit') ->label('Limite FE da processare') ->numeric() ->default(50) ->minValue(1) ->maxValue(500), Toggle::make('crea_ticket') ->label('Crea ticket su anomalie') ->default(true), ]) ->action(function (array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $features = is_array($this->box['fornitore_features'] ?? null) ? $this->box['fornitore_features'] : []; $acqua = is_array($features['acqua'] ?? null) ? $features['acqua'] : []; if (! (bool) ($acqua['enabled'] ?? false)) { Notification::make()->title('Acqua non attiva su questo fornitore')->warning()->send(); return; } $soloStabileAttivo = (bool) ($data['solo_stabile_attivo'] ?? true); $soloNonEstratte = (bool) ($data['solo_non_estratte'] ?? true); $activeStabileId = StabileContext::resolveActiveStabileId($user); if ($soloStabileAttivo && ! $activeStabileId) { Notification::make()->title('Seleziona uno stabile attivo')->warning()->send(); return; } $stabileIds = []; if ($soloStabileAttivo) { $stabileIds = [(int) $activeStabileId]; } else { $stabileIds = Stabile::query() ->where('amministratore_id', (int) $this->fornitore->amministratore_id) ->pluck('id') ->map(fn($v) => (int) $v) ->all(); } if (count($stabileIds) < 1) { Notification::make()->title('Nessuno stabile trovato')->warning()->send(); return; } $limit = isset($data['limit']) && is_numeric($data['limit']) ? (int) $data['limit'] : 50; $limit = max(1, min(500, $limit)); $voceSpesaId = isset($data['voce_spesa_id']) && is_numeric($data['voce_spesa_id']) ? (int) $data['voce_spesa_id'] : null; $forceServizioId = isset($data['force_servizio_id']) && is_numeric($data['force_servizio_id']) ? (int) $data['force_servizio_id'] : null; $creaTicket = (bool) ($data['crea_ticket'] ?? true); $normalize = static function (?string $value): string { $value = strtoupper(trim((string) $value)); $value = str_replace(' ', '', $value); return $value; }; $ids = []; $piva = $normalize($this->fornitore->partita_iva); $cf = $normalize($this->fornitore->codice_fiscale); if ($piva !== '') { $ids[] = $piva; if (str_starts_with($piva, 'IT') && strlen($piva) > 2) { $ids[] = substr($piva, 2); } } if ($cf !== '') { $ids[] = $cf; } $ids = array_values(array_unique(array_filter($ids, fn($v) => $v !== ''))); $feQuery = FatturaElettronica::query() ->whereIn('stabile_id', $stabileIds) ->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void { $q->where('fornitore_id', (int) $this->fornitore->id); if (! empty($ids)) { $q->orWhere(function (\Illuminate\Database\Eloquent\Builder $qq) use ($ids): void { foreach ($ids as $id) { $qq->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$id]) ->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$id]); } }); } }); if ($soloNonEstratte) { $feQuery->where(function (\Illuminate\Database\Eloquent\Builder $q): void { $q->whereNull('consumo_raw') ->orWhere('consumo_raw', 'not like', '%"type":"acqua"%'); }); } $feQuery ->orderByDesc('data_fattura') ->orderByDesc('id') ->limit($limit); $fatture = $feQuery->get(['id', 'stabile_id', 'fornitore_id', 'sdi_file', 'totale', 'data_fattura', 'allegato_pdf_path', 'allegato_pdf_hash', 'xml_content', 'consumo_raw']); $processed = 0; $ok = 0; $skipped = 0; $tickets = 0; foreach ($fatture as $fe) { $processed++; // Ensure Documento (best-effort) try { if ((int) $user->id > 0) { app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fe, (int) $user->id); $fe->refresh(); } } catch (\Throwable) { // ignore } $doc = Documento::query() ->where('documentable_type', FatturaElettronica::class) ->where('documentable_id', (int) $fe->id) ->first(); if (! $doc) { $skipped++; if ($creaTicket) { try { $ing = app(ConsumiAcquaIngestionService::class)->ingest( $fe, ['error' => 'Documento non presente', 'consumi' => []], $voceSpesaId, (int) $user->id, true, $forceServizioId ); $tickets += (int) ($ing['tickets'] ?? 0); } catch (\Throwable) { // ignore } } continue; } $text = is_string($doc->contenuto_ocr) ? trim($doc->contenuto_ocr) : ''; if ($text === '') { $res = app(PdfTextExtractionService::class)->extractAndStore($doc, false); if (($res['status'] ?? null) !== 'ok') { $skipped++; if ($creaTicket) { try { $ing = app(ConsumiAcquaIngestionService::class)->ingest( $fe, ['error' => (string) ($res['message'] ?? 'OCR fallita'), 'consumi' => []], $voceSpesaId, (int) $user->id, true, $forceServizioId ); $tickets += (int) ($ing['tickets'] ?? 0); } catch (\Throwable) { // ignore } } continue; } $doc->refresh(); $text = is_string($doc->contenuto_ocr) ? trim($doc->contenuto_ocr) : ''; } if ($text === '') { $skipped++; if ($creaTicket) { try { $ing = app(ConsumiAcquaIngestionService::class)->ingest( $fe, ['error' => 'Testo OCR vuoto', 'consumi' => []], $voceSpesaId, (int) $user->id, true, $forceServizioId ); $tickets += (int) ($ing['tickets'] ?? 0); } catch (\Throwable) { // ignore } } continue; } $parsed = app(AcquaPdfTextParser::class)->parse($text); $codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : []; $contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : []; $consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []; $generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : []; $finestra = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : []; $letture = is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : []; $quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : []; $tariffe = is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : []; $ivaInfo = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : []; $hasTariffData = count(array_filter([ $generale['periodicita_fatturazione'] ?? null, $tariffe['profilo'] ?? null, $ivaInfo['codice'] ?? null, $quadro['quota_fissa'] ?? null, ], static fn($v): bool => $v !== null && $v !== '')) > 0; $hasAny = false; foreach ([$codici['utenza'] ?? null, $codici['cliente'] ?? null, $codici['contratto'] ?? null, $contatore['matricola'] ?? null] as $v) { if (is_string($v) && trim($v) !== '') { $hasAny = true; break; } } if (! $hasAny && count($consumi) < 1 && ! $hasTariffData) { $skipped++; if ($creaTicket) { try { $ing = app(ConsumiAcquaIngestionService::class)->ingest($fe, $parsed, $voceSpesaId, (int) $user->id, true, $forceServizioId); $tickets += (int) ($ing['tickets'] ?? 0); } catch (\Throwable) { // ignore } } continue; } // Salva anche su FE (consumo_raw + campi generici) $payload = [ 'type' => 'acqua', 'source' => 'pdf_ocr', 'codici' => [ 'utenza' => $codici['utenza'] ?? null, 'cliente' => $codici['cliente'] ?? null, 'contratto' => $codici['contratto'] ?? null, ], 'contatore' => [ 'matricola' => $contatore['matricola'] ?? null, ], 'consumi' => array_values($consumi), 'generale' => $generale, 'finestra_autolettura' => $finestra, 'riepilogo_letture' => array_values($letture), 'quadro_dettaglio' => $quadro, 'tariffe' => $tariffe, 'iva' => [ 'codice' => $ivaInfo['codice'] ?? null, 'aliquota_percentuale' => $ivaInfo['aliquota_percentuale'] ?? null, 'descrizione' => $ivaInfo['descrizione'] ?? null, ], 'parsed_at' => now()->toISOString(), ]; $totMc = null; $ref = null; if (count($consumi) > 0) { $sum = 0.0; $has = false; foreach ($consumi as $c) { if (isset($c['valore']) && is_numeric($c['valore'])) { $sum += (float) $c['valore']; $has = true; } } if ($has) { $totMc = $sum; } $first = $consumi[0] ?? []; $dal = $first['dal'] ?? null; $al = $first['al'] ?? null; if (is_string($dal) && is_string($al) && $dal !== '' && $al !== '') { $ref = $dal . ' - ' . $al; } } $fe->consumo_unita = 'mc'; $fe->consumo_valore = $totMc; $fe->consumo_riferimento = $ref; $fe->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $fe->save(); try { app(ConsumiAcquaTariffeIngestionService::class)->ingest($fe, $parsed, null); } catch (\Throwable) { // best-effort } $ing = app(ConsumiAcquaIngestionService::class)->ingest($fe, $parsed, $voceSpesaId, (int) $user->id, $creaTicket, $forceServizioId); if (($ing['status'] ?? null) === 'ok') { try { app(ConsumiAcquaTariffeIngestionService::class)->ingest( $fe, $parsed, isset($ing['servizio_id']) && is_numeric($ing['servizio_id']) ? (int) $ing['servizio_id'] : null ); } catch (\Throwable) { // best-effort } } if (($ing['status'] ?? null) === 'ok') { $ok++; } elseif (($ing['status'] ?? null) === 'no-data' && $hasTariffData) { $ok++; } elseif (($ing['status'] ?? null) === 'mismatch') { $tickets += (int) ($ing['tickets'] ?? 1); $skipped++; } elseif (($ing['status'] ?? null) === 'no-data') { $tickets += (int) ($ing['tickets'] ?? 0); $skipped++; } else { $skipped++; } } $body = 'Processate: ' . $processed . ' · OK: ' . $ok . ' · Skipped: ' . $skipped; if ($tickets > 0) { $body .= ' · Ticket: ' . $tickets; } Notification::make()->title('Scansione acqua completata')->body($body)->success()->send(); }), Action::make('torna') ->label('Torna') ->icon('heroicon-o-arrow-left') ->url(fn() => url()->previous()), ]; } }