netgescon-day0/app/Filament/Pages/Gescon/FornitoreScheda.php

1332 lines
63 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Filament\Pages\Gescon;
use App\Models\Documento;
use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\RegistroRitenuteAcconto;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Models\StabileServizio;
use App\Models\StgFatturaAde;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
use App\Modules\Contabilita\Models\PianoConti;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
use App\Support\StabileContext;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
class FornitoreScheda extends Page
{
protected static ?string $title = 'Scheda fornitore';
protected static ?string $slug = 'anagrafica/fornitori/{record}';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.gescon.fornitore-scheda';
public Fornitore $fornitore;
/** @var array<string, mixed> */
public array $box = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaAde = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaFe = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaContabilita = [];
/** @var array<int, array<string, mixed>> */
public array $fattureAssociate = [];
/** @var array<int, array<string, mixed>> */
public array $raVersateRows = [];
public string $tagsInput = '';
/** @var array<int, string> */
public array $tagSuggestions = [];
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)));
$fornitoreAdminId = (int) ($this->fornitore->amministratore_id ?? 0);
if ($fornitoreAdminId <= 0 || ! in_array($fornitoreAdminId, $allowedAdminIds, true)) {
abort(404);
}
}
$this->hydrateBoxData($user);
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions();
}
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);
}
/**
* @return array<int, string>
*/
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<int, string>
*/
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 laggancio 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()),
];
}
}