1601 lines
75 KiB
PHP
1601 lines
75 KiB
PHP
<?php
|
||
namespace App\Filament\Pages\Contabilita;
|
||
|
||
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
|
||
use App\Jobs\RunCassettoFiscaleDownload;
|
||
use App\Models\FatturaElettronica;
|
||
use App\Models\Stabile;
|
||
use App\Models\User;
|
||
use App\Services\FattureElettroniche\CassettoFiscaleDownloadService;
|
||
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
|
||
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
|
||
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
|
||
use App\Services\FattureElettroniche\P7mExtractor;
|
||
use App\Support\ArchivioPaths;
|
||
use App\Support\StabileContext;
|
||
use BackedEnum;
|
||
use Filament\Actions\Action;
|
||
use Filament\Forms\Components\FileUpload;
|
||
use Filament\Forms\Components\Placeholder;
|
||
use Filament\Forms\Components\Select;
|
||
use Filament\Forms\Components\TextInput;
|
||
use Filament\Forms\Components\Toggle;
|
||
use Filament\Notifications\Notification;
|
||
use Filament\Pages\Page;
|
||
use Filament\Tables\Columns\BadgeColumn;
|
||
use Filament\Tables\Columns\TextColumn;
|
||
use Filament\Tables\Concerns\InteractsWithTable;
|
||
use Filament\Tables\Contracts\HasTable;
|
||
use Filament\Tables\Filters\SelectFilter;
|
||
use Filament\Tables\Table;
|
||
use Illuminate\Contracts\Pagination\Paginator;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Support\Facades\Artisan;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use UnitEnum;
|
||
use ZipArchive;
|
||
|
||
class FattureElettronicheP7mRicevute extends Page implements HasTable
|
||
{
|
||
use InteractsWithTable;
|
||
|
||
public array $quarterDownloads = [];
|
||
|
||
public function getActiveStabileLabelProperty(): string
|
||
{
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return 'Stabile non selezionato';
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
return 'Stabile non selezionato';
|
||
}
|
||
|
||
$parts = array_values(array_filter([
|
||
trim((string) ($stabile->cod_stabile ?? '')),
|
||
trim((string) ($stabile->codice_stabile ?? '')),
|
||
trim((string) ($stabile->denominazione ?? '')),
|
||
]));
|
||
|
||
return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile->id);
|
||
}
|
||
|
||
private function safeUtf8(string $value): string
|
||
{
|
||
$value = preg_replace('/^\xEF\xBB\xBF/', '', $value) ?? $value;
|
||
|
||
if (function_exists('mb_check_encoding') && ! mb_check_encoding($value, 'UTF-8')) {
|
||
if (function_exists('mb_convert_encoding')) {
|
||
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8,ISO-8859-1,Windows-1252');
|
||
}
|
||
}
|
||
|
||
$fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
|
||
if (is_string($fixed) && $fixed !== '') {
|
||
$value = $fixed;
|
||
}
|
||
|
||
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value) ?? $value;
|
||
}
|
||
|
||
public int $cassettoAnno;
|
||
|
||
/**
|
||
* Ultimi scarichi da Cassetto Fiscale per lo stabile attivo.
|
||
*/
|
||
public function getCassettoDownloadLogs(int $limit = 10): \Illuminate\Support\Collection
|
||
{
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return collect();
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
return collect();
|
||
}
|
||
|
||
$amministratore = $stabile->amministratore;
|
||
if (! $amministratore) {
|
||
return collect();
|
||
}
|
||
|
||
return DB::table('fe_cassetto_download_logs')
|
||
->where('stabile_id', (int) $stabile->id)
|
||
->where('amministratore_id', (int) $amministratore->id)
|
||
->orderByDesc('created_at')
|
||
->limit(max(1, min(50, $limit)))
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Stato trimestri per un anno (Q1..Q4) per lo stabile attivo.
|
||
* @return array<int, array{quarter: string, dal: string, al: string, stato: string, log_id: int|null, files_total: int, imported: int, duplicates: int, errors: int, created_at: string|null}>
|
||
*/
|
||
public function getCassettoQuarterStatus(int $anno): array
|
||
{
|
||
$anno = max(2000, min(2100, (int) $anno));
|
||
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return [];
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
return [];
|
||
}
|
||
|
||
$amministratore = $stabile->amministratore;
|
||
if (! $amministratore) {
|
||
return [];
|
||
}
|
||
|
||
$ranges = [
|
||
'Q1' => [$anno . '-01-01', $anno . '-03-31'],
|
||
'Q2' => [$anno . '-04-01', $anno . '-06-30'],
|
||
'Q3' => [$anno . '-07-01', $anno . '-09-30'],
|
||
'Q4' => [$anno . '-10-01', $anno . '-12-31'],
|
||
];
|
||
|
||
$out = [];
|
||
|
||
foreach ($ranges as $q => [$dal, $al]) {
|
||
$log = DB::table('fe_cassetto_download_logs')
|
||
->where('amministratore_id', (int) $amministratore->id)
|
||
->where('stabile_id', (int) $stabile->id)
|
||
->whereDate('dal', $dal)
|
||
->whereDate('al', $al)
|
||
->orderByDesc('id')
|
||
->first();
|
||
|
||
$stato = 'MAI';
|
||
$filesTotal = 0;
|
||
$imported = 0;
|
||
$duplicates = 0;
|
||
$errors = 0;
|
||
$createdAt = null;
|
||
$logId = null;
|
||
|
||
if ($log) {
|
||
$logId = is_numeric($log->id ?? null) ? (int) $log->id : null;
|
||
$filesTotal = (int) ($log->files_total ?? 0);
|
||
$imported = (int) ($log->imported ?? 0);
|
||
$duplicates = (int) ($log->duplicates ?? 0);
|
||
$errors = (int) ($log->errors ?? 0);
|
||
$createdAt = is_string($log->created_at ?? null) ? (string) $log->created_at : null;
|
||
|
||
$status = strtolower((string) ($log->status ?? ''));
|
||
|
||
$isComplete = (
|
||
$status === 'ok'
|
||
&& $errors === 0
|
||
&& $filesTotal > 0
|
||
&& ($imported + $duplicates) >= $filesTotal
|
||
);
|
||
|
||
if ($isComplete) {
|
||
$stato = 'COMPLETO';
|
||
} elseif (in_array($status, ['started', 'running', 'in_progress'], true)) {
|
||
$stato = 'IN CORSO';
|
||
} elseif ($errors > 0 || $status === 'error' || $status === 'failed') {
|
||
$stato = 'ERRORE';
|
||
} else {
|
||
$stato = 'PARZIALE';
|
||
}
|
||
}
|
||
|
||
$out[] = [
|
||
'quarter' => $q,
|
||
'dal' => $dal,
|
||
'al' => $al,
|
||
'stato' => $stato,
|
||
'log_id' => $logId,
|
||
'files_total' => $filesTotal,
|
||
'imported' => $imported,
|
||
'duplicates' => $duplicates,
|
||
'errors' => $errors,
|
||
'created_at' => $createdAt,
|
||
];
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
public function downloadCassettoQuarter(string $quarter, string $dal, string $al, int $anno): void
|
||
{
|
||
$key = $quarter . '-' . $anno;
|
||
if (! empty($this->quarterDownloads[$key])) {
|
||
return;
|
||
}
|
||
$this->quarterDownloads[$key] = time();
|
||
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
$this->quarterDownloads[$key] = false;
|
||
return;
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
Notification::make()->title('Seleziona uno stabile')->danger()->send();
|
||
$this->quarterDownloads[$key] = false;
|
||
return;
|
||
}
|
||
|
||
$amministratoreId = (int) ($stabile->amministratore_id ?? 0);
|
||
if ($amministratoreId <= 0) {
|
||
Notification::make()->title('Amministratore non valido')->danger()->send();
|
||
$this->quarterDownloads[$key] = false;
|
||
return;
|
||
}
|
||
|
||
if ($this->isCassettoQuarterCompleteFor($amministratoreId, (int) $stabile->id, $dal, $al)) {
|
||
Notification::make()
|
||
->title('Trimestre già completo')
|
||
->body($this->safeUtf8("{$quarter} {$anno} risulta già scaricato (COMPLETO)."))
|
||
->warning()
|
||
->send();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$job = new RunCassettoFiscaleDownload(
|
||
$amministratoreId,
|
||
(int) $stabile->id,
|
||
$dal,
|
||
$al,
|
||
false,
|
||
(int) $user->id,
|
||
[
|
||
'force_update' => false,
|
||
'importa_righe' => 'auto',
|
||
'create_fornitore_if_missing' => true,
|
||
],
|
||
);
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
Notification::make()->title('Errore')->body($this->safeUtf8($e->getMessage()))->danger()->send();
|
||
return;
|
||
}
|
||
|
||
// Eseguiamo dopo la risposta HTTP per non dipendere da queue worker attivo.
|
||
app()->terminating(function () use ($job): void {
|
||
try {
|
||
$job->handle(app(CassettoFiscaleDownloadService::class));
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
});
|
||
|
||
Notification::make()
|
||
->title('Scarico avviato')
|
||
->body($this->safeUtf8("Trimestre {$quarter} {$anno} avviato. Aggiorna tra poco la tab scarichi per vedere il log."))
|
||
->success()
|
||
->send();
|
||
}
|
||
|
||
private function isCassettoQuarterCompleteFor(int $amministratoreId, int $stabileId, string $dalYmd, string $alYmd): bool
|
||
{
|
||
$row = DB::table('fe_cassetto_download_logs')
|
||
->where('amministratore_id', $amministratoreId)
|
||
->where('stabile_id', $stabileId)
|
||
->whereDate('dal', $dalYmd)
|
||
->whereDate('al', $alYmd)
|
||
->where('status', 'ok')
|
||
->orderByDesc('id')
|
||
->first();
|
||
|
||
if (! $row) {
|
||
return false;
|
||
}
|
||
|
||
$filesTotal = (int) ($row->files_total ?? 0);
|
||
$imported = (int) ($row->imported ?? 0);
|
||
$duplicates = (int) ($row->duplicates ?? 0);
|
||
$errors = (int) ($row->errors ?? 0);
|
||
|
||
return $errors === 0 && $filesTotal > 0 && ($imported + $duplicates) >= $filesTotal;
|
||
}
|
||
|
||
public function getArchivioRigheVisteCount(): int
|
||
{
|
||
$records = $this->getTableRecords();
|
||
|
||
if ($records instanceof LengthAwarePaginator) {
|
||
return (int) $records->total();
|
||
}
|
||
|
||
if ($records instanceof Paginator) {
|
||
return (int) $records->count();
|
||
}
|
||
|
||
return is_countable($records) ? count($records) : 0;
|
||
}
|
||
|
||
protected static ?string $navigationLabel = 'Fatture ricevute';
|
||
|
||
protected static ?string $title = 'Fatture ricevute';
|
||
|
||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
|
||
|
||
protected static UnitEnum|string|null $navigationGroup = 'Contabilità';
|
||
|
||
protected static ?int $navigationSort = 35;
|
||
|
||
protected static ?string $slug = 'contabilita/fatture-ricevute';
|
||
|
||
protected string $view = 'filament.pages.contabilita.fatture-elettroniche-p7m-ricevute';
|
||
|
||
public static function canAccess(): bool
|
||
{
|
||
$user = Auth::user();
|
||
|
||
if (! $user instanceof User) {
|
||
return false;
|
||
}
|
||
|
||
if ($user->hasAnyRole(['super-admin', 'admin'])) {
|
||
return true;
|
||
}
|
||
|
||
return $user->can('contabilita.fatture-elettroniche');
|
||
}
|
||
|
||
public function mount(): void
|
||
{
|
||
$this->cassettoAnno = (int) now()->format('Y');
|
||
$this->mountInteractsWithTable();
|
||
}
|
||
|
||
protected function getHeaderActions(): array
|
||
{
|
||
return [
|
||
Action::make('rigenera_pdf_da_xml')
|
||
->label('Rigenera PDF da XML')
|
||
->icon('heroicon-o-document-arrow-up')
|
||
->visible(function (): bool {
|
||
$u = Auth::user();
|
||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin']);
|
||
})
|
||
->form([
|
||
Toggle::make('solo_mancanti')
|
||
->label('Solo fatture senza PDF')
|
||
->default(true),
|
||
TextInput::make('limit')
|
||
->label('Max fatture')
|
||
->numeric()
|
||
->default(200)
|
||
->required(),
|
||
])
|
||
->action(function (array $data): void {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
Notification::make()->title('Seleziona uno stabile')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$limit = (int) ($data['limit'] ?? 200);
|
||
$limit = max(1, min(2000, $limit));
|
||
$soloMancanti = (bool) ($data['solo_mancanti'] ?? true);
|
||
|
||
$q = FatturaElettronica::query()
|
||
->where('stabile_id', (int) $stabile->id)
|
||
->where(function (Builder $qq) {
|
||
$qq
|
||
->where(function (Builder $q2) {
|
||
$q2->whereNotNull('xml_content')->where('xml_content', '!=', '');
|
||
})
|
||
->orWhere(function (Builder $q2) {
|
||
$q2->whereNotNull('xml_path')->where('xml_path', '!=', '');
|
||
});
|
||
});
|
||
|
||
if ($soloMancanti) {
|
||
$q->where(function (Builder $qq) {
|
||
$qq
|
||
->whereNull('allegato_pdf_path')
|
||
->orWhere('allegato_pdf_path', '')
|
||
->orWhereNull('allegato_pdf_hash')
|
||
->orWhere('allegato_pdf_hash', '');
|
||
});
|
||
} else {
|
||
// Include anche i PDF "generati" (non allegati originali), per rigenerazione con XSL Assosoftware.
|
||
$q->where(function (Builder $qq) {
|
||
$qq->whereNull('allegato_pdf_hash')->orWhere('allegato_pdf_hash', '');
|
||
});
|
||
}
|
||
|
||
$rows = $q
|
||
->orderBy('id')
|
||
->limit($limit)
|
||
->get(['id', 'allegato_pdf_path', 'allegato_pdf_hash', 'xml_path', 'xml_content', 'stabile_id']);
|
||
|
||
if ($rows->isEmpty()) {
|
||
Notification::make()->title('Nessuna fattura da rigenerare')->warning()->send();
|
||
return;
|
||
}
|
||
|
||
$svc = app(FatturaElettronicaProtocolloService::class);
|
||
|
||
$ok = 0;
|
||
$skipped = 0;
|
||
$errors = 0;
|
||
$errorLines = [];
|
||
|
||
foreach ($rows as $fattura) {
|
||
try {
|
||
$hasOriginalAttachment = is_string($fattura->allegato_pdf_hash ?? null)
|
||
&& trim((string) $fattura->allegato_pdf_hash) !== '';
|
||
|
||
if ($hasOriginalAttachment) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$pdfPath = is_string($fattura->allegato_pdf_path ?? null) ? trim((string) $fattura->allegato_pdf_path) : '';
|
||
if ($pdfPath !== '' && Storage::disk('local')->exists($pdfPath)) {
|
||
$oldDir = trim((string) (dirname($pdfPath))) . '/__OLD';
|
||
$oldPath = $oldDir . '/' . now()->format('Ymd-His') . '-' . basename($pdfPath);
|
||
try {
|
||
Storage::disk('local')->makeDirectory($oldDir);
|
||
Storage::disk('local')->move($pdfPath, $oldPath);
|
||
} catch (\Throwable) {
|
||
// ignore: se non riesce a spostare, rigeneriamo comunque (il service eviterà overwrite)
|
||
}
|
||
}
|
||
|
||
$fattura->allegato_pdf_path = null;
|
||
$fattura->allegato_pdf_nome = null;
|
||
$fattura->save();
|
||
|
||
$svc->ensureDocumentoProtocollato($fattura, (int) $user->id);
|
||
$ok++;
|
||
} catch (\Throwable $e) {
|
||
$errors++;
|
||
if (count($errorLines) < 10) {
|
||
$errorLines[] = $this->safeUtf8('#' . (int) $fattura->id . ' — ' . $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
$body = 'Selezionate: ' . (int) $rows->count();
|
||
$body .= ' · Rigenerate: ' . (int) $ok;
|
||
$body .= ' · Saltate (PDF allegato): ' . (int) $skipped;
|
||
$body .= ' · Errori: ' . (int) $errors;
|
||
|
||
$n = Notification::make()->title('Rigenerazione completata');
|
||
if ($errors > 0) {
|
||
$n->body($this->safeUtf8($body . "\n" . implode("\n", $errorLines)))->warning()->send();
|
||
} else {
|
||
$n->body($this->safeUtf8($body))->success()->send();
|
||
}
|
||
}),
|
||
|
||
Action::make('importa_zip_cassetto')
|
||
->label('Importa ZIP Cassetto')
|
||
->icon('heroicon-o-arrow-up-tray')
|
||
->visible(function (): bool {
|
||
$u = Auth::user();
|
||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
||
})
|
||
->form([
|
||
FileUpload::make('zip_file')
|
||
->label('ZIP del Cassetto (già scaricato)')
|
||
->directory(function (): string {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return 'tmp/cassetto-zip';
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile) {
|
||
return 'tmp/cassetto-zip';
|
||
}
|
||
$adminCode = $stabile?->amministratore?->codice_amministratore;
|
||
$adminId = (int) ($stabile?->amministratore_id ?: 0);
|
||
$adminFolder = $adminCode ?: ('ID-' . $adminId);
|
||
|
||
return 'amministratori/' . $adminFolder . '/temp/upload/cassetto-zip';
|
||
})
|
||
->disk('local')
|
||
->acceptedFileTypes(['application/zip', 'application/x-zip-compressed'])
|
||
->required(),
|
||
Toggle::make('force_update')
|
||
->label('Reimport / completa anche se già presente')
|
||
->default(false),
|
||
Toggle::make('no_skip')
|
||
->label('Non saltare se già importato (no_skip)')
|
||
->default(false),
|
||
Toggle::make('metadati')
|
||
->label('Considera anche metadati')
|
||
->default(false),
|
||
Select::make('importa_righe')
|
||
->label('Import righe')
|
||
->options([
|
||
'auto' => 'Auto (segue impostazione fornitore)',
|
||
'si' => 'Sì (forza import righe)',
|
||
'no' => 'No (non importare righe)',
|
||
])
|
||
->default('auto')
|
||
->required(),
|
||
Toggle::make('create_fornitore_if_missing')
|
||
->label('Crea fornitore se non esiste')
|
||
->default(true),
|
||
])
|
||
->action(function (array $data): void {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
Notification::make()->title('Seleziona uno stabile')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$zipPath = (string) ($data['zip_file'] ?? '');
|
||
if ($zipPath === '') {
|
||
Notification::make()->title('ZIP mancante')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$absolute = Storage::disk('local')->path($zipPath);
|
||
if (! is_file($absolute)) {
|
||
Notification::make()->title('ZIP non trovato')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$exit = Artisan::call('fe:cassetto-import-local', [
|
||
'stabile_id' => (int) $stabile->id,
|
||
'path' => $absolute,
|
||
'--user_id' => (int) $user->id,
|
||
'--metadati' => (bool) ($data['metadati'] ?? false) ? 1 : 0,
|
||
'--force_update' => (bool) ($data['force_update'] ?? false) ? 1 : 0,
|
||
'--no_skip' => (bool) ($data['no_skip'] ?? false) ? 1 : 0,
|
||
'--importa_righe' => (string) ($data['importa_righe'] ?? 'auto'),
|
||
'--create_fornitore_if_missing' => (bool) ($data['create_fornitore_if_missing'] ?? true) ? 1 : 0,
|
||
]);
|
||
|
||
$out = trim((string) Artisan::output());
|
||
$lines = $out === '' ? [] : preg_split('/\r\n|\r|\n/', $out);
|
||
$tail = $lines ? implode("\n", array_slice($lines, -10)) : '';
|
||
|
||
if ($exit !== 0) {
|
||
Notification::make()
|
||
->title('Import ZIP fallito')
|
||
->body($this->safeUtf8($tail !== '' ? $tail : 'Comando terminato con errore'))
|
||
->danger()
|
||
->send();
|
||
return;
|
||
}
|
||
|
||
Notification::make()
|
||
->title('Import ZIP completato')
|
||
->body($this->safeUtf8($tail !== '' ? $tail : 'Controlla la tab “Scarichi” per log/stato trimestri.'))
|
||
->success()
|
||
->send();
|
||
}),
|
||
|
||
Action::make('scarica_cassetto')
|
||
->label('Scarica da Cassetto Fiscale')
|
||
->icon('heroicon-o-arrow-down-tray')
|
||
->visible(function (): bool {
|
||
$u = Auth::user();
|
||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
||
})
|
||
->form([
|
||
Placeholder::make('stabile_attivo_info')
|
||
->label('Stabile di destinazione')
|
||
->content(fn(): string => $this->getActiveStabileLabelProperty()),
|
||
|
||
Select::make('anno')
|
||
->label('Anno')
|
||
->options(function (): array {
|
||
$y = (int) now()->format('Y');
|
||
$out = [];
|
||
for ($i = 0; $i < 10; $i++) {
|
||
$out[(string) ($y - $i)] = (string) ($y - $i);
|
||
}
|
||
return $out;
|
||
})
|
||
->default(fn() => now()->format('Y'))
|
||
->reactive(),
|
||
|
||
Select::make('periodo')
|
||
->label('Periodo suggerito')
|
||
->options([
|
||
'Q1' => '01/01 - 31/03',
|
||
'Q2' => '01/04 - 30/06',
|
||
'Q3' => '01/07 - 30/09',
|
||
'Q4' => '01/10 - 31/12',
|
||
'M_CUR' => 'Mese corrente',
|
||
'D1' => 'Solo oggi (1 giorno)',
|
||
'CUSTOM' => 'Personalizzato (manuale)',
|
||
])
|
||
->default('Q1')
|
||
->reactive()
|
||
->afterStateUpdated(function ($state, callable $set, callable $get): void {
|
||
$anno = (string) ($get('anno') ?? now()->format('Y'));
|
||
$q = (string) ($state ?? 'Q1');
|
||
[$dal, $al] = match ($q) {
|
||
'Q2' => ['01-04-' . $anno, '30-06-' . $anno],
|
||
'Q3' => ['01-07-' . $anno, '30-09-' . $anno],
|
||
'Q4' => ['01-10-' . $anno, '31-12-' . $anno],
|
||
'M_CUR' => [now()->startOfMonth()->format('d-m-Y'), now()->endOfMonth()->format('d-m-Y')],
|
||
'D1' => [now()->format('d-m-Y'), now()->format('d-m-Y')],
|
||
'CUSTOM' => [
|
||
(string) ($get('dal') ?? now()->format('d-m-Y')),
|
||
(string) ($get('al') ?? now()->format('d-m-Y')),
|
||
],
|
||
default => ['01-01-' . $anno, '31-03-' . $anno],
|
||
};
|
||
$set('dal', $dal);
|
||
$set('al', $al);
|
||
}),
|
||
|
||
TextInput::make('dal')
|
||
->label('Dal (gg-mm-aaaa)')
|
||
->default(fn(callable $get) => '01-01-' . (string) ($get('anno') ?? now()->format('Y')))
|
||
->helperText('Puoi inserire anche un solo giorno (dal = al).')
|
||
->required(),
|
||
TextInput::make('al')
|
||
->label('Al (gg-mm-aaaa)')
|
||
->default(fn(callable $get) => '31-03-' . (string) ($get('anno') ?? now()->format('Y')))
|
||
->helperText('Intervallo massimo 92 giorni.')
|
||
->required(),
|
||
Toggle::make('metadati')
|
||
->label('Scarica anche metadati')
|
||
->default(false),
|
||
Toggle::make('force_update')
|
||
->label('Reimport / completa anche se già presente')
|
||
->default(false),
|
||
Select::make('importa_righe')
|
||
->label('Import righe')
|
||
->options([
|
||
'auto' => 'Auto (segue impostazione fornitore)',
|
||
'si' => 'Sì (forza import righe)',
|
||
'no' => 'No (non importare righe)',
|
||
])
|
||
->default('auto')
|
||
->required(),
|
||
Toggle::make('create_fornitore_if_missing')
|
||
->label('Crea fornitore se non esiste')
|
||
->default(true),
|
||
])
|
||
->action(function (array $data): void {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile instanceof Stabile) {
|
||
Notification::make()->title('Seleziona uno stabile')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$amministratore = $stabile->amministratore;
|
||
if (! $amministratore) {
|
||
Notification::make()->title('Amministratore non trovato')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$dalStr = (string) ($data['dal'] ?? '');
|
||
$alStr = (string) ($data['al'] ?? '');
|
||
$dal = \DateTimeImmutable::createFromFormat('d-m-Y', $dalStr) ?: null;
|
||
$al = \DateTimeImmutable::createFromFormat('d-m-Y', $alStr) ?: null;
|
||
if (! $dal || ! $al) {
|
||
Notification::make()->title('Date non valide')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$forceUpdate = (bool) ($data['force_update'] ?? false);
|
||
|
||
// Blocco trimestri: se il trimestre è già completo, evita ri-scarico (salvo force_update).
|
||
$periodo = (string) ($data['periodo'] ?? '');
|
||
$anno = is_numeric($data['anno'] ?? null) ? (int) $data['anno'] : (int) $dal->format('Y');
|
||
if (! $forceUpdate && in_array($periodo, ['Q1', 'Q2', 'Q3', 'Q4'], true)) {
|
||
[$expectedDal, $expectedAl] = match ($periodo) {
|
||
'Q2' => [$anno . '-04-01', $anno . '-06-30'],
|
||
'Q3' => [$anno . '-07-01', $anno . '-09-30'],
|
||
'Q4' => [$anno . '-10-01', $anno . '-12-31'],
|
||
default => [$anno . '-01-01', $anno . '-03-31'],
|
||
};
|
||
|
||
if ($dal->format('Y-m-d') === $expectedDal && $al->format('Y-m-d') === $expectedAl) {
|
||
if ($this->isCassettoQuarterCompleteFor((int) $amministratore->id, (int) $stabile->id, $expectedDal, $expectedAl)) {
|
||
Notification::make()
|
||
->title('Trimestre già completo')
|
||
->body('Per questo trimestre risulta uno scarico OK completo. Per forzare, abilita “Reimport / completa anche se già presente”.')
|
||
->warning()
|
||
->send();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Anti-duplicati veloce: se esiste già un OK per lo stesso periodo, non avviamo niente.
|
||
if (! $forceUpdate) {
|
||
$cf = trim((string) ($stabile->codice_fiscale ?? ''));
|
||
if ($cf === '') {
|
||
try {
|
||
$stabile->loadMissing('rubrica');
|
||
$cf = trim((string) ($stabile->rubrica?->codice_fiscale ?? ''));
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
}
|
||
$cf = strtoupper(trim($cf));
|
||
|
||
if ($cf !== '') {
|
||
$requestHash = hash('sha256', implode('|', [
|
||
'cf=' . $cf,
|
||
'dal=' . $dal->format('d-m-Y'),
|
||
'al=' . $al->format('d-m-Y'),
|
||
'metadati=' . (((bool) ($data['metadati'] ?? false)) ? '1' : '0'),
|
||
'stabile=' . (int) $stabile->id,
|
||
'amm=' . (int) $amministratore->id,
|
||
]));
|
||
|
||
$existingOk = DB::table('fe_cassetto_download_logs')
|
||
->where('amministratore_id', (int) $amministratore->id)
|
||
->where('stabile_id', (int) $stabile->id)
|
||
->where('request_hash', $requestHash)
|
||
->where('status', 'ok')
|
||
->orderByDesc('id')
|
||
->first();
|
||
|
||
if ($existingOk) {
|
||
Notification::make()
|
||
->title('Già scaricato')
|
||
->body('Esiste già uno scarico OK per questo periodo (log #' . (int) $existingOk->id . ').')
|
||
->warning()
|
||
->send();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Max 3 mesi (circa 92 giorni) per richiesta
|
||
$diffDays = (int) $dal->diff($al)->format('%r%a');
|
||
if ($diffDays < 0) {
|
||
Notification::make()->title('Intervallo date non valido')->danger()->send();
|
||
return;
|
||
}
|
||
if ($diffDays > 92) {
|
||
Notification::make()->title('Intervallo troppo ampio (max 3 mesi)')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
// Esecuzione "in background" senza dipendere da un queue worker:
|
||
// registriamo un callback di terminazione che parte dopo l'invio della risposta HTTP.
|
||
app()->terminating(function () use ($amministratore, $stabile, $dal, $al, $data, $user): void {
|
||
try {
|
||
$job = new RunCassettoFiscaleDownload(
|
||
(int) $amministratore->id,
|
||
(int) $stabile->id,
|
||
$dal->format('Y-m-d'),
|
||
$al->format('Y-m-d'),
|
||
(bool) ($data['metadati'] ?? false),
|
||
(int) $user->id,
|
||
[
|
||
'force_update' => (bool) ($data['force_update'] ?? false),
|
||
'importa_righe' => (string) ($data['importa_righe'] ?? 'auto'),
|
||
'create_fornitore_if_missing' => (bool) ($data['create_fornitore_if_missing'] ?? true),
|
||
],
|
||
);
|
||
|
||
$job->handle(app(CassettoFiscaleDownloadService::class));
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
});
|
||
|
||
Notification::make()
|
||
->title('Scarico avviato')
|
||
->body("Operazione avviata in background. Se già scaricato, verrà saltato automaticamente.\nAggiorna tra poco e controlla la sezione 'Ultimi scarichi'.")
|
||
->success()
|
||
->send();
|
||
}),
|
||
|
||
Action::make('importa_fe')
|
||
->label('Importa FE')
|
||
->icon('heroicon-o-arrow-up-tray')
|
||
->form([
|
||
FileUpload::make('fe_files')
|
||
->label('File FE (XML/P7M/ZIP)')
|
||
->directory(function (): string {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return 'tmp/fatture-fe';
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile) {
|
||
return 'tmp/fatture-fe';
|
||
}
|
||
$adminCode = $stabile?->amministratore?->codice_amministratore;
|
||
$adminId = (int) ($stabile?->amministratore_id ?: 0);
|
||
$adminFolder = $adminCode ?: ('ID-' . $adminId);
|
||
|
||
return 'amministratori/' . $adminFolder . '/temp/upload/fatture-fe';
|
||
})
|
||
->disk('local')
|
||
->acceptedFileTypes([
|
||
'.p7m',
|
||
'.xml',
|
||
'.zip',
|
||
'application/xml',
|
||
'text/xml',
|
||
'application/pkcs7-mime',
|
||
'application/x-pkcs7-mime',
|
||
'application/pkcs7-signature',
|
||
'application/x-pkcs7-signature',
|
||
'application/octet-stream',
|
||
'application/zip',
|
||
'application/x-zip-compressed',
|
||
])
|
||
->multiple()
|
||
->required(),
|
||
|
||
Select::make('stabile_forzato_id')
|
||
->label('Destinazione stabile (solo super-admin)')
|
||
->helperText('Se valorizzato, ignora lo smistamento automatico (CF/SDI/PEC).')
|
||
->options(function () {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return [];
|
||
}
|
||
|
||
return StabileContext::accessibleStabili($user)
|
||
->mapWithKeys(function (Stabile $s) {
|
||
$adminCode = (string) ($s->amministratore?->codice_amministratore ?: $s->amministratore?->codice_univoco ?: '');
|
||
$prefix = $adminCode !== '' ? ($adminCode . ' — ') : '';
|
||
return [(string) $s->id => $prefix . $s->codice_stabile . ' - ' . $s->denominazione];
|
||
})
|
||
->all();
|
||
})
|
||
->searchable()
|
||
->nullable()
|
||
->visible(function (): bool {
|
||
$u = Auth::user();
|
||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin']);
|
||
}),
|
||
|
||
Toggle::make('force_update')
|
||
->label('Reimport / completa anche se già presente')
|
||
->default(false),
|
||
|
||
Select::make('importa_righe')
|
||
->label('Import righe')
|
||
->options([
|
||
'auto' => 'Auto (segue impostazione fornitore)',
|
||
'si' => 'Sì (forza import righe)',
|
||
'no' => 'No (non importare righe)',
|
||
])
|
||
->default('auto')
|
||
->required(),
|
||
|
||
Toggle::make('create_fornitore_if_missing')
|
||
->label('Crea fornitore se non esiste')
|
||
->helperText('Se il fornitore non viene trovato (P.IVA/CF), crea una nuova anagrafica fornitore collegata allo stabile.')
|
||
->default(true),
|
||
])
|
||
->action(function (array $data): void {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$forcedStabileId = (int) ($data['stabile_forzato_id'] ?? 0);
|
||
$canForce = $forcedStabileId > 0 && $user->hasAnyRole(['super-admin', 'admin']);
|
||
if ($canForce && ! Stabile::query()->attivi()->whereKey($forcedStabileId)->exists()) {
|
||
Notification::make()->title('Stabile forzato non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$paths = $data['fe_files'] ?? [];
|
||
$paths = is_array($paths) ? $paths : [$paths];
|
||
|
||
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
|
||
$extractor = app(P7mExtractor::class);
|
||
$parser = new FatturaElettronicaXmlParser();
|
||
|
||
$accessibleStabili = StabileContext::accessibleStabili($user)->pluck('id')->map(fn($v) => (int) $v)->all();
|
||
$accessibleMap = array_fill_keys($accessibleStabili, true);
|
||
|
||
$imported = 0;
|
||
$duplicates = 0;
|
||
$errors = 0;
|
||
|
||
$duplicateDetails = [];
|
||
$errorDetails = [];
|
||
|
||
$debugLines = [];
|
||
$debugPath = null;
|
||
$debugYm = now()->format('Y/m');
|
||
$debugPath = 'fe-import-debug/' . $debugYm . '/' . (string) Str::uuid() . '.log';
|
||
|
||
foreach ($paths as $path) {
|
||
$permanentP7mPath = null;
|
||
$permanentXmlPath = null;
|
||
|
||
try {
|
||
$path = (string) $path;
|
||
$filename = basename($path);
|
||
$lower = Str::lower($filename);
|
||
|
||
// ZIP: estrai e importa tutti gli XML/P7M contenuti
|
||
if (Str::endsWith($lower, '.zip')) {
|
||
$absZip = Storage::disk('local')->path($path);
|
||
if (! is_file($absZip)) {
|
||
throw new \RuntimeException('ZIP non trovato');
|
||
}
|
||
|
||
$tmpDir = 'tmp/fe-extract/' . now()->format('Y/m') . '/' . (string) Str::uuid();
|
||
Storage::disk('local')->makeDirectory($tmpDir);
|
||
$absTmp = Storage::disk('local')->path($tmpDir);
|
||
|
||
$zip = new ZipArchive();
|
||
$open = $zip->open($absZip);
|
||
if ($open !== true) {
|
||
throw new \RuntimeException('ZIP non valido');
|
||
}
|
||
$zip->extractTo($absTmp);
|
||
$zip->close();
|
||
|
||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absTmp));
|
||
foreach ($it as $fileInfo) {
|
||
if (! $fileInfo instanceof \SplFileInfo || ! $fileInfo->isFile()) {
|
||
continue;
|
||
}
|
||
|
||
$innerName = (string) $fileInfo->getFilename();
|
||
$innerLower = Str::lower($innerName);
|
||
if (! (Str::endsWith($innerLower, '.xml') || Str::endsWith($innerLower, '.p7m'))) {
|
||
continue;
|
||
}
|
||
|
||
$bytes = file_get_contents($fileInfo->getPathname());
|
||
if (! is_string($bytes) || $bytes === '') {
|
||
$errors++;
|
||
$errorDetails[] = $filename . '::' . $innerName . ' — file vuoto';
|
||
continue;
|
||
}
|
||
|
||
$isP7m = Str::endsWith($innerLower, '.p7m');
|
||
$xml = $isP7m
|
||
? $extractor->extractXmlFromP7m($fileInfo->getPathname())
|
||
: $bytes;
|
||
if (! is_string($xml) || trim($xml) === '') {
|
||
$errors++;
|
||
$errorDetails[] = $filename . '::' . $innerName . ' — XML non valido/assente';
|
||
continue;
|
||
}
|
||
|
||
$parsed = $parser->parse($xml);
|
||
$stabileId = $canForce ? $forcedStabileId : $this->resolveStabileIdFromParsed($parsed);
|
||
if (! $stabileId) {
|
||
$errors++;
|
||
$errorDetails[] = $filename . '::' . $innerName . ' — impossibile determinare lo stabile dalla FE';
|
||
continue;
|
||
}
|
||
if (! isset($accessibleMap[$stabileId])) {
|
||
$errors++;
|
||
$errorDetails[] = $filename . '::' . $innerName . ' — stabile non accessibile per l’utente';
|
||
continue;
|
||
}
|
||
|
||
$unique = (string) Str::uuid();
|
||
$ym = now()->format('Y/m');
|
||
$inbox = $this->inboxBaseForStabile($stabileId, $ym);
|
||
|
||
$permanentP7mPath = null;
|
||
if ($isP7m) {
|
||
$permanentP7mPath = $inbox . '/' . $unique . '-' . $innerName;
|
||
Storage::disk('local')->put($permanentP7mPath, $bytes);
|
||
}
|
||
|
||
$xmlFilename = $isP7m
|
||
? (pathinfo($innerName, PATHINFO_FILENAME) . '.xml')
|
||
: $innerName;
|
||
$permanentXmlPath = $inbox . '/' . $unique . '-' . $xmlFilename;
|
||
Storage::disk('local')->put($permanentXmlPath, $xml);
|
||
|
||
$result = $importer->importXml($xml, 0, $xmlFilename, [
|
||
'xml_path' => $permanentXmlPath,
|
||
'p7m_path' => $permanentP7mPath,
|
||
'nome_file_p7m' => $isP7m ? $innerName : null,
|
||
'force_update' => (bool) ($data['force_update'] ?? false),
|
||
'auto_repair_duplicate' => true,
|
||
'importa_righe' => (string) ($data['importa_righe'] ?? 'auto'),
|
||
'create_fornitore_if_missing' => (bool) ($data['create_fornitore_if_missing'] ?? false),
|
||
'allow_fallback_stabile' => false,
|
||
'force_stabile_id' => $canForce ? $forcedStabileId : 0,
|
||
'user_id' => (int) $user->id,
|
||
]);
|
||
|
||
try {
|
||
$status = (string) ($result['status'] ?? 'unknown');
|
||
$msg = (string) ($result['message'] ?? '');
|
||
$dbg = $result['debug'] ?? null;
|
||
$debugLines[] = now()->format('c') . ' | ' . $status . ' | ' . $filename . '::' . $innerName
|
||
. ($msg !== '' ? ' | ' . $msg : '')
|
||
. (is_array($dbg) ? ' | debug=' . json_encode($dbg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '');
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
if (($result['status'] ?? null) === 'imported') {
|
||
$imported++;
|
||
} elseif (($result['status'] ?? null) === 'duplicate') {
|
||
$duplicates++;
|
||
$duplicateDetails[] = $filename . '::' . $innerName . (($result['message'] ?? null) ? ' — ' . (string) $result['message'] : '');
|
||
try {
|
||
if ($permanentXmlPath) {
|
||
Storage::disk('local')->delete($permanentXmlPath);
|
||
}
|
||
if ($permanentP7mPath) {
|
||
Storage::disk('local')->delete($permanentP7mPath);
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
} else {
|
||
$errors++;
|
||
$errorDetails[] = $filename . '::' . $innerName . (($result['message'] ?? null) ? ' — ' . (string) $result['message'] : '');
|
||
try {
|
||
if ($permanentXmlPath) {
|
||
Storage::disk('local')->delete($permanentXmlPath);
|
||
}
|
||
if ($permanentP7mPath) {
|
||
Storage::disk('local')->delete($permanentP7mPath);
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
Storage::disk('local')->deleteDirectory($tmpDir);
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$bytes = Storage::disk('local')->get($path);
|
||
if (! is_string($bytes) || $bytes === '') {
|
||
throw new \RuntimeException('File vuoto');
|
||
}
|
||
|
||
$isP7m = Str::endsWith($lower, '.p7m');
|
||
$xml = $isP7m
|
||
? $extractor->extractXmlFromP7m(Storage::disk('local')->path($path))
|
||
: $bytes;
|
||
if (! is_string($xml) || trim($xml) === '') {
|
||
throw new \RuntimeException('XML non valido/assente');
|
||
}
|
||
|
||
// Smistamento obbligatorio: deve risolvere a uno stabile univoco e accessibile.
|
||
$parsed = $parser->parse($xml);
|
||
$stabileId = $canForce ? $forcedStabileId : $this->resolveStabileIdFromParsed($parsed);
|
||
if (! $stabileId) {
|
||
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (CF/SDI/PEC non trovati o non univoci)');
|
||
}
|
||
if (! isset($accessibleMap[$stabileId])) {
|
||
throw new \RuntimeException('Stabile non accessibile per l’utente');
|
||
}
|
||
|
||
$unique = (string) Str::uuid();
|
||
$ym = now()->format('Y/m');
|
||
$inbox = $this->inboxBaseForStabile($stabileId, $ym);
|
||
|
||
if ($isP7m) {
|
||
$permanentP7mPath = $inbox . '/' . $unique . '-' . $filename;
|
||
Storage::disk('local')->put($permanentP7mPath, $bytes);
|
||
}
|
||
|
||
$xmlFilename = $isP7m
|
||
? (pathinfo($filename, PATHINFO_FILENAME) . '.xml')
|
||
: $filename;
|
||
|
||
$permanentXmlPath = $inbox . '/' . $unique . '-' . $xmlFilename;
|
||
Storage::disk('local')->put($permanentXmlPath, $xml);
|
||
|
||
$result = $importer->importXml($xml, 0, $xmlFilename, [
|
||
'xml_path' => $permanentXmlPath,
|
||
'p7m_path' => $permanentP7mPath,
|
||
'nome_file_p7m' => $isP7m ? $filename : null,
|
||
'force_update' => (bool) ($data['force_update'] ?? false),
|
||
'auto_repair_duplicate' => true,
|
||
'importa_righe' => (string) ($data['importa_righe'] ?? 'auto'),
|
||
'create_fornitore_if_missing' => (bool) ($data['create_fornitore_if_missing'] ?? false),
|
||
'allow_fallback_stabile' => false,
|
||
'force_stabile_id' => $canForce ? $forcedStabileId : 0,
|
||
'user_id' => (int) $user->id,
|
||
]);
|
||
|
||
try {
|
||
$status = (string) ($result['status'] ?? 'unknown');
|
||
$msg = (string) ($result['message'] ?? '');
|
||
$dbg = $result['debug'] ?? null;
|
||
$debugLines[] = now()->format('c') . ' | ' . $status . ' | ' . $filename
|
||
. ($msg !== '' ? ' | ' . $msg : '')
|
||
. (is_array($dbg) ? ' | debug=' . json_encode($dbg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '');
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
if (($result['status'] ?? null) === 'imported') {
|
||
$imported++;
|
||
} elseif (($result['status'] ?? null) === 'duplicate') {
|
||
$duplicates++;
|
||
$duplicateDetails[] = $this->safeUtf8($filename . (($result['message'] ?? null) ? ' — ' . (string) $result['message'] : ''));
|
||
|
||
try {
|
||
if ($permanentXmlPath) {
|
||
Storage::disk('local')->delete($permanentXmlPath);
|
||
}
|
||
if ($permanentP7mPath) {
|
||
Storage::disk('local')->delete($permanentP7mPath);
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
} else {
|
||
$errors++;
|
||
$errorDetails[] = $this->safeUtf8($filename . (($result['message'] ?? null) ? ' — ' . (string) $result['message'] : ''));
|
||
|
||
try {
|
||
if ($permanentXmlPath) {
|
||
Storage::disk('local')->delete($permanentXmlPath);
|
||
}
|
||
if ($permanentP7mPath) {
|
||
Storage::disk('local')->delete($permanentP7mPath);
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$errors++;
|
||
$filename = basename((string) $path);
|
||
$debugNote = '';
|
||
try {
|
||
$lower = Str::lower($filename);
|
||
if (Str::endsWith($lower, '.p7m') && isset($bytes) && is_string($bytes) && $bytes !== '') {
|
||
$debugDir = 'fe-import-errors/' . now()->format('Y/m');
|
||
$debugPath = $debugDir . '/' . (string) Str::uuid() . '-' . $filename;
|
||
Storage::disk('local')->put($debugPath, $bytes);
|
||
$debugNote = ' (salvato per debug: ' . $debugPath . ')';
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
$errorDetails[] = $this->safeUtf8($filename . ' — ' . $e->getMessage() . $debugNote);
|
||
|
||
try {
|
||
$debugLines[] = $this->safeUtf8(now()->format('c') . ' | error | ' . $filename . ' | ' . $e->getMessage() . $debugNote);
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
try {
|
||
if ($permanentXmlPath) {
|
||
Storage::disk('local')->delete($permanentXmlPath);
|
||
}
|
||
if ($permanentP7mPath) {
|
||
Storage::disk('local')->delete($permanentP7mPath);
|
||
}
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
} finally {
|
||
try {
|
||
Storage::disk('local')->delete($path);
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
$logNote = '';
|
||
if (($duplicates > 0 || $errors > 0) && $debugPath && count($debugLines) > 0) {
|
||
try {
|
||
Storage::disk('local')->put($debugPath, implode("\n", $debugLines) . "\n");
|
||
$logNote = "\nLog diagnostica: {$debugPath}";
|
||
} catch (\Throwable) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
Notification::make()
|
||
->title('Import completato')
|
||
->body($this->safeUtf8("Importate: {$imported} · Duplicate: {$duplicates} · Errori: {$errors}" . $logNote))
|
||
->success()
|
||
->send();
|
||
|
||
if ($duplicates > 0 && count($duplicateDetails) > 0) {
|
||
Notification::make()
|
||
->title('Duplicati (non importati)')
|
||
->body($this->safeUtf8(implode("\n", array_slice($duplicateDetails, 0, 10)) . (count($duplicateDetails) > 10 ? "\n…" : '')))
|
||
->warning()
|
||
->send();
|
||
}
|
||
|
||
if ($errors > 0 && count($errorDetails) > 0) {
|
||
Notification::make()
|
||
->title('Errori durante l’import')
|
||
->body($this->safeUtf8(implode("\n", array_slice($errorDetails, 0, 10)) . (count($errorDetails) > 10 ? "\n…" : '')))
|
||
->danger()
|
||
->send();
|
||
}
|
||
}),
|
||
|
||
Action::make('importa_ade_csv')
|
||
->label('Importa CSV AdE')
|
||
->icon('heroicon-o-clipboard-document-list')
|
||
->form([
|
||
FileUpload::make('csv_file')
|
||
->label('CSV AdE (separatore ; )')
|
||
->directory(function (): string {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return 'tmp/ade-csv';
|
||
}
|
||
|
||
$stabile = StabileContext::getActiveStabile($user);
|
||
if (! $stabile) {
|
||
return 'tmp/ade-csv';
|
||
}
|
||
$adminCode = $stabile?->amministratore?->codice_amministratore;
|
||
$adminId = (int) ($stabile?->amministratore_id ?: 0);
|
||
$adminFolder = $adminCode ?: ('ID-' . $adminId);
|
||
|
||
return 'amministratori/' . $adminFolder . '/temp/upload/ade-csv';
|
||
})
|
||
->disk('local')
|
||
->acceptedFileTypes([
|
||
'text/csv',
|
||
'text/plain',
|
||
'application/vnd.ms-excel',
|
||
])
|
||
->required(),
|
||
|
||
Select::make('stabile_target_id')
|
||
->label('Target stabile (solo super-admin)')
|
||
->helperText('Se non valorizzato, usa lo stabile attivo.')
|
||
->options(function () {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return [];
|
||
}
|
||
|
||
return StabileContext::accessibleStabili($user)
|
||
->mapWithKeys(function (Stabile $s) {
|
||
$adminCode = (string) ($s->amministratore?->codice_amministratore ?: $s->amministratore?->codice_univoco ?: '');
|
||
$prefix = $adminCode !== '' ? ($adminCode . ' — ') : '';
|
||
return [(string) $s->id => $prefix . $s->codice_stabile . ' - ' . $s->denominazione];
|
||
})
|
||
->all();
|
||
})
|
||
->searchable()
|
||
->nullable()
|
||
->visible(function (): bool {
|
||
$u = Auth::user();
|
||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin']);
|
||
}),
|
||
|
||
TextInput::make('cartella_legacy')
|
||
->label('Cartella legacy (opzionale)')
|
||
->helperText('Esempio: 0021 (solo metadato, non influenza il matching).')
|
||
->maxLength(10)
|
||
->nullable(),
|
||
])
|
||
->action(function (array $data): void {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
Notification::make()->title('Utente non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
||
if (! $activeStabileId) {
|
||
Notification::make()->title('Stabile attivo non impostato')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$forcedStabileId = (int) ($data['stabile_target_id'] ?? 0);
|
||
$canForce = $forcedStabileId > 0 && $user->hasAnyRole(['super-admin', 'admin']);
|
||
$targetStabileId = $canForce ? $forcedStabileId : (int) $activeStabileId;
|
||
|
||
$accessibleStabili = StabileContext::accessibleStabili($user)->pluck('id')->map(fn($v) => (int) $v)->all();
|
||
$accessibleMap = array_fill_keys($accessibleStabili, true);
|
||
if (! isset($accessibleMap[$targetStabileId])) {
|
||
Notification::make()->title('Stabile non accessibile per l’utente')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($targetStabileId);
|
||
$amministratoreId = (int) ($stabile?->amministratore_id ?: 0);
|
||
if ($amministratoreId <= 0) {
|
||
Notification::make()->title('Amministratore non valido')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$csvPath = (string) ($data['csv_file'] ?? '');
|
||
if ($csvPath === '') {
|
||
Notification::make()->title('CSV mancante')->danger()->send();
|
||
return;
|
||
}
|
||
|
||
$absolute = Storage::disk('local')->path($csvPath);
|
||
$cartella = isset($data['cartella_legacy']) ? trim((string) $data['cartella_legacy']) : '';
|
||
|
||
Artisan::call('gescon:import-fatture-ade-csv', array_filter([
|
||
'amministratore_id' => $amministratoreId,
|
||
'--stabile_id' => $targetStabileId,
|
||
'--cartella' => $cartella !== '' ? $cartella : null,
|
||
'--path' => $absolute,
|
||
], fn($v) => $v !== null && $v !== ''));
|
||
|
||
$out = trim((string) Artisan::output());
|
||
$lines = $out === '' ? [] : preg_split('/\r\n|\r|\n/', $out);
|
||
$tail = $lines ? implode("\n", array_slice($lines, -8)) : 'Import completato.';
|
||
|
||
Notification::make()
|
||
->title('Import AdE completato')
|
||
->body($this->safeUtf8($tail))
|
||
->success()
|
||
->send();
|
||
}),
|
||
];
|
||
}
|
||
|
||
private function inboxBaseForStabile(int $stabileId, string $ym): string
|
||
{
|
||
$stabile = Stabile::query()
|
||
->with(['amministratore:id,codice_amministratore,codice_univoco'])
|
||
->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco'])
|
||
->find($stabileId);
|
||
|
||
if ($stabile) {
|
||
$base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore);
|
||
if (is_string($base) && $base !== '') {
|
||
return $base . '/fatture-ricevute/inbox/' . $ym;
|
||
}
|
||
}
|
||
|
||
$adminId = (int) ($stabile?->amministratore_id ?: 0);
|
||
return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture-ricevute/inbox/' . $ym;
|
||
}
|
||
|
||
private function resolveStabileIdFromParsed(array $parsed): ?int
|
||
{
|
||
$cf = isset($parsed['destinatario_cf']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_cf']))) : null;
|
||
$piva = isset($parsed['destinatario_piva']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_piva']))) : null;
|
||
$codice = isset($parsed['codice_destinatario']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['codice_destinatario']))) : null;
|
||
$pec = $parsed['pec_destinatario'] ?? null;
|
||
|
||
if ($cf) {
|
||
$match = Stabile::query()
|
||
->whereNotNull('codice_fiscale')
|
||
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf])
|
||
->where('attivo', true)
|
||
->get();
|
||
if ($match->count() === 1) {
|
||
return (int) $match->first()->id;
|
||
}
|
||
}
|
||
|
||
if ($codice) {
|
||
$match = Stabile::query()
|
||
->whereNotNull('codice_destinatario_sdi')
|
||
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
|
||
->where('attivo', true)
|
||
->get();
|
||
if ($match->count() === 1) {
|
||
return (int) $match->first()->id;
|
||
}
|
||
}
|
||
|
||
if ($pec) {
|
||
$pec = trim(strtolower((string) $pec));
|
||
$match = Stabile::query()
|
||
->where(function ($q) use ($pec) {
|
||
$q->whereRaw('LOWER(pec_condominio) = ?', [$pec])
|
||
->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
|
||
})
|
||
->where('attivo', true)
|
||
->get();
|
||
if ($match->count() === 1) {
|
||
return (int) $match->first()->id;
|
||
}
|
||
}
|
||
|
||
if ($piva) {
|
||
$match = Stabile::query()
|
||
->select('stabili.id')
|
||
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
|
||
->where('stabili.attivo', true)
|
||
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$piva])
|
||
->get();
|
||
if ($match->count() === 1) {
|
||
return (int) $match->first()->id;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function archiveBaseForStabile(int $stabileId): string
|
||
{
|
||
$stabile = Stabile::query()
|
||
->with(['amministratore:id,codice_amministratore,codice_univoco'])
|
||
->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco'])
|
||
->find($stabileId);
|
||
|
||
$ym = now()->format('Y/m');
|
||
|
||
if ($stabile) {
|
||
$base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore);
|
||
if (is_string($base) && $base !== '') {
|
||
return $base . '/fatture-ricevute/' . $ym;
|
||
}
|
||
}
|
||
|
||
$adminId = (int) ($stabile?->amministratore_id ?: 0);
|
||
return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture-ricevute/' . $ym;
|
||
}
|
||
|
||
protected function getTableQuery(): Builder
|
||
{
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return FatturaElettronica::query()->whereRaw('1 = 0');
|
||
}
|
||
|
||
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
||
if (! $activeStabileId) {
|
||
return FatturaElettronica::query()->whereRaw('1 = 0');
|
||
}
|
||
|
||
return FatturaElettronica::query()
|
||
->where('stabile_id', $activeStabileId);
|
||
}
|
||
|
||
public function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->striped()
|
||
->paginationPageOptions([10, 25, 50, 100])
|
||
->defaultPaginationPageOption(50)
|
||
->defaultSort('data_fattura', 'desc')
|
||
->filters([
|
||
SelectFilter::make('anno')
|
||
->label('Anno fattura')
|
||
->options(function (): array {
|
||
$user = Auth::user();
|
||
if (! $user instanceof User) {
|
||
return [];
|
||
}
|
||
|
||
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
||
if (! $activeStabileId) {
|
||
return [];
|
||
}
|
||
|
||
return FatturaElettronica::query()
|
||
->where('stabile_id', $activeStabileId)
|
||
->whereNotNull('data_fattura')
|
||
->selectRaw('YEAR(data_fattura) as anno')
|
||
->distinct()
|
||
->orderByDesc('anno')
|
||
->pluck('anno', 'anno')
|
||
->mapWithKeys(fn($anno) => [(string) $anno => (string) $anno])
|
||
->all();
|
||
})
|
||
->query(function (Builder $query, array $data): Builder {
|
||
$anno = is_numeric($data['value'] ?? null) ? (int) $data['value'] : 0;
|
||
|
||
return $anno > 0 ? $query->whereYear('data_fattura', $anno) : $query;
|
||
}),
|
||
|
||
SelectFilter::make('ordine')
|
||
->label('Ordine')
|
||
->options([
|
||
'data_desc' => 'Data (più recenti)',
|
||
'data_asc' => 'Data (più vecchie)',
|
||
'numero_asc' => 'Numero (A → Z)',
|
||
'numero_desc' => 'Numero (Z → A)',
|
||
'totale_desc' => 'Totale (più alto)',
|
||
'totale_asc' => 'Totale (più basso)',
|
||
])
|
||
->default('data_desc')
|
||
->query(function (Builder $query, array $data): Builder {
|
||
$v = (string) ($data['value'] ?? 'data_desc');
|
||
return match ($v) {
|
||
'data_asc' => $query->reorder('data_fattura', 'asc')->orderBy('id', 'asc'),
|
||
'numero_asc' => $query->reorder('numero_fattura', 'asc')->orderBy('id', 'asc'),
|
||
'numero_desc' => $query->reorder('numero_fattura', 'desc')->orderBy('id', 'desc'),
|
||
'totale_asc' => $query->reorder('totale', 'asc')->orderBy('id', 'asc'),
|
||
'totale_desc' => $query->reorder('totale', 'desc')->orderBy('id', 'desc'),
|
||
default => $query->reorder('data_fattura', 'desc')->orderBy('id', 'desc'),
|
||
};
|
||
}),
|
||
])
|
||
->columns([
|
||
TextColumn::make('fornitore_denominazione')
|
||
->label('Fornitore')
|
||
->searchable()
|
||
->wrap(),
|
||
|
||
TextColumn::make('numero_fattura')
|
||
->label('Numero')
|
||
->searchable()
|
||
->sortable(),
|
||
|
||
TextColumn::make('data_fattura')
|
||
->label('Data')
|
||
->date('d/m/Y')
|
||
->sortable(),
|
||
|
||
TextColumn::make('totale')
|
||
->label('Totale')
|
||
->money('EUR')
|
||
->sortable(),
|
||
|
||
BadgeColumn::make('stato')
|
||
->label('Stato')
|
||
->colors([
|
||
'warning' => 'ricevuta',
|
||
'success' => 'pagata',
|
||
'primary' => 'contabilizzata',
|
||
'danger' => 'rifiutata',
|
||
]),
|
||
|
||
TextColumn::make('nome_file_p7m')
|
||
->label('P7M')
|
||
->toggleable(isToggledHiddenByDefault: true)
|
||
->wrap(),
|
||
|
||
TextColumn::make('destinatario_cf')
|
||
->label('Identificativo destinatario')
|
||
->formatStateUsing(fn(FatturaElettronica $record): string => trim((string) ($record->destinatario_cf ?: $record->destinatario_piva ?: '—')))
|
||
->toggleable(isToggledHiddenByDefault: true)
|
||
->wrap(),
|
||
])
|
||
->actions([
|
||
Action::make('apri')
|
||
->label('Apri')
|
||
->icon('heroicon-o-eye')
|
||
->url(fn(FatturaElettronica $record) => FatturaElettronicaScheda::getUrl([
|
||
'record' => $record->id,
|
||
'back' => request()->getRequestUri(),
|
||
], panel: 'admin-filament')),
|
||
]);
|
||
}
|
||
}
|