571 lines
23 KiB
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;
|
|
}
|
|
}
|