netgescon-day0/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php

1184 lines
58 KiB
PHP

<?php
namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Contabilita\FattureElettronicheArchivio;
use App\Models\Documento;
use App\Models\FatturaElettronica;
use App\Models\FeXsdCode;
use App\Models\FeXsdCodeDomain;
use App\Models\FeXsdVersion;
use App\Models\Fornitore;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Support\ArchivioPaths;
use App\Support\StabileContext;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FatturaElettronicaScheda extends Page
{
protected static ?string $title = 'Scheda fattura ricevuta';
protected static ?string $slug = 'contabilita/fatture-ricevute/{record}';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.contabilita.fattura-elettronica-scheda';
public FatturaElettronica $fattura;
public ?Documento $documento = null;
/** @var array<string, mixed> */
public array $xmlDetails = [];
public ?int $contabilitaFatturaId = null;
/** @var array<string,mixed> */
public array $acquaPdfPreview = [];
public function getFornitoreAgganciatoLabel(): ?string
{
$f = $this->fattura->fornitore;
if (! $f) {
return null;
}
$ragione = is_string($f->ragione_sociale ?? null) ? trim((string) $f->ragione_sociale) : '';
if ($ragione !== '') {
return $ragione;
}
$nome = is_string($f->nome ?? null) ? trim((string) $f->nome) : '';
$cognome = is_string($f->cognome ?? null) ? trim((string) $f->cognome) : '';
$full = trim($nome . ' ' . $cognome);
if ($full !== '') {
return $full;
}
$code = is_string($f->codice_univoco ?? null) ? trim((string) $f->codice_univoco) : '';
return $code !== '' ? $code : ('Fornitore #' . (int) $f->id);
}
public function getPagamentoModalitaXsdLabel(): ?string
{
$code = is_string($this->fattura->pagamento_modalita) ? trim($this->fattura->pagamento_modalita) : '';
if ($code === '') {
return null;
}
$latestId = FeXsdVersion::query()->orderByDesc('imported_at')->orderByDesc('id')->value('id');
$latestId = is_numeric($latestId) ? (int) $latestId : null;
if (! $latestId) {
return null;
}
$domainId = FeXsdCodeDomain::query()
->where('version_id', $latestId)
->where('type_name', 'ModalitaPagamentoType')
->value('id');
$domainId = is_numeric($domainId) ? (int) $domainId : null;
if (! $domainId) {
return null;
}
$doc = FeXsdCode::query()
->where('domain_id', $domainId)
->where('code', $code)
->value('documentation');
return is_string($doc) && trim($doc) !== '' ? trim($doc) : null;
}
public function mount(int | string $record): void
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->hasAnyRole(['super-admin', 'admin']) && ! $user->can('contabilita.fatture-elettroniche')) {
abort(403);
}
$this->fattura = FatturaElettronica::query()->with(['stabile', 'fornitore'])->findOrFail((int) $record);
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if ($activeStabileId && (int) $this->fattura->stabile_id !== (int) $activeStabileId) {
// Non blocchiamo la view, ma evitiamo accesso accidentale cross-stabile via URL.
abort(404);
}
// Parse XML (best-effort) per righe/ritenuta/riferimenti.
$this->xmlDetails = [];
if (is_string($this->fattura->xml_content) && $this->fattura->xml_content !== '') {
try {
$parsed = app(FatturaElettronicaXmlParser::class)->parse($this->fattura->xml_content);
$this->xmlDetails = is_array($parsed) ? $parsed : [];
} catch (\Throwable) {
$this->xmlDetails = [];
}
}
// Se esiste una fattura contabile collegata alla FE, esponiamo l'id per link rapido.
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$this->contabilitaFatturaId = ContabilitaFatturaFornitore::query()
->where('fattura_elettronica_id', (int) $this->fattura->id)
->value('id');
$this->contabilitaFatturaId = $this->contabilitaFatturaId ? (int) $this->contabilitaFatturaId : null;
}
// Aggancio automatico fornitore (se manca) basato su P.IVA/CF.
$this->tryAutoLinkFornitore();
// Best-effort: assicurati che esista sempre un Documento (e un PDF archiviabile).
// Questo evita casi in cui l'utente vede "PDF non disponibile" e non ha nulla da archiviare.
try {
$userId = (int) (Auth::id() ?: 0);
$pdfOk = (is_string($this->fattura->allegato_pdf_path) && trim($this->fattura->allegato_pdf_path) !== '')
|| (is_string($this->fattura->allegato_pdf_hash ?? null) && trim((string) $this->fattura->allegato_pdf_hash) !== '');
if ($userId > 0 && (! $this->documento || ! $pdfOk)) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
}
} catch (\Throwable) {
// ignore
}
}
private function tryAutoLinkFornitore(): void
{
$stabile = $this->fattura->stabile;
$amministratoreId = (int) ($stabile?->amministratore_id ?: 0);
if ($amministratoreId <= 0) {
return;
}
// Guard: se la FE è agganciata a un fornitore di un altro amministratore (es. legacy), ripariamo.
$currentFornitoreId = (int) ($this->fattura->fornitore_id ?? 0);
if ($currentFornitoreId > 0) {
try {
$current = Fornitore::query()->select(['id', 'amministratore_id'])->find($currentFornitoreId);
if ($current && (int) $current->amministratore_id !== $amministratoreId) {
$this->fattura->fornitore_id = null;
$this->fattura->save();
$this->fattura->unsetRelation('fornitore');
} else {
return;
}
} catch (\Throwable) {
// Se non riusciamo a validare, non blocchiamo la view.
}
}
$normalize = static function (?string $value): string {
$value = strtoupper(trim((string) $value));
$value = str_replace(' ', '', $value);
return $value;
};
$piva = $normalize($this->fattura->fornitore_piva);
$cf = $normalize($this->fattura->fornitore_cf);
if ($piva === '' || $piva === 'ND') {
$piva = '';
}
if ($cf === '' || $cf === 'ND') {
$cf = '';
}
$query = Fornitore::query()->where('amministratore_id', $amministratoreId);
$match = null;
if ($piva !== '') {
$match = (clone $query)->whereRaw("REPLACE(UPPER(partita_iva), ' ', '') = ?", [$piva])->first();
if (! $match && str_starts_with($piva, 'IT') && strlen($piva) > 2) {
$match = (clone $query)->whereRaw("REPLACE(UPPER(partita_iva), ' ', '') = ?", [substr($piva, 2)])->first();
}
}
if (! $match && $cf !== '') {
$match = (clone $query)->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf])->first();
}
if (! $match) {
return;
}
$this->fattura->fornitore_id = (int) $match->id;
$this->fattura->save();
// Refresh relazione per UI.
$this->fattura->setRelation('fornitore', $match);
}
public function getBreadcrumbs(): array
{
return [
'Contabilità' => null,
'Fatture ricevute (XML)' => FattureElettronicheArchivio::getUrl(panel: 'admin-filament'),
'Fattura ' . ($this->fattura->numero_fattura ?? '#') => null,
];
}
public function getBackUrl(): string
{
$back = request()->query('back');
if (! is_string($back) || $back === '') {
return FattureElettronicheArchivio::getUrl(panel: 'admin-filament');
}
$back = trim($back);
if (Str::startsWith($back, ['http://', 'https://', '//'])) {
return FattureElettronicheArchivio::getUrl(panel: 'admin-filament');
}
if (! Str::startsWith($back, '/')) {
return FattureElettronicheArchivio::getUrl(panel: 'admin-filament');
}
// Never allow browser navigation to Livewire internal endpoints.
if (Str::startsWith($back, ['/livewire/', '/livewire'])) {
return FattureElettronicheArchivio::getUrl(panel: 'admin-filament');
}
return $back;
}
protected function getHeaderActions(): array
{
return [
Action::make('visualizza_pdf_modal')
->label('Vedi PDF')
->icon('heroicon-o-eye')
->visible(function (): bool {
$pdfPath = is_string($this->fattura->allegato_pdf_path ?? null) ? trim((string) $this->fattura->allegato_pdf_path) : '';
$pdfHash = is_string($this->fattura->allegato_pdf_hash ?? null) ? trim((string) $this->fattura->allegato_pdf_hash) : '';
return $pdfPath !== '' || $pdfHash !== '' || (bool) $this->documento;
})
->modalHeading('Fattura (PDF)')
->modalWidth('7xl')
->modalContent(function () {
$url = route('admin.fatture-elettroniche.download-pdf', ['fattura' => $this->fattura->id]) . '?inline=1';
return view('filament.modals.pdf-viewer', ['url' => $url]);
})
->modalSubmitAction(false)
->modalCancelActionLabel('Chiudi'),
Action::make('completa')
->label('Completa')
->icon('heroicon-o-sparkles')
->visible(fn(): bool => $this->needsCompleta())
->requiresConfirmation()
->action(function (): void {
if (! is_string($this->fattura->xml_content) || trim($this->fattura->xml_content) === '') {
Notification::make()->title('XML non disponibile')->danger()->send();
return;
}
$xml = (string) $this->fattura->xml_content;
$filename = is_string($this->fattura->nome_file_xml) && $this->fattura->nome_file_xml !== ''
? $this->fattura->nome_file_xml
: ('FE-' . (int) $this->fattura->id . '.xml');
$unique = (string) Str::uuid();
$ym = now()->format('Y/m');
$base = null;
if ($this->fattura->stabile) {
$base = ArchivioPaths::stabileBase($this->fattura->stabile, $this->fattura->stabile->amministratore);
}
if (! is_string($base) || $base === '') {
$admin = $this->fattura->stabile?->amministratore;
$adminFolder = $admin?->codice_univoco ?: ($admin?->codice_amministratore ?: 'ID-0');
$stabileFolder = $this->fattura->stabile?->codice_univoco
?: ($this->fattura->stabile?->codice_stabile ?: ('ID-' . (int) $this->fattura->stabile_id));
$base = 'amministratori/' . $adminFolder . '/stabili/' . $stabileFolder;
}
$inbox = $base . '/fatture-ricevute/inbox/' . $ym;
$permanentXmlPath = $inbox . '/' . $unique . '-' . $filename;
try {
Storage::disk('local')->put($permanentXmlPath, $xml);
} catch (\Throwable $e) {
Notification::make()->title('Errore salvataggio XML')->body($e->getMessage())->danger()->send();
return;
}
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
$stabileId = $this->fattura->stabile_id ? (int) $this->fattura->stabile_id : 0;
$result = $importer->importXml($xml, $stabileId, $filename, [
'xml_path' => $permanentXmlPath,
'force_update' => true,
'importa_righe' => 'auto',
'auto_repair_duplicate' => true,
'allow_fallback_stabile' => true,
'force_stabile_id' => $stabileId,
'user_id' => (int) (Auth::id() ?: 0),
]);
if (($result['status'] ?? null) === 'imported') {
// Best-effort: ripara/aggiorna protocollazione e percorso PDF, se mancanti.
try {
$userId = (int) (Auth::id() ?: 0);
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
Notification::make()->title('Completata')->success()->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
return;
}
$message = (string) ($result['message'] ?? 'Operazione non riuscita');
Notification::make()->title('Errore durante completa')->body($message)->danger()->send();
}),
Action::make('rigenera_pdf')
->label('Rigenera PDF (da XML)')
->icon('heroicon-o-arrow-path')
->visible(function (): bool {
$hasXml = (is_string($this->fattura->xml_content) && trim($this->fattura->xml_content) !== '')
|| (is_string($this->fattura->xml_path) && $this->fattura->xml_path !== '');
return $hasXml;
})
->requiresConfirmation()
->action(function (): void {
$userId = (int) (Auth::id() ?: 0);
if ($userId <= 0) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
$hasXml = (is_string($this->fattura->xml_content) && trim($this->fattura->xml_content) !== '')
|| (is_string($this->fattura->xml_path) && $this->fattura->xml_path !== '' && Storage::disk('local')->exists($this->fattura->xml_path));
if (! $hasXml) {
Notification::make()->title('XML non disponibile')->danger()->send();
return;
}
// Metti in quarantena l'eventuale PDF già agganciato (evita perdita dati / facilita diagnosi).
try {
$oldPath = is_string($this->fattura->allegato_pdf_path) ? trim($this->fattura->allegato_pdf_path) : '';
if ($oldPath !== '' && Storage::disk('local')->exists($oldPath)) {
$oldDir = trim((string) dirname($oldPath));
$quarantineDir = $oldDir . '/__OLD';
if (! Storage::disk('local')->exists($quarantineDir)) {
Storage::disk('local')->makeDirectory($quarantineDir);
}
$stamp = now()->format('Ymd_His');
$target = $quarantineDir . '/' . $stamp . '-' . basename($oldPath);
Storage::disk('local')->move($oldPath, $target);
}
} catch (\Throwable) {
// ignore
}
// Forza rigenerazione: svuota riferimenti e riprotocollo.
$this->fattura->allegato_pdf_path = null;
$this->fattura->allegato_pdf_nome = null;
$this->fattura->allegato_pdf_hash = null;
$this->fattura->save();
try {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
Notification::make()->title('PDF rigenerato')->success()->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
} catch (\Throwable $e) {
Notification::make()->title('Errore rigenerazione PDF')->body($e->getMessage())->danger()->send();
}
}),
Action::make('estrai_testo_pdf')
->label('Estrai testo PDF')
->icon('heroicon-o-document-magnifying-glass')
->visible(function (): bool {
if ($this->documento && is_string($this->documento->percorso_file) && $this->documento->percorso_file !== '') {
return true;
}
if (is_string($this->fattura->allegato_pdf_path) && $this->fattura->allegato_pdf_path !== '') {
return true;
}
return false;
})
->requiresConfirmation()
->action(function (): void {
$userId = (int) (Auth::id() ?: 0);
// Best-effort: assicurati che esista Documento e percorso PDF aggiornato.
try {
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
// Refresh documento (può essere stato creato ora).
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
// Fallback: se il Documento non esiste ma abbiamo un path PDF sulla FE, creiamo un Documento minimale.
if (! $this->documento) {
$pdfPath = is_string($this->fattura->allegato_pdf_path) ? trim($this->fattura->allegato_pdf_path) : '';
if ($pdfPath !== '' && Storage::disk('local')->exists($pdfPath)) {
$this->documento = Documento::query()->create([
'documentable_type' => FatturaElettronica::class,
'documentable_id' => (int) $this->fattura->id,
'stabile_id' => $this->fattura->stabile_id ? (int) $this->fattura->stabile_id : null,
'utente_id' => $userId > 0 ? $userId : null,
'tipologia' => 'fattura',
'nome' => 'Fattura elettronica #' . (int) $this->fattura->id,
'nome_file' => is_string($this->fattura->allegato_pdf_nome) && $this->fattura->allegato_pdf_nome !== ''
? $this->fattura->allegato_pdf_nome
: ('fattura-' . (int) $this->fattura->id . '.pdf'),
'mime_type' => 'application/pdf',
// legacy field (some parts of the app still rely on it)
'path_file' => $pdfPath,
// new field
'percorso_file' => $pdfPath,
'data_documento' => $this->fattura->data_fattura,
'data_scadenza' => $this->fattura->data_scadenza,
'importo_collegato' => $this->fattura->totale,
'fornitore' => is_string($this->fattura->fornitore_denominazione) ? $this->fattura->fornitore_denominazione : null,
]);
}
}
if (! $this->documento) {
Notification::make()->title('Documento non disponibile')->danger()->send();
return;
}
$result = app(PdfTextExtractionService::class)->extractAndStore($this->documento, false);
if (($result['status'] ?? null) === 'ok') {
$chars = (int) ($result['chars'] ?? 0);
$engine = (string) ($result['engine'] ?? '');
$body = 'Testo estratto (' . number_format($chars, 0, ',', '.') . ' caratteri)';
if ($engine !== '') {
$body .= ' via ' . $engine;
}
Notification::make()->title('Estrazione completata')->body($body)->success()->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
return;
}
$message = (string) ($result['message'] ?? 'Operazione non riuscita');
$status = (string) ($result['status'] ?? 'error');
if ($status === 'skipped') {
Notification::make()->title('Nessuna modifica')->body($message)->warning()->send();
return;
}
Notification::make()->title('Errore estrazione PDF')->body($message)->danger()->send();
}),
Action::make('estrai_dati_acqua')
->label('Estrai dati acqua')
->icon('heroicon-o-beaker')
->visible(function (): bool {
if ($this->documento && is_string($this->documento->percorso_file) && $this->documento->percorso_file !== '') {
return true;
}
if (is_string($this->fattura->allegato_pdf_path) && $this->fattura->allegato_pdf_path !== '') {
return true;
}
return false;
})
->form([
Select::make('voce_spesa_id')
->label('Voce di spesa (opzionale)')
->helperText('Se selezionata, i consumi/periodi verranno collegati alla voce (utile per statistiche e ripartizione).')
->options(function (): array {
if (! $this->fattura?->stabile_id || ! Schema::hasTable('voci_spesa')) {
return [];
}
return VoceSpesa::query()
->where('stabile_id', (int) $this->fattura->stabile_id)
->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(),
])
->action(function (array $data): void {
$userId = (int) (Auth::id() ?: 0);
// Assicura Documento + testo OCR disponibile.
try {
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
// Fallback: se il Documento non esiste ma abbiamo un path PDF sulla FE, creiamo un Documento minimale.
if (! $this->documento) {
$pdfPath = is_string($this->fattura->allegato_pdf_path) ? trim($this->fattura->allegato_pdf_path) : '';
if ($pdfPath !== '' && Storage::disk('local')->exists($pdfPath)) {
$this->documento = Documento::query()->create([
'documentable_type' => FatturaElettronica::class,
'documentable_id' => (int) $this->fattura->id,
'stabile_id' => $this->fattura->stabile_id ? (int) $this->fattura->stabile_id : null,
'utente_id' => $userId > 0 ? $userId : null,
'tipologia' => 'fattura',
'nome' => 'Fattura elettronica #' . (int) $this->fattura->id,
'nome_file' => is_string($this->fattura->allegato_pdf_nome) && $this->fattura->allegato_pdf_nome !== ''
? $this->fattura->allegato_pdf_nome
: ('fattura-' . (int) $this->fattura->id . '.pdf'),
'mime_type' => 'application/pdf',
// legacy field (some parts of the app still rely on it)
'path_file' => $pdfPath,
'percorso_file' => $pdfPath,
'data_documento' => $this->fattura->data_fattura,
'data_scadenza' => $this->fattura->data_scadenza,
'importo_collegato' => $this->fattura->totale,
'fornitore' => is_string($this->fattura->fornitore_denominazione) ? $this->fattura->fornitore_denominazione : null,
]);
}
}
if (! $this->documento) {
Notification::make()->title('Documento non disponibile')->danger()->send();
return;
}
$text = is_string($this->documento->contenuto_ocr) ? trim($this->documento->contenuto_ocr) : '';
if ($text === '') {
$result = app(PdfTextExtractionService::class)->extractAndStore($this->documento, false);
if (($result['status'] ?? null) !== 'ok') {
$message = (string) ($result['message'] ?? 'Testo PDF non disponibile');
Notification::make()->title('Impossibile estrarre testo PDF')->body($message)->danger()->send();
return;
}
$this->documento->refresh();
$text = is_string($this->documento->contenuto_ocr) ? trim($this->documento->contenuto_ocr) : '';
}
if ($text === '') {
Notification::make()->title('Testo PDF vuoto')->warning()->send();
return;
}
$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) {
Notification::make()->title('Dati acqua non trovati')->warning()->send();
return;
}
$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' => [
'tipologia_utenza' => $generale['tipologia_utenza'] ?? null,
'tariffa_applicata' => $generale['tariffa_applicata'] ?? null,
'numero_componenti_totali' => $generale['numero_componenti_totali'] ?? null,
'deposito_cauzionale_euro' => $generale['deposito_cauzionale_euro'] ?? null,
'numero_unita_abitative' => $generale['numero_unita_abitative'] ?? null,
'periodicita_fatturazione' => $generale['periodicita_fatturazione'] ?? null,
'numero_fattura_pdf' => $generale['numero_fattura_pdf'] ?? null,
'data_emissione_pdf' => $generale['data_emissione_pdf'] ?? null,
],
'finestra_autolettura' => [
'dal' => $finestra['dal'] ?? null,
'al' => $finestra['al'] ?? null,
'alert' => (bool) ($finestra['alert'] ?? false),
'messaggio' => $finestra['messaggio'] ?? null,
],
'riepilogo_letture' => array_values($letture),
'quadro_dettaglio' => [
'quota_fissa' => $quadro['quota_fissa'] ?? null,
'acquedotto' => $quadro['acquedotto'] ?? null,
'fognatura' => $quadro['fognatura'] ?? null,
'depurazione' => $quadro['depurazione'] ?? null,
'oneri_perequazione' => $quadro['oneri_perequazione'] ?? null,
'restituzione_acconti' => $quadro['restituzione_acconti'] ?? null,
],
'tariffe' => $tariffe,
'iva' => [
'codice' => $ivaInfo['codice'] ?? null,
'aliquota_percentuale' => $ivaInfo['aliquota_percentuale'] ?? null,
'descrizione' => $ivaInfo['descrizione'] ?? null,
],
'parsed_at' => now()->toISOString(),
];
// Compila anche i campi generici consumo_* (best-effort)
$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;
}
}
$this->fattura->consumo_unita = 'mc';
$this->fattura->consumo_valore = $totMc;
$this->fattura->consumo_riferimento = $ref;
$this->fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$this->fattura->save();
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest($this->fattura, $parsed, null);
} catch (\Throwable) {
// best-effort
}
// Persistenza strutturale: servizio + letture periodiche (uno-a-molti) per statistiche/ripartizione.
$voceSpesaId = isset($data['voce_spesa_id']) && is_numeric($data['voce_spesa_id']) ? (int) $data['voce_spesa_id'] : null;
$ing = app(ConsumiAcquaIngestionService::class)->ingest($this->fattura, $parsed, $voceSpesaId, $userId, true, null);
if (($ing['status'] ?? null) === 'no-data' && $hasTariffData) {
Notification::make()
->title('Tariffe acqua salvate')
->body('Nessuna lettura consumi trovata, ma il quadro tariffario e IVA sono stati archiviati.')
->success()
->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
return;
}
if (($ing['status'] ?? null) === 'skipped') {
Notification::make()
->title('Dati acqua salvati (solo su FE)')
->body((string) ($ing['message'] ?? 'Tabelle servizi/letture non presenti'))
->warning()
->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
return;
}
if (($ing['status'] ?? null) === 'mismatch') {
Notification::make()
->title('Attenzione: mismatch utenza/contatore')
->body((string) ($ing['message'] ?? 'Controllare il PDF: aperto un ticket per verifica.'))
->warning()
->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
return;
}
if (($ing['status'] ?? null) !== 'ok') {
Notification::make()
->title('Errore salvataggio letture')
->body((string) ($ing['message'] ?? 'Operazione non riuscita'))
->danger()
->send();
return;
}
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest(
$this->fattura,
$parsed,
isset($ing['servizio_id']) && is_numeric($ing['servizio_id']) ? (int) $ing['servizio_id'] : null
);
} catch (\Throwable) {
// best-effort: non bloccare il salvataggio letture
}
$body = 'Servizio: #' . (int) ($ing['servizio_id'] ?? 0);
$body .= ' · Letture create: ' . (int) ($ing['created_letture'] ?? 0);
$upd = (int) ($ing['updated_letture'] ?? 0);
if ($upd > 0) {
$body .= ' · aggiornate: ' . $upd;
}
Notification::make()->title('Dati acqua salvati')->body($body)->success()->send();
$this->redirect(self::getUrl(['record' => $this->fattura->id], panel: 'admin-filament'));
}),
Action::make('leggi_pdf_acqua_guidata')
->label('Leggi PDF acqua (guidata)')
->icon('heroicon-o-clipboard-document-list')
->color('warning')
->visible(function (): bool {
if ($this->documento && is_string($this->documento->percorso_file) && $this->documento->percorso_file !== '') {
return true;
}
if (is_string($this->fattura->allegato_pdf_path) && $this->fattura->allegato_pdf_path !== '') {
return true;
}
return false;
})
->action(function (): void {
if (! $this->isFatturaCompatibleWithCurrentStabileCf()) {
Notification::make()
->title('Filtro stabile non superato')
->body('La FE non risulta allineata al codice fiscale dello stabile attivo. Verifica il CF dello stabile e il destinatario FE.')
->danger()
->send();
return;
}
$userId = (int) (Auth::id() ?: 0);
try {
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($this->fattura, $userId);
$this->fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
$this->documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $this->fattura->id)
->first();
if (! $this->documento) {
Notification::make()->title('Documento non disponibile')->danger()->send();
return;
}
$text = is_string($this->documento->contenuto_ocr) ? trim($this->documento->contenuto_ocr) : '';
if ($text === '') {
$result = app(PdfTextExtractionService::class)->extractAndStore($this->documento, false);
if (($result['status'] ?? null) !== 'ok') {
$message = (string) ($result['message'] ?? 'Testo PDF non disponibile');
Notification::make()->title('Impossibile leggere PDF')->body($message)->danger()->send();
return;
}
$this->documento->refresh();
$text = is_string($this->documento->contenuto_ocr) ? trim($this->documento->contenuto_ocr) : '';
}
if ($text === '') {
Notification::make()->title('Testo PDF vuoto')->warning()->send();
return;
}
$parsed = app(AcquaPdfTextParser::class)->parse($text);
$orderedParsed = $this->orderAcquaParsedPayload(is_array($parsed) ? $parsed : []);
$maxChars = 250000;
$textPreview = mb_substr($text, 0, $maxChars);
$wasTruncated = mb_strlen($text) > $maxChars;
$this->acquaPdfPreview = [
'fattura_id' => (int) $this->fattura->id,
'stabile_id' => (int) ($this->fattura->stabile_id ?? 0),
'stabile_cf' => $this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale),
'destinatario_cf' => $this->normalizeMatchValue($this->fattura->destinatario_cf),
'chars' => mb_strlen($text),
'truncated' => $wasTruncated,
'raw_text' => $textPreview,
'parsed' => $orderedParsed,
'generated_at' => now()->format('d/m/Y H:i:s'),
];
Notification::make()
->title('Lettura PDF completata')
->body('Anteprima disponibile in fondo pagina. Indicami ora i campi da memorizzare.')
->success()
->send();
}),
Action::make('modifica')
->label('Modifica')
->icon('heroicon-o-pencil-square')
->form([
Select::make('fornitore_id')
->label('Fornitore (opzionale)')
->options(fn() => Fornitore::query()
->orderBy('ragione_sociale')
->orderBy('id')
->limit(500)
->get(['id', 'ragione_sociale', 'codice_fiscale', 'partita_iva'])
->mapWithKeys(function (Fornitore $f) {
$label = trim((string) ($f->ragione_sociale ?? ''));
if ($label === '') {
$cf = trim((string) ($f->codice_fiscale ?? ''));
$piva = trim((string) ($f->partita_iva ?? ''));
$label = $cf !== '' ? $cf : ($piva !== '' ? $piva : ('Fornitore #' . $f->id));
}
return [(string) $f->id => $label];
})
->all())
->searchable()
->nullable(),
DatePicker::make('data_scadenza')
->label('Scadenza')
->nullable(),
Select::make('stato')
->label('Stato')
->options([
'ricevuta' => 'Ricevuta',
'contabilizzata' => 'Contabilizzata',
'pagata' => 'Pagata',
'rifiutata' => 'Rifiutata',
])
->required(),
])
->fillForm(fn() => [
'fornitore_id' => $this->fattura->fornitore_id,
'data_scadenza' => $this->fattura->data_scadenza,
'stato' => $this->fattura->stato,
])
->action(function (array $data): void {
$this->fattura->fornitore_id = $data['fornitore_id'] ? (int) $data['fornitore_id'] : null;
$this->fattura->data_scadenza = $data['data_scadenza'] ?? null;
$this->fattura->stato = (string) $data['stato'];
$this->fattura->save();
Notification::make()->title('Fattura aggiornata')->success()->send();
}),
Action::make('apri_contabilita')
->label('Apri in contabilità')
->icon('heroicon-o-banknotes')
->visible(fn(): bool => ! empty($this->contabilitaFatturaId))
->url(fn() => \App\Filament\Pages\Contabilita\FatturaFornitoreScheda::getUrl([
'record' => $this->contabilitaFatturaId,
], panel: 'admin-filament')),
Action::make('apri_fornitore')
->label('Apri fornitore')
->icon('heroicon-o-arrow-top-right-on-square')
->visible(fn(): bool => (int) ($this->fattura->fornitore_id ?? 0) > 0)
->url(fn() => \App\Filament\Pages\Gescon\FornitoreScheda::getUrl([
'record' => (int) $this->fattura->fornitore_id,
], panel: 'admin-filament'), shouldOpenInNewTab: true),
Action::make('crea_fornitore')
->label('Crea fornitore')
->icon('heroicon-o-plus')
->visible(function (): bool {
$user = Auth::user();
if (! $user instanceof User) {
return false;
}
return (int) ($this->fattura->fornitore_id ?? 0) <= 0
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
})
->requiresConfirmation()
->action(function (): void {
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
if (! $this->fattura->stabile) {
Notification::make()->title('Stabile non disponibile')->danger()->send();
return;
}
$amministratoreId = (int) ($this->fattura->stabile->amministratore_id ?: 0);
if ($amministratoreId <= 0) {
Notification::make()->title('Amministratore non disponibile')->danger()->send();
return;
}
$fornitore = $this->ensureFornitoreFromFattura($amministratoreId, (int) $user->id);
if (! $fornitore) {
Notification::make()->title('Impossibile creare il fornitore')->danger()->send();
return;
}
$this->fattura->fornitore_id = (int) $fornitore->id;
$this->fattura->save();
Notification::make()->title('Fornitore creato')->success()->send();
$this->redirect(\App\Filament\Pages\Gescon\FornitoreScheda::getUrl([
'record' => (int) $fornitore->id,
], panel: 'admin-filament'));
}),
Action::make('torna')
->label('Indietro')
->icon('heroicon-o-arrow-left')
->url(fn() => $this->getBackUrl()),
];
}
private function ensureFornitoreFromFattura(int $amministratoreId, int $userId): ?Fornitore
{
$piva = is_string($this->fattura->fornitore_piva) ? trim($this->fattura->fornitore_piva) : null;
$cf = is_string($this->fattura->fornitore_cf) ? trim($this->fattura->fornitore_cf) : null;
$den = is_string($this->fattura->fornitore_denominazione) ? trim($this->fattura->fornitore_denominazione) : '';
if ($piva === '' || $piva === 'ND') {
$piva = null;
}
if ($cf === '') {
$cf = null;
}
if ($den === '') {
$den = $piva ?: ($cf ?: 'Fornitore');
}
$query = Fornitore::query()->where('amministratore_id', $amministratoreId);
if ($piva) {
$existing = (clone $query)->where('partita_iva', $piva)->first();
if ($existing) {
return $existing;
}
}
if ($cf) {
$existing = (clone $query)->where('codice_fiscale', $cf)->first();
if ($existing) {
return $existing;
}
}
$now = now();
$rubrica = null;
if ($piva) {
$rubrica = RubricaUniversale::query()->where('partita_iva', $piva)->first();
}
if (! $rubrica && $cf) {
$rubrica = RubricaUniversale::query()->where('codice_fiscale', $cf)->first();
}
if (! $rubrica) {
$rubrica = RubricaUniversale::query()
->where('tipo_contatto', 'persona_giuridica')
->where('categoria', 'fornitore')
->whereRaw('LOWER(ragione_sociale) = ?', [mb_strtolower($den)])
->first();
}
if (! $rubrica) {
$rubrica = RubricaUniversale::query()->create([
'ragione_sociale' => $den,
'tipo_contatto' => 'persona_giuridica',
'categoria' => 'fornitore',
'stato' => 'attivo',
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'data_inserimento' => $now,
'data_ultima_modifica' => $now,
'creato_da' => $userId > 0 ? $userId : null,
'modificato_da' => $userId > 0 ? $userId : null,
]);
}
return Fornitore::query()->create([
'amministratore_id' => $amministratoreId,
'rubrica_id' => (int) $rubrica->id,
'ragione_sociale' => $den,
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'iban' => is_string($this->fattura->pagamento_iban) ? $this->fattura->pagamento_iban : null,
'bic' => is_string($this->fattura->pagamento_bic) ? $this->fattura->pagamento_bic : null,
'modalita_pagamento_predefinita' => is_string($this->fattura->pagamento_modalita) ? $this->fattura->pagamento_modalita : null,
]);
}
private function needsCompleta(): bool
{
// Documento mancante
if (! $this->documento) {
return true;
}
// PDF non disponibile sullo storage
if (! is_string($this->fattura->allegato_pdf_path) || $this->fattura->allegato_pdf_path === '' || ! Storage::disk('local')->exists($this->fattura->allegato_pdf_path)) {
return true;
}
// XML su storage mancante (se abbiamo xml_path)
if (is_string($this->fattura->xml_path) && $this->fattura->xml_path !== '' && ! Storage::disk('local')->exists($this->fattura->xml_path)) {
return true;
}
return false;
}
private function isFatturaCompatibleWithCurrentStabileCf(): bool
{
$candidateIds = array_filter([
$this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale),
$this->normalizeMatchValue($this->fattura->stabile?->cod_fisc_amministratore),
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->codice_fiscale_studio),
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->partita_iva),
]);
$destIds = array_filter([
$this->normalizeMatchValue($this->fattura->destinatario_cf),
$this->normalizeMatchValue($this->fattura->destinatario_piva ?? null),
]);
if ($candidateIds === [] || $destIds === []) {
return false;
}
foreach ($destIds as $destId) {
if (in_array($destId, $candidateIds, true)) {
return true;
}
}
return false;
}
private function normalizeMatchValue(?string $value): string
{
return strtoupper(str_replace(' ', '', trim((string) $value)));
}
/** @param array<string,mixed> $parsed */
private function orderAcquaParsedPayload(array $parsed): array
{
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$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'] : [];
$iva = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
return [
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'generale' => [
'tipologia_utenza' => $generale['tipologia_utenza'] ?? null,
'tariffa_applicata' => $generale['tariffa_applicata'] ?? null,
'numero_componenti_totali' => $generale['numero_componenti_totali'] ?? null,
'deposito_cauzionale_euro' => $generale['deposito_cauzionale_euro'] ?? null,
'numero_unita_abitative' => $generale['numero_unita_abitative'] ?? null,
'periodicita_fatturazione' => $generale['periodicita_fatturazione'] ?? null,
'numero_fattura_pdf' => $generale['numero_fattura_pdf'] ?? null,
'data_emissione_pdf' => $generale['data_emissione_pdf'] ?? null,
],
'finestra_autolettura' => [
'dal' => $finestra['dal'] ?? null,
'al' => $finestra['al'] ?? null,
'alert' => (bool) ($finestra['alert'] ?? false),
'messaggio' => $finestra['messaggio'] ?? null,
],
'riepilogo_letture' => array_values($letture),
'quadro_dettaglio' => [
'quota_fissa' => $quadro['quota_fissa'] ?? null,
'acquedotto' => $quadro['acquedotto'] ?? null,
'fognatura' => $quadro['fognatura'] ?? null,
'depurazione' => $quadro['depurazione'] ?? null,
'oneri_perequazione' => $quadro['oneri_perequazione'] ?? null,
'restituzione_acconti' => $quadro['restituzione_acconti'] ?? null,
],
'tariffe' => $tariffe,
'iva' => [
'codice' => $iva['codice'] ?? null,
'aliquota_percentuale' => $iva['aliquota_percentuale'] ?? null,
'descrizione' => $iva['descrizione'] ?? null,
],
'consumi' => array_values($consumi),
];
}
}