netgescon-day0/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php

571 lines
23 KiB
PHP

<?php
namespace App\Services\FattureElettroniche;
use App\Models\Amministratore;
use App\Models\FeCassettoServiceConfig;
use App\Models\Stabile;
use App\Models\StgFatturaAde;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Support\ArchivioPaths;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class CassettoFiscaleDownloadService
{
/**
* @return array{status: 'ok'|'skipped'|'error', message?: string, log_id?: int, imported?: int, duplicates?: int, errors?: int, files_total?: int}
*/
public function downloadAndImport(
Amministratore $amministratore,
Stabile $stabile,
\DateTimeInterface $dal,
\DateTimeInterface $al,
bool $metadati,
int $userId,
array $opts = [],
): array {
$cf = $this->resolveCodiceFiscaleSoggetto($stabile);
if ($cf === '') {
return ['status' => 'error', 'message' => 'Codice fiscale dello stabile non presente'];
}
if (! $amministratore->fe_cassetto_enabled) {
$adminLabel = trim((string) ($amministratore->codice_amministratore ?? ''));
if ($adminLabel === '') {
$adminLabel = 'ID-' . (int) $amministratore->id;
}
return [
'status' => 'error',
'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')',
];
}
if ($amministratore->fe_cassetto_expires_at) {
try {
$expires = $amministratore->fe_cassetto_expires_at;
if ($expires instanceof \DateTimeInterface) {
$today = new \DateTimeImmutable('today');
$exp = new \DateTimeImmutable($expires->format('Y-m-d'));
if ($exp < $today) {
return [
'status' => 'error',
'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')',
];
}
}
} catch (\Throwable) {
// ignore
}
}
$cfg = FeCassettoServiceConfig::query()->first();
if (! $cfg && ! $amministratore->fe_cassetto_base_url && ! $amministratore->fe_cassetto_username) {
return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)'];
}
$baseUrl = trim((string) ($amministratore->fe_cassetto_base_url ?: ($cfg->base_url ?? '')));
if ($baseUrl === '') {
$baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php';
}
$passwordScript = trim((string) ($amministratore->fe_cassetto_password_script ?: ($cfg?->password_script ?? '')));
$username = trim((string) ($amministratore->fe_cassetto_username ?: ($cfg?->username ?? '')));
$password = trim((string) ($amministratore->fe_cassetto_password ?: ($cfg?->password ?? '')));
$pin = trim((string) ($amministratore->fe_cassetto_pin ?: ($cfg?->pin ?? '')));
// passwordScript: nel servizio esterno viene usato come "token"/licenza.
if ($passwordScript === '') {
return ['status' => 'error', 'message' => 'passwordScript mancante (licenza). Compilarlo nel Super Admin: Servizio Cassetto Fiscale.'];
}
if ($username === '' || $password === '' || $pin === '') {
return ['status' => 'error', 'message' => 'Credenziali servizio incomplete (username/password/pin). Compilarle nel Super Admin: Servizio Cassetto Fiscale.'];
}
$dalStr = $dal->format('d-m-Y');
$alStr = $al->format('d-m-Y');
$requestHash = hash('sha256', implode('|', [
'cf=' . $cf,
'dal=' . $dalStr,
'al=' . $alStr,
'metadati=' . ($metadati ? '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();
$allowSkip = ! (bool) ($opts['force_update'] ?? false);
$allowSkip = $allowSkip && ! (bool) ($opts['no_skip'] ?? false);
if ($existingOk && $allowSkip) {
return [
'status' => 'skipped',
'message' => 'Download già eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')',
'log_id' => (int) $existingOk->id,
];
}
$logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([
'amministratore_id' => (int) $amministratore->id,
'stabile_id' => (int) $stabile->id,
'codice_fiscale_soggetto' => $cf,
'dal' => $dal->format('Y-m-d'),
'al' => $al->format('Y-m-d'),
'metadati' => $metadati ? 1 : 0,
'request_hash' => $requestHash,
'status' => 'started',
'created_at' => now(),
'updated_at' => now(),
]);
try {
$query = [
'passwordScript' => $passwordScript,
'usernameEntratel' => $username,
'passwordEntratel' => $password,
'pinEntratel' => $pin,
'cf' => $cf,
'dal' => $dalStr,
'al' => $alStr,
];
if ($metadati) {
$query['metadati'] = 1;
}
if (! empty($opts['idFiscCedente'])) {
$query['idFiscCedente'] = (string) $opts['idFiscCedente'];
}
$resp = Http::timeout(180)->get($baseUrl, $query);
if (! $resp->successful()) {
$preview = '';
try {
$body = $resp->body();
if (is_string($body) && $body !== '') {
$preview = trim(substr($body, 0, 500));
$preview = preg_replace('/\s+/', ' ', $preview) ?? $preview;
}
} catch (\Throwable) {
// ignore
}
$msg = 'HTTP ' . $resp->status();
$msg .= ' | url=' . $baseUrl;
if ($preview !== '') {
$msg .= ' | preview: ' . $preview;
}
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'status' => 'error',
'message' => $msg,
'updated_at' => now(),
]);
return ['status' => 'error', 'message' => $msg, 'log_id' => $logId];
}
$bytes = $resp->body();
if (! is_string($bytes) || $bytes === '') {
throw new \RuntimeException('Risposta vuota dal servizio');
}
// ZIP signature: PK
if (! str_starts_with($bytes, "PK")) {
$preview = trim(substr($bytes, 0, 500));
throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview);
}
$zipSha = hash('sha256', $bytes);
$ym = now()->format('Y-m');
$adminBase = ArchivioPaths::adminBase($amministratore);
$stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id);
$stabileBase = $adminBase . '/stabili/' . $stabileFolder;
$basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym;
$uuid = (string) Str::uuid();
$zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip';
Storage::disk('local')->put($zipPath, $bytes);
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'zip_sha256' => $zipSha,
'zip_bytes' => strlen($bytes),
'status' => 'downloaded',
'updated_at' => now(),
]);
$tmpDir = $stabileBase . '/temp/fe-cassetto/' . $uuid;
Storage::disk('local')->makeDirectory($tmpDir);
$zipFsPath = Storage::disk('local')->path($zipPath);
$tmpFsPath = Storage::disk('local')->path($tmpDir);
if (! class_exists(\ZipArchive::class)) {
throw new \RuntimeException('Estensione ZIP (ZipArchive) non disponibile sul server');
}
$zip = new \ZipArchive();
if ($zip->open($zipFsPath) !== true) {
throw new \RuntimeException('Impossibile aprire lo ZIP scaricato');
}
$zip->extractTo($tmpFsPath);
$zip->close();
$riepilogo = $this->findRiepilogoJson($tmpFsPath);
$riepilogoStoredPath = null;
$riepilogoWarnings = [];
if ($riepilogo !== null) {
$riepilogoFilename = $riepilogo['filename'];
$riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename;
Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']);
$riepilogoCf = trim((string) ($riepilogo['data']['codiceFiscale'] ?? $riepilogo['data']['cf'] ?? ''));
if ($riepilogoCf !== '' && strtoupper($riepilogoCf) !== strtoupper($cf)) {
$riepilogoWarnings[] = 'CF riepilogo (' . strtoupper($riepilogoCf) . ') diverso da CF richiesta (' . strtoupper($cf) . ')';
}
try {
$this->upsertAdeFromRiepilogo($amministratore, $stabile, $riepilogo['data'], (string) $riepilogoStoredPath);
} catch (\Throwable) {
// ignore: non blocchiamo lo scarico/import FE se la lista AdE non si aggiorna.
}
}
$files = $this->collectFeFiles($tmpFsPath);
$filesTotal = count($files);
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
$extractor = app(P7mExtractor::class);
$imported = 0;
$duplicates = 0;
$errors = 0;
$errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m');
Storage::disk('local')->makeDirectory($errorsDir);
$errorsLogPath = $errorsDir . '/' . $uuid . '-download-errors.jsonl';
// Salva i file scaricati in una cartella stabile XML dentro lo stabile.
$xmlBase = $stabileBase . '/XML';
Storage::disk('local')->makeDirectory($xmlBase);
foreach ($files as $filePath) {
$filename = basename($filePath);
$lower = strtolower($filename);
$bytes = @file_get_contents($filePath);
if (! is_string($bytes) || trim($bytes) === '') {
$errors++;
continue;
}
$unique = (string) Str::uuid();
$storedP7mPath = null;
$storedXmlPath = null;
$isP7m = str_ends_with($lower, '.p7m');
$xml = $isP7m
? null
: $bytes;
if ($isP7m) {
$storedP7mPath = $xmlBase . '/' . $unique . '-' . $filename;
Storage::disk('local')->put($storedP7mPath, $bytes);
try {
$xml = $extractor->extractXmlFromP7m(Storage::disk('local')->path($storedP7mPath));
} catch (\Throwable) {
// fallback: lascia andare a errore
$xml = '';
}
}
if (! is_string($xml) || trim($xml) === '') {
$errors++;
try {
Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'empty_xml',
'filename' => $filename,
'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath,
], JSON_UNESCAPED_SLASHES));
} catch (\Throwable) {
}
continue;
}
$xmlFilename = $isP7m
? (pathinfo($filename, PATHINFO_FILENAME) . (str_ends_with(strtolower(pathinfo($filename, PATHINFO_FILENAME)), '.xml') ? '' : '.xml'))
: $filename;
$storedXmlPath = $xmlBase . '/' . $unique . '-' . $xmlFilename;
Storage::disk('local')->put($storedXmlPath, $xml);
try {
$result = $importer->importXml($xml, 0, $xmlFilename, [
'xml_path' => $storedXmlPath,
'p7m_path' => $storedP7mPath,
'nome_file_p7m' => $isP7m ? $filename : null,
'force_stabile_id' => (int) $stabile->id,
'allow_fallback_stabile' => false,
'force_update' => (bool) ($opts['force_update'] ?? false),
'auto_repair_duplicate' => true,
'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'),
'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true),
'user_id' => $userId,
]);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'imported') {
$imported++;
} elseif ($status === 'duplicate') {
$duplicates++;
} else {
$errors++;
try {
Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'import_error',
'filename' => $filename,
'xml_filename' => $xmlFilename,
'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath,
'result' => $result,
], JSON_UNESCAPED_SLASHES));
} catch (\Throwable) {
}
}
} catch (\Throwable $e) {
$errors++;
try {
Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'exception',
'filename' => $filename,
'xml_filename' => $xmlFilename,
'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath,
'message' => $e->getMessage(),
], JSON_UNESCAPED_SLASHES));
} catch (\Throwable) {
}
}
}
$messageParts = [];
$messageParts[] = 'zip=' . $zipPath;
if ($riepilogo !== null) {
$tot = $riepilogo['data']['totaleFatture'] ?? null;
if (is_int($tot) || (is_string($tot) && ctype_digit($tot))) {
$messageParts[] = 'riepilogo_totaleFatture=' . (int) $tot;
}
if (is_string($riepilogoStoredPath) && $riepilogoStoredPath !== '') {
$messageParts[] = 'riepilogo_json=' . $riepilogoStoredPath;
}
}
foreach ($riepilogoWarnings as $w) {
$messageParts[] = 'WARN: ' . $w;
}
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'files_total' => $filesTotal,
'imported' => $imported,
'duplicates' => $duplicates,
'errors' => $errors,
'status' => 'ok',
'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null,
'updated_at' => now(),
]);
return [
'status' => 'ok',
'log_id' => $logId,
'files_total' => $filesTotal,
'imported' => $imported,
'duplicates' => $duplicates,
'errors' => $errors,
];
} catch (\Throwable $e) {
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'status' => 'error',
'message' => $e->getMessage(),
'updated_at' => now(),
]);
return ['status' => 'error', 'message' => $e->getMessage(), 'log_id' => $logId];
}
}
/**
* @return list<string>
*/
private function collectFeFiles(string $rootDir): array
{
$out = [];
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
}
$name = $file->getFilename();
$lower = strtolower($name);
// File di supporto del portale (non sono fatture elettroniche).
if (str_starts_with($lower, 'informazioni_associate_') && str_ends_with($lower, '.xml')) {
continue;
}
// Importiamo XML e P7M (P7M verranno estratti a XML).
if (str_ends_with($lower, '.xml') || str_ends_with($lower, '.p7m')) {
$out[] = $file->getPathname();
}
}
sort($out);
return $out;
}
private function resolveCodiceFiscaleSoggetto(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
}
}
$cf = strtoupper(trim($cf));
return $cf;
}
/**
* Cerca un file JSON riepilogo (con chiave totaleFatture) all'interno dello ZIP estratto.
*
* @return array{filename: string, contents: string, data: array<string, mixed>}|null
*/
private function findRiepilogoJson(string $rootDir): ?array
{
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
}
$name = $file->getFilename();
$lower = strtolower($name);
if (! str_ends_with($lower, '.json')) {
continue;
}
$contents = @file_get_contents($file->getPathname());
if (! is_string($contents) || trim($contents) === '') {
continue;
}
$decoded = json_decode($contents, true);
if (! is_array($decoded)) {
continue;
}
if (! array_key_exists('totaleFatture', $decoded)) {
continue;
}
return [
'filename' => $name,
'contents' => $contents,
'data' => $decoded,
];
}
return null;
}
private function upsertAdeFromRiepilogo(Amministratore $amministratore, Stabile $stabile, array $data, string $sourceFile): void
{
$fatture = $data['fatture'] ?? null;
if (! is_array($fatture)) {
return;
}
foreach ($fatture as $row) {
if (! is_array($row)) {
continue;
}
$sdi = trim((string) (($row['fileDownload']['idInvio'] ?? null) ?? ($row['idInvio'] ?? null) ?? ($row['idFattura'] ?? null)));
if ($sdi === '') {
continue;
}
$date = null;
$d = trim((string) ($row['dataFattura'] ?? ''));
if ($d !== '') {
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null;
}
$imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0'));
$imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0'));
StgFatturaAde::query()->updateOrCreate(
[
'amministratore_id' => (int) $amministratore->id,
'stabile_id' => (int) $stabile->id,
'sdi_file' => $sdi,
],
[
'cartella_legacy' => null,
'tipo_documento' => (string) ($row['tipoDocumento'] ?? null),
'numero_documento' => (string) ($row['numeroFattura'] ?? null),
'data_emissione' => $date ? $date->format('Y-m-d') : null,
'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))),
'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null),
'imponibile' => $imponibile,
'imposta' => $imposta,
'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null),
'source_file' => $sourceFile,
'raw' => $row,
]
);
}
}
private function parseAdeMoney(string $value): float
{
$v = trim($value);
if ($v === '') {
return 0.0;
}
// Esempi: "+000000000828,00" oppure "828,00".
$v = str_replace(['.', ' '], ['', ''], $v);
$v = str_replace(',', '.', $v);
$v = preg_replace('/[^0-9+\-\.]/', '', $v) ?? $v;
return is_numeric($v) ? (float) $v : 0.0;
}
}