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

2374 lines
109 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Jobs\RunCassettoFiscaleDownload;
use App\Models\Documento;
use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\Stabile;
use App\Models\StgFatturaAde;
use App\Models\User;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\FattureElettroniche\CassettoFiscaleDownloadService;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Services\FattureElettroniche\FeSubjectModuleService;
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\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use UnitEnum;
use ZipArchive;
class FattureElettronicheP7mRicevute extends Page implements HasTable
{
use InteractsWithTable;
use ResolvesOperatoreContext;
public array $quarterDownloads = [];
public string $cassettoViewMode = 'cards';
/** @var array<string,mixed> */
public array $feModuleStatus = [];
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);
}
public function getActiveStabileDestinationSummaryProperty(): string
{
$user = Auth::user();
if (! $user instanceof User) {
return 'Stabile non selezionato';
}
$stabile = StabileContext::getActiveStabile($user);
if (! $stabile instanceof Stabile) {
return 'Stabile non selezionato';
}
$summary = $this->getActiveStabileLabelProperty();
$cf = $this->resolveStabileSoggettoCf($stabile);
if ($cf !== '') {
$summary .= "\nCodice fiscale soggetto usato per lo scarico: {$cf}";
}
if ($this->isLikelyTechnicalFeShell($stabile)) {
$summary .= "\nATTENZIONE: lo stabile attivo sembra uno shell tecnico FE separato. Per scaricare le fatture del condominio cambia lo stabile in topbar prima di procedere.";
}
return $summary;
}
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;
}
/**
* @return array{status: string, created_letture?: int, updated_letture?: int, tickets?: int, message?: string}
*/
private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): array
{
$fattura = FatturaElettronica::query()->find($fatturaId);
if (! $fattura instanceof FatturaElettronica) {
return ['status' => 'skipped', 'message' => 'Fattura non trovata'];
}
$hasPdf = is_string($fattura->allegato_pdf_path ?? null) && trim((string) $fattura->allegato_pdf_path) !== '';
$hasStoredPayload = is_string($fattura->consumo_raw ?? null) && trim((string) $fattura->consumo_raw) !== '';
if (! $hasPdf && ! $hasStoredPayload) {
return ['status' => 'skipped', 'message' => 'Nessun PDF/payload acqua disponibile'];
}
try {
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fattura, $userId);
$fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
$documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $fattura->id)
->first();
$parsed = [];
if ($documento instanceof Documento) {
$text = is_string($documento->contenuto_ocr) ? trim((string) $documento->contenuto_ocr) : '';
if ($text === '') {
try {
$result = app(PdfTextExtractionService::class)->extractAndStore($documento, false);
if (($result['status'] ?? null) === 'ok') {
$documento->refresh();
$text = is_string($documento->contenuto_ocr) ? trim((string) $documento->contenuto_ocr) : '';
}
} catch (\Throwable) {
$text = '';
}
}
if ($text !== '') {
$parsed = app(AcquaPdfTextParser::class)->parse($text);
}
}
$storedRaw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : '';
if ($storedRaw !== '' && $parsed === []) {
$decoded = json_decode($storedRaw, true);
if (is_array($decoded)) {
$parsed = $decoded;
}
}
$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'] : [];
$hasAny = false;
foreach ([$codici['utenza'] ?? null, $codici['cliente'] ?? null, $codici['contratto'] ?? null, $contatore['matricola'] ?? null] as $value) {
if (is_string($value) && trim($value) !== '') {
$hasAny = true;
break;
}
}
$hasTariffData = count(array_filter([
$generale['periodicita_fatturazione'] ?? null,
$tariffe['profilo'] ?? null,
$ivaInfo['codice'] ?? null,
$quadro['quota_fissa'] ?? null,
], static fn($value): bool => $value !== null && $value !== '')) > 0;
if (! $hasAny && count($consumi) < 1 && ! $hasTariffData) {
return ['status' => 'skipped', 'message' => 'Nessun dato acqua utile rilevato'];
}
$payload = [
'type' => 'acqua',
'source' => 'pdf_ocr',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'consumi' => array_values($consumi),
'generale' => $generale,
'finestra_autolettura' => $finestra,
'riepilogo_letture' => array_values($letture),
'quadro_dettaglio' => $quadro,
'tariffe' => $tariffe,
'iva' => [
'codice' => $ivaInfo['codice'] ?? null,
'aliquota_percentuale' => $ivaInfo['aliquota_percentuale'] ?? null,
'descrizione' => $ivaInfo['descrizione'] ?? null,
],
'parsed_at' => now()->toISOString(),
];
$totalMc = null;
$reference = null;
if ($consumi !== []) {
$sum = 0.0;
$hasNumeric = false;
foreach ($consumi as $consumo) {
if (isset($consumo['valore']) && is_numeric($consumo['valore'])) {
$sum += (float) $consumo['valore'];
$hasNumeric = true;
}
}
if ($hasNumeric) {
$totalMc = $sum;
}
$first = $consumi[0] ?? [];
$dal = $first['dal'] ?? null;
$al = $first['al'] ?? null;
if (is_string($dal) && is_string($al) && $dal !== '' && $al !== '') {
$reference = $dal . ' - ' . $al;
}
}
$fattura->consumo_unita = 'mc';
$fattura->consumo_valore = $totalMc;
$fattura->consumo_riferimento = $reference;
$fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$fattura->save();
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest($fattura, $parsed, null);
} catch (\Throwable) {
// best-effort
}
$ingestion = app(ConsumiAcquaIngestionService::class)->ingest($fattura, $parsed, null, $userId, false, null);
if (($ingestion['status'] ?? null) === 'ok') {
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest(
$fattura,
$parsed,
isset($ingestion['servizio_id']) && is_numeric($ingestion['servizio_id']) ? (int) $ingestion['servizio_id'] : null
);
} catch (\Throwable) {
// best-effort
}
}
return $ingestion;
}
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()
->map(function (object $log) use ($stabile): object {
$message = is_string($log->message ?? null) ? (string) $log->message : '';
$filesTotal = (int) ($log->files_total ?? 0);
$log->remote_total = $this->extractRiepilogoTotaleFatture($message) ?? ($filesTotal > 0 ? $filesTotal : null);
$log->local_total = 0;
$log->missing_estimate = null;
if (! empty($log->dal) && ! empty($log->al)) {
$log->local_total = $this->countLocalFattureForPeriod((int) $stabile->id, (string) $log->dal, (string) $log->al);
}
if ($log->remote_total !== null) {
$log->missing_estimate = max(0, (int) $log->remote_total - (int) $log->local_total);
}
return $log;
});
}
/**
* Stato trimestri per un anno (Q1..Q4) per lo stabile attivo.
* @return array<int, array<string,mixed>>
*/
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 = $this->findCassettoDownloadLogForPeriod((int) $amministratore->id, (int) $stabile->id, $dal, $al);
$stato = 'MAI';
$filesTotal = 0;
$remoteTotal = null;
$localTotal = $this->countLocalFattureForPeriod((int) $stabile->id, $dal, $al);
$adeTotal = $this->countAdeRowsForPeriod((int) $stabile->id, $dal, $al);
$missingEstimate = null;
$imported = 0;
$duplicates = 0;
$errors = 0;
$createdAt = null;
$logId = null;
$message = '';
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;
$message = is_string($log->message ?? null) ? (string) $log->message : '';
$remoteTotal = $this->extractRiepilogoTotaleFatture($message) ?? ($filesTotal > 0 ? $filesTotal : null);
$status = strtolower((string) ($log->status ?? ''));
$processedTotal = $imported + $duplicates + $errors;
$downloadComplete = $filesTotal > 0 && $processedTotal >= $filesTotal;
$expectedTotal = max((int) ($remoteTotal ?? 0), $filesTotal, $adeTotal);
if ($expectedTotal > 0) {
$missingEstimate = max(0, $expectedTotal - $localTotal);
}
$isComplete = (
$status === 'ok'
&& $errors === 0
&& $downloadComplete
&& $expectedTotal > 0
&& $localTotal >= $expectedTotal
);
if ($isComplete) {
$stato = 'COMPLETO';
} elseif (in_array($status, ['queued', 'started', 'downloaded', 'importing', 'running', 'in_progress'], true)) {
$stato = 'IN CORSO';
} elseif ($errors > 0 || $status === 'error' || $status === 'failed') {
$stato = 'ERRORE';
} elseif ($status === 'skipped') {
$stato = 'SALTATO';
} elseif ($remoteTotal !== null && $missingEstimate !== null && $missingEstimate > 0) {
$stato = 'VERIFICA';
} else {
$stato = 'PARZIALE';
}
} elseif ($localTotal > 0 || $adeTotal > 0) {
$stato = 'LOCALE';
}
$out[] = [
'quarter' => $q,
'dal' => $dal,
'al' => $al,
'stato' => $stato,
'log_id' => $logId,
'remote_total' => $remoteTotal,
'expected_total' => $expectedTotal ?? 0,
'files_total' => $filesTotal,
'local_total' => $localTotal,
'ade_total' => $adeTotal,
'missing_estimate' => $missingEstimate,
'imported' => $imported,
'duplicates' => $duplicates,
'errors' => $errors,
'download_complete' => $downloadComplete ?? false,
'created_at' => $createdAt,
'message' => $message,
];
}
return $out;
}
public function getCassettoYearSummary(int $anno): array
{
$rows = $this->getCassettoQuarterStatus($anno);
return [
'remote_total' => collect($rows)->sum(fn(array $row): int => (int) ($row['remote_total'] ?? 0)),
'files_total' => collect($rows)->sum(fn(array $row): int => (int) ($row['files_total'] ?? 0)),
'local_total' => collect($rows)->sum(fn(array $row): int => (int) ($row['local_total'] ?? 0)),
'ade_total' => collect($rows)->sum(fn(array $row): int => (int) ($row['ade_total'] ?? 0)),
'missing_estimate' => collect($rows)->sum(fn(array $row): int => (int) ($row['missing_estimate'] ?? 0)),
];
}
public function setCassettoViewMode(string $mode): void
{
$this->cassettoViewMode = in_array($mode, ['cards', 'table'], true) ? $mode : 'cards';
}
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;
}
if ($this->isLikelyTechnicalFeShell($stabile)) {
$this->notifyTechnicalFeShellBlocked();
$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;
}
$this->feModuleStatus = $this->resolveFeModuleStatus();
if (! $this->canUseCassettoSource()) {
Notification::make()
->title('Cassetto Fiscale non abilitato')
->body($this->safeUtf8('Lo scarico da Cassetto Fiscale non e abilitato per l amministratore collegato allo stabile attivo. Abilitalo in Super Admin > Amministratori prima di scaricare.'))
->warning()
->send();
$this->quarterDownloads[$key] = false;
return;
}
$force = false;
if ($this->isCassettoQuarterCompleteFor($amministratoreId, (int) $stabile->id, $dal, $al)) {
$force = true;
}
$cfSoggetto = $this->resolveStabileSoggettoCf($stabile);
if ($cfSoggetto === '') {
Notification::make()->title('Codice fiscale stabile mancante')->danger()->send();
$this->quarterDownloads[$key] = false;
return;
}
$logId = $this->createQueuedCassettoDownloadLog($amministratoreId, (int) $stabile->id, $cfSoggetto, $dal, $al, false, "Scarico {$quarter} {$anno} in coda...");
try {
$job = new RunCassettoFiscaleDownload(
$amministratoreId,
(int) $stabile->id,
$dal,
$al,
false,
(int) $user->id,
[
'force_update' => $force,
'no_skip' => true,
'importa_righe' => 'auto',
'create_fornitore_if_missing' => true,
'log_id' => $logId,
],
);
} catch (\Throwable $e) {
if ($logId > 0) {
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'status' => 'error',
'message' => $e->getMessage(),
'updated_at' => now(),
]);
}
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 (log #{$logId}). La tab scarichi si aggiorna automaticamente."))
->success()
->send();
}
private function createQueuedCassettoDownloadLog(int $amministratoreId, int $stabileId, string $codiceFiscaleSoggetto, string $dalYmd, string $alYmd, bool $metadati, string $message): int
{
return (int) DB::table('fe_cassetto_download_logs')->insertGetId([
'amministratore_id' => $amministratoreId,
'stabile_id' => $stabileId,
'codice_fiscale_soggetto' => $codiceFiscaleSoggetto,
'dal' => $dalYmd,
'al' => $alYmd,
'metadati' => $metadati ? 1 : 0,
'request_hash' => 'queued-' . (string) Str::uuid(),
'status' => 'queued',
'message' => $message,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function isCassettoQuarterCompleteFor(int $amministratoreId, int $stabileId, string $dalYmd, string $alYmd): bool
{
$row = $this->findCassettoDownloadLogForPeriod($amministratoreId, $stabileId, $dalYmd, $alYmd, 'ok');
if (! $row) {
return false;
}
$filesTotal = (int) ($row->files_total ?? 0);
$errors = (int) ($row->errors ?? 0);
$message = is_string($row->message ?? null) ? (string) $row->message : '';
$remoteTotal = $this->extractRiepilogoTotaleFatture($message) ?? ($filesTotal > 0 ? $filesTotal : null);
if ($remoteTotal === null || $remoteTotal <= 0) {
return false;
}
$downloadComplete = $this->isCassettoQuarterDownloadCompleteFor($amministratoreId, $stabileId, $dalYmd, $alYmd);
$expectedTotal = max($remoteTotal, $filesTotal, $this->countAdeRowsForPeriod($stabileId, $dalYmd, $alYmd));
$localTotal = $this->countLocalFattureForPeriod($stabileId, $dalYmd, $alYmd);
return $errors === 0 && $downloadComplete && $expectedTotal > 0 && $localTotal >= $expectedTotal;
}
private function isCassettoQuarterDownloadCompleteFor(int $amministratoreId, int $stabileId, string $dalYmd, string $alYmd): bool
{
$row = $this->findCassettoDownloadLogForPeriod($amministratoreId, $stabileId, $dalYmd, $alYmd, 'ok');
if (! $row) {
return false;
}
$filesTotal = (int) ($row->files_total ?? 0);
if ($filesTotal <= 0) {
return false;
}
$processedTotal = (int) ($row->imported ?? 0) + (int) ($row->duplicates ?? 0) + (int) ($row->errors ?? 0);
return $processedTotal >= $filesTotal;
}
private function extractRiepilogoTotaleFatture(?string $message): ?int
{
$message = trim((string) $message);
if ($message === '') {
return null;
}
if (! preg_match('/(?:^|\R)riepilogo_totaleFatture=(\d+)/', $message, $matches)) {
return null;
}
return max(0, (int) $matches[1]);
}
private function countLocalFattureForPeriod(int $stabileId, string $dalYmd, string $alYmd): int
{
return (int) FatturaElettronica::query()
->where('stabile_id', $stabileId)
->whereBetween('data_fattura', [$dalYmd, $alYmd])
->count();
}
private function countAdeRowsForPeriod(int $stabileId, string $dalYmd, string $alYmd): int
{
$query = StgFatturaAde::query()
->where('stabile_id', $stabileId)
->whereBetween('data_emissione', [$dalYmd, $alYmd]);
$distinctSdi = (clone $query)
->whereNotNull('sdi_file')
->where('sdi_file', '<>', '')
->distinct()
->count('sdi_file');
if ($distinctSdi > 0) {
return (int) $distinctSdi;
}
return (int) $query->count();
}
private function findCassettoDownloadLogForPeriod(int $amministratoreId, int $stabileId, string $dalYmd, string $alYmd, ?string $status = null): ?object
{
$alCandidates = [$alYmd];
try {
$alNextDay = \Carbon\CarbonImmutable::parse($alYmd)->addDay()->toDateString();
$alCandidates[] = $alNextDay;
} catch (\Throwable) {
// ignore invalid compatibility date
}
$query = DB::table('fe_cassetto_download_logs')
->where('amministratore_id', $amministratoreId)
->where('stabile_id', $stabileId)
->whereDate('dal', $dalYmd)
->whereIn(DB::raw('DATE(al)'), array_values(array_unique($alCandidates)));
if ($status !== null) {
$query->where('status', $status);
}
return $query->orderByDesc('id')->first();
}
private function resolveStabileSoggettoCf(Stabile $stabile): string
{
$cf = trim((string) ($stabile->codice_fiscale ?? ''));
if ($cf === '') {
try {
$stabile->loadMissing('rubrica');
$cf = trim((string) ($stabile->rubrica?->codice_fiscale ?? ''));
} catch (\Throwable) {
// ignore
}
}
return strtoupper(trim($cf));
}
private function isLikelyTechnicalFeShell(Stabile $stabile): bool
{
$stabileCf = $this->resolveStabileSoggettoCf($stabile);
$adminCf = strtoupper(trim((string) ($stabile->amministratore?->codice_fiscale_studio ?? '')));
$code = strtoupper(trim((string) ($stabile->codice_stabile ?? '')));
$legacyCode = trim((string) ($stabile->cod_stabile ?? ''));
return $stabileCf !== ''
&& $adminCf !== ''
&& $stabileCf === $adminCf
&& (str_starts_with($code, 'FE') || $legacyCode === '');
}
private function notifyTechnicalFeShellBlocked(): void
{
Notification::make()
->title('Stabile tecnico FE separato')
->body('Lo scarico è bloccato perché lo stabile attivo usa il codice fiscale dello studio/amministratore. Se devi scaricare le fatture del condominio, cambia lo stabile attivo in topbar e seleziona il condominio reale.')
->warning()
->send();
}
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;
}
if ($user->hasRole('fornitore')) {
return true;
}
return $user->can('contabilita.fatture-elettroniche');
}
public function mount(): void
{
$this->cassettoAnno = (int) now()->format('Y');
$this->feModuleStatus = $this->resolveFeModuleStatus();
$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'])
&& $this->canUseCassettoSource();
})
->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'])
&& $this->canUseCassettoSource();
})
->form([
Placeholder::make('stabile_attivo_info')
->label('Stabile di destinazione')
->content(fn(): string => $this->getActiveStabileDestinationSummaryProperty()),
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()
->afterStateUpdated(function ($state, callable $set, callable $get): void {
$anno = (string) ($state ?? now()->format('Y'));
$q = (string) ($get('periodo') ?? '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);
}),
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),
Toggle::make('no_skip')
->label('Non saltare se lo stesso periodo ha già un log OK')
->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;
}
if ($this->isLikelyTechnicalFeShell($stabile)) {
$this->notifyTechnicalFeShellBlocked();
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);
$noSkip = (bool) ($data['no_skip'] ?? false);
$allowRerun = $forceUpdate || $noSkip;
// 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 (! $allowRerun && 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 (! $allowRerun) {
$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),
'no_skip' => (bool) ($data['no_skip'] ?? 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 hai abilitato 'Reimport / completa' o 'Non saltare', lo stesso periodo verrà rieseguito.\nAggiorna tra poco e controlla la sezione 'Ultimi scarichi'.")
->success()
->send();
}),
Action::make('importa_fe')
->label('Importa FE')
->icon('heroicon-o-arrow-up-tray')
->visible(fn(): bool => $this->hasFeModuleEnabled())
->form([
FileUpload::make('fe_files')
->label('Cartella FE (XML/P7M/ZIP)')
->directory(function (): string {
$user = Auth::user();
return $user instanceof User
? $this->feUploadTempDirectory($user)
: 'tmp/fatture-fe';
})
->disk('local')
->helperText('Usa Sfoglia per selezionare una cartella locale: il browser carica in blocco i file XML, P7M e ZIP contenuti nella directory scelta.')
->extraInputAttributes([
'webkitdirectory' => true,
'directory' => true,
])
->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;
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
$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];
$paths = array_values(array_filter($paths, static fn($path): bool => is_string($path) && trim($path) !== ''));
if ($paths === []) {
Notification::make()
->title('Nessun file FE da importare')
->warning()
->send();
return;
}
$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;
$skipped = 0;
$errors = 0;
$waterCreated = 0;
$waterUpdated = 0;
$duplicateDetails = [];
$errorDetails = [];
$skippedDetails = [];
$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;
}
if ($this->isSdINotificationDocument($innerName, $xml)) {
$skipped++;
$skippedDetails[] = $filename . '::' . $innerName . ' — ricevuta/notifica SdI saltata';
continue;
}
$parsed = $parser->parse($xml);
$forcedFornitoreId = 0;
if ($user->hasRole('fornitore')) {
[$fornitore] = $this->resolveOperatoreContext();
if (! $fornitore instanceof Fornitore) {
$errors++;
$errorDetails[] = $filename . '::' . $innerName . ' — profilo fornitore non collegato';
continue;
}
if (! $this->supplierMatchesParsedIdentity($fornitore, $parsed)) {
$errors++;
$errorDetails[] = $filename . '::' . $innerName . ' — il file non appartiene al fornitore corrente';
continue;
}
$forcedFornitoreId = (int) $fornitore->id;
$stabileId = $canForce
? $forcedStabileId
: ($this->resolveSupplierStabileIdFromParsed($fornitore, $parsed) ?: (int) ($activeStabileId ?: 0));
} else {
$stabileId = $canForce ? $forcedStabileId : $this->resolveStabileIdFromParsed($parsed);
}
if (! $stabileId) {
$errors++;
$errorDetails[] = $filename . '::' . $innerName . ' — impossibile determinare lo stabile dalla FE';
continue;
}
if (! $user->hasRole('fornitore') && ! isset($accessibleMap[$stabileId])) {
$errors++;
$errorDetails[] = $filename . '::' . $innerName . ' — stabile non accessibile per lutente';
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' => $forcedFornitoreId > 0,
'force_stabile_id' => (int) $stabileId,
'force_fornitore_id' => $forcedFornitoreId,
'skip_fornitore_sync' => $forcedFornitoreId > 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 (in_array((string) ($result['status'] ?? ''), ['imported', 'duplicate'], true) && is_numeric($result['id'] ?? null)) {
$waterResult = $this->autoLinkWaterReadingsForInvoice((int) $result['id'], (int) $user->id);
if (($waterResult['status'] ?? null) === 'ok') {
$waterCreated += (int) ($waterResult['created_letture'] ?? 0);
$waterUpdated += (int) ($waterResult['updated_letture'] ?? 0);
}
}
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.
if ($this->isSdINotificationDocument($filename, $xml)) {
$skipped++;
$skippedDetails[] = $this->safeUtf8($filename . ' — ricevuta/notifica SdI saltata');
continue;
}
$parsed = $parser->parse($xml);
$forcedFornitoreId = 0;
if ($user->hasRole('fornitore')) {
[$fornitore] = $this->resolveOperatoreContext();
if (! $fornitore instanceof Fornitore) {
throw new \RuntimeException('Profilo fornitore non collegato');
}
if (! $this->supplierMatchesParsedIdentity($fornitore, $parsed)) {
throw new \RuntimeException('Il file non appartiene al fornitore corrente');
}
$forcedFornitoreId = (int) $fornitore->id;
$stabileId = $canForce
? $forcedStabileId
: ($this->resolveSupplierStabileIdFromParsed($fornitore, $parsed) ?: (int) ($activeStabileId ?: 0));
} else {
$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 (! $user->hasRole('fornitore') && ! isset($accessibleMap[$stabileId])) {
throw new \RuntimeException('Stabile non accessibile per lutente');
}
$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' => $forcedFornitoreId > 0,
'force_stabile_id' => $stabileId,
'force_fornitore_id' => $forcedFornitoreId,
'skip_fornitore_sync' => $forcedFornitoreId > 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 (in_array((string) ($result['status'] ?? ''), ['imported', 'duplicate'], true) && is_numeric($result['id'] ?? null)) {
$waterResult = $this->autoLinkWaterReadingsForInvoice((int) $result['id'], (int) $user->id);
if (($waterResult['status'] ?? null) === 'ok') {
$waterCreated += (int) ($waterResult['created_letture'] ?? 0);
$waterUpdated += (int) ($waterResult['updated_letture'] ?? 0);
}
}
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} · Saltate: {$skipped} · Errori: {$errors} · Letture acqua create: {$waterCreated} · aggiornate: {$waterUpdated}" . $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 limport')
->body($this->safeUtf8(implode("\n", array_slice($errorDetails, 0, 10)) . (count($errorDetails) > 10 ? "\n" : '')))
->danger()
->send();
}
if ($skipped > 0 && count($skippedDetails) > 0) {
Notification::make()
->title('File saltati')
->body($this->safeUtf8(implode("\n", array_slice($skippedDetails, 0, 10)) . (count($skippedDetails) > 10 ? "\n" : '')))
->warning()
->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 lutente')->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();
}),
];
}
/**
* @return array<string,mixed>
*/
private function resolveFeModuleStatus(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return app(FeSubjectModuleService::class)->disabled('utente_non_valido');
}
if ($user->hasRole('fornitore')) {
[$fornitore] = $this->resolveOperatoreContext();
return app(FeSubjectModuleService::class)->forFornitore($fornitore instanceof Fornitore ? $fornitore : null);
}
$stabile = StabileContext::getActiveStabile($user);
return app(FeSubjectModuleService::class)->forAmministratore($stabile?->amministratore);
}
private function hasFeModuleEnabled(): bool
{
return (bool) ($this->feModuleStatus['enabled'] ?? false);
}
private function canUseCassettoSource(): bool
{
return in_array('cassetto_fiscale', (array) ($this->feModuleStatus['sources'] ?? []), true);
}
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 feUploadTempDirectory(User $user): string
{
$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';
}
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]);
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$q->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 isSdINotificationDocument(string $xmlFilename, string $xml): bool
{
$filename = strtoupper(trim($xmlFilename));
if ($filename !== '' && preg_match('/_(RC|NS|MC|NE|EC|AT|DT|MT|SE|SM)_\d+\.XML$/', $filename) === 1) {
return true;
}
foreach ([
'<RicevutaConsegna',
'<NotificaScarto',
'<NotificaMancataConsegna',
'<NotificaEsito',
'<MetadatiInvioFile',
'<AttestazioneTrasmissioneFattura',
'<EsitoCommittente',
] as $needle) {
if (str_contains($xml, $needle)) {
return true;
}
}
return false;
}
private function supplierMatchesParsedIdentity(Fornitore $fornitore, array $parsed): bool
{
$invoiceIds = array_values(array_unique(array_filter([
$this->normalizeTaxId((string) ($parsed['fornitore_piva'] ?? '')),
$this->normalizeTaxId((string) ($parsed['fornitore_cf'] ?? '')),
$this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? '')),
$this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? '')),
])));
if ($invoiceIds === []) {
return false;
}
$supplierIds = array_values(array_unique(array_filter([
$this->normalizeTaxId((string) ($fornitore->partita_iva ?? '')),
$this->normalizeTaxId((string) ($fornitore->codice_fiscale ?? '')),
$this->normalizeTaxId((string) ($fornitore->rubrica?->partita_iva ?? '')),
$this->normalizeTaxId((string) ($fornitore->rubrica?->codice_fiscale ?? '')),
])));
foreach ($invoiceIds as $invoiceId) {
if (in_array($invoiceId, $supplierIds, true)) {
return true;
}
}
return false;
}
private function resolveSupplierStabileIdFromParsed(Fornitore $fornitore, array $parsed): int
{
$adminId = (int) ($fornitore->amministratore_id ?? 0);
if ($adminId <= 0) {
return 0;
}
$cf = $this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? ''));
if ($cf !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->whereNotNull('codice_fiscale')
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$codice = $this->normalizeTaxId((string) ($parsed['codice_destinatario'] ?? ''));
if ($codice !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->whereNotNull('codice_destinatario_sdi')
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$pec = trim(strtolower((string) ($parsed['pec_destinatario'] ?? '')));
if ($pec !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->where(function ($query) use ($pec): void {
$query->whereRaw('LOWER(pec_condominio) = ?', [$pec]);
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$query->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
}
})
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$destPiva = $this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? ''));
if ($destPiva !== '') {
$match = Stabile::query()
->select('stabili.id')
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
->where('stabili.amministratore_id', $adminId)
->where('stabili.attivo', true)
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva])
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
return 0;
}
private function normalizeTaxId(string $value): string
{
$normalized = strtoupper(str_replace(' ', '', trim($value)));
if (str_starts_with($normalized, 'IT') && strlen($normalized) > 11) {
$normalized = substr($normalized, 2);
}
return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? '';
}
private function applySupplierVisibilityQuery(Builder $query, Fornitore $fornitore): Builder
{
$supplierId = (int) $fornitore->id;
$taxIds = array_values(array_unique(array_filter([
$this->normalizeTaxId((string) ($fornitore->partita_iva ?? '')),
$this->normalizeTaxId((string) ($fornitore->codice_fiscale ?? '')),
$this->normalizeTaxId((string) ($fornitore->rubrica?->partita_iva ?? '')),
$this->normalizeTaxId((string) ($fornitore->rubrica?->codice_fiscale ?? '')),
])));
return $query->where(function (Builder $builder) use ($supplierId, $taxIds): void {
$builder->where('fornitore_id', $supplierId);
if ($taxIds !== []) {
$builder->orWhereIn('destinatario_piva', $taxIds)
->orWhereIn('destinatario_cf', $taxIds);
}
});
}
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');
}
if ($user->hasRole('fornitore')) {
[$fornitore] = $this->resolveOperatoreContext();
if (! $fornitore instanceof Fornitore) {
return FatturaElettronica::query()->whereRaw('1 = 0');
}
return $this->applySupplierVisibilityQuery(FatturaElettronica::query(), $fornitore);
}
$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')),
]);
}
}