netgescon-day0/app/Console/Commands/FeCassettoImportLocal.php

640 lines
26 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use App\Models\Stabile;
use App\Models\StgFatturaAde;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Support\ArchivioPaths;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FeCassettoImportLocal extends Command
{
/** @var string */
protected $signature = 'fe:cassetto-import-local
{stabile_id : ID dello stabile NetGescon}
{path : Percorso locale a directory (XML/P7M/JSON) o ZIP}
{--user_id=0 : User ID (audit)}
{--metadati=0 : 1 per metadati}
{--dal= : Data inizio (YYYY-MM-DD). Se omessa prova a leggerla dal riepilogo JSON}
{--al= : Data fine (YYYY-MM-DD). Se omessa prova a leggerla dal riepilogo JSON}
{--force_update=0 : Forza update su duplicate}
{--importa_righe=auto : auto|si|no}
{--create_fornitore_if_missing=1 : Crea fornitore se mancante}
{--no_skip=0 : 1 per rieseguire anche se esiste già un log ok per lo stesso hash}
{--only_files= : Lista file (basename) separati da virgola da importare (subset)}
{--print_errors=1 : 1 per stampare dettagli errori a fine run}
';
/** @var string */
protected $description = 'Importa fatture elettroniche da un download locale (directory o ZIP) del Cassetto Fiscale';
public function handle(): int
{
$stabileId = (int) $this->argument('stabile_id');
$path = (string) $this->argument('path');
/** @var Stabile|null $stabile */
$stabile = Stabile::query()->with('amministratore')->find($stabileId);
if (! $stabile) {
$this->error('Stabile non trovato: ID=' . $stabileId);
return self::FAILURE;
}
$amministratore = $stabile->amministratore;
if (! $amministratore) {
$this->error('Amministratore non trovato per stabile ID=' . $stabileId);
return self::FAILURE;
}
$metadati = (bool) ((int) $this->option('metadati') === 1);
$userId = (int) $this->option('user_id');
$dal = $this->parseDateYmd((string) $this->option('dal'));
$al = $this->parseDateYmd((string) $this->option('al'));
$sourceIsZip = is_file($path) && str_ends_with(strtolower($path), '.zip');
$sourceIsDir = is_dir($path);
if (! $sourceIsZip && ! $sourceIsDir) {
$this->error('Path non valido (atteso directory o .zip): ' . $path);
return self::FAILURE;
}
$uuid = (string) Str::uuid();
$ym = now()->format('Y-m');
$adminBase = ArchivioPaths::adminBase($amministratore);
$stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id);
$stabileBase = $adminBase . '/stabili/' . $stabileFolder;
$baseDownloads = $stabileBase . '/fatture-ricevute/downloads/' . $ym;
Storage::disk('local')->makeDirectory($baseDownloads);
$tmpDir = $stabileBase . '/temp/fe-cassetto-local/' . $uuid;
Storage::disk('local')->makeDirectory($tmpDir);
$tmpFsPath = Storage::disk('local')->path($tmpDir);
try {
if ($sourceIsZip) {
if (! class_exists(\ZipArchive::class)) {
throw new \RuntimeException('Estensione ZIP (ZipArchive) non disponibile sul server');
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
throw new \RuntimeException('Impossibile aprire ZIP: ' . $path);
}
$zip->extractTo($tmpFsPath);
$zip->close();
} else {
// Directory: lavoriamo direttamente sul path (nessuna copia).
$tmpFsPath = $path;
}
$riepilogo = $this->findRiepilogoJson($tmpFsPath);
if ($riepilogo && (! $dal || ! $al)) {
$maybeDal = $this->parseDateDmy((string) ($riepilogo['data']['dataRicercaDa'] ?? ''));
$maybeAl = $this->parseDateDmy((string) ($riepilogo['data']['dataRicercaA'] ?? ''));
$dal = $dal ?: $maybeDal;
$al = $al ?: $maybeAl;
}
if (! $dal || ! $al) {
throw new \RuntimeException('Date dal/al mancanti: passale come --dal=YYYY-MM-DD --al=YYYY-MM-DD oppure assicurati che il riepilogo JSON contenga dataRicercaDa/dataRicercaA');
}
$cf = $this->resolveCodiceFiscaleSoggetto($stabile);
if ($cf === '') {
throw new \RuntimeException('Codice fiscale dello stabile non presente (neanche in rubrica)');
}
$requestHash = hash('sha256', implode('|', [
'local=1',
'path=' . $path,
'cf=' . $cf,
'dal=' . $dal->format('Y-m-d'),
'al=' . $al->format('Y-m-d'),
'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();
$noSkip = (int) $this->option('no_skip') === 1;
if ($existingOk && ! $noSkip) {
$this->warn('Già importato (stesso hash): log #' . (int) $existingOk->id);
return self::SUCCESS;
}
$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',
'message' => 'source=local\npath=' . $path,
'created_at' => now(),
'updated_at' => now(),
]);
$riepilogoStoredPath = null;
$messageParts = [];
if ($riepilogo) {
$riepilogoStoredPath = $baseDownloads . '/' . $uuid . '-' . $riepilogo['filename'];
Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']);
$tot = $riepilogo['data']['totaleFatture'] ?? null;
if (is_int($tot) || (is_string($tot) && ctype_digit($tot))) {
$messageParts[] = 'riepilogo_totaleFatture=' . (int) $tot;
}
$messageParts[] = 'riepilogo_json=' . $riepilogoStoredPath;
$riepilogoCf = trim((string) ($riepilogo['data']['codiceFiscale'] ?? $riepilogo['data']['cf'] ?? ''));
if ($riepilogoCf !== '' && strtoupper($riepilogoCf) !== strtoupper($cf)) {
$messageParts[] = 'WARN: CF riepilogo (' . strtoupper($riepilogoCf) . ') diverso da CF stabile (' . strtoupper($cf) . ')';
}
try {
$this->upsertAdeFromRiepilogo((int) $amministratore->id, (int) $stabile->id, $riepilogo['data'], (string) $riepilogoStoredPath);
} catch (\Throwable) {
// ignore
}
}
$files = $this->collectFeFiles(
$tmpFsPath,
$sourceIsDir ? Storage::disk('local')->path($tmpDir) : null
);
$onlyFilesOpt = trim((string) ($this->option('only_files') ?? ''));
$onlyFiles = [];
if ($onlyFilesOpt !== '') {
foreach (explode(',', $onlyFilesOpt) as $p) {
$p = trim((string) $p);
if ($p !== '') {
$onlyFiles[$p] = true;
}
}
$files = array_values(array_filter($files, function (string $full) use ($onlyFiles): bool {
$b = basename($full);
return isset($onlyFiles[$b]);
}));
}
$filesTotal = count($files);
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
$extractor = app(P7mExtractor::class);
$imported = 0;
$duplicates = 0;
$errors = 0;
$skippedNonInvoice = 0;
$skippedRecipientMismatch = 0;
$errorDetails = [];
$errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m');
Storage::disk('local')->makeDirectory($errorsDir);
// Salva i file in una cartella stabile XML dentro lo stabile.
$xmlBase = $stabileBase . '/XML';
Storage::disk('local')->makeDirectory($xmlBase);
foreach ($files as $fileFsPath) {
$filename = basename($fileFsPath);
$lower = strtolower($filename);
$permanentP7mPath = null;
$permanentXmlPath = null;
try {
$bytes = @file_get_contents($fileFsPath);
if (! is_string($bytes) || $bytes === '') {
throw new \RuntimeException('File vuoto');
}
$isP7m = str_ends_with($lower, '.p7m');
if ($isP7m) {
$permanentP7mPath = $xmlBase . '/' . $filename;
if (! Storage::disk('local')->exists($permanentP7mPath)) {
Storage::disk('local')->put($permanentP7mPath, $bytes);
}
$xml = $extractor->extractXmlFromP7m(Storage::disk('local')->path($permanentP7mPath));
if (! is_string($xml) || trim($xml) === '') {
throw new \RuntimeException('XML non valido/assente (estratto da P7M)');
}
if (! $this->isFatturaElettronicaPayload($xml)) {
$skippedNonInvoice++;
continue;
}
$xmlFilename = pathinfo($filename, PATHINFO_FILENAME);
// Gestisce *.xml.p7m => *.xml
if (! str_ends_with(strtolower($xmlFilename), '.xml')) {
$xmlFilename .= '.xml';
}
$permanentXmlPath = $xmlBase . '/' . $xmlFilename;
$forceUpdate = (int) $this->option('force_update') === 1;
$shouldWriteXml = $forceUpdate || ! Storage::disk('local')->exists($permanentXmlPath);
if (! $shouldWriteXml) {
try {
$size = (int) (Storage::disk('local')->size($permanentXmlPath) ?? 0);
if ($size < 2000) {
$shouldWriteXml = true;
} else {
$existing = Storage::disk('local')->get($permanentXmlPath);
if (! is_string($existing) || ! preg_match('/<\s*\/\s*(?:[a-z0-9_\-]+:)?FatturaElettronica\s*>/i', $existing)) {
$shouldWriteXml = true;
}
}
} catch (\Throwable) {
$shouldWriteXml = true;
}
}
if ($shouldWriteXml) {
Storage::disk('local')->put($permanentXmlPath, $xml);
}
$result = $importer->importXml($xml, 0, $xmlFilename, [
'xml_path' => $permanentXmlPath,
'p7m_path' => $permanentP7mPath,
'nome_file_p7m' => $filename,
'force_stabile_id' => (int) $stabile->id,
'allow_fallback_stabile' => false,
'force_update' => (bool) ((int) $this->option('force_update') === 1),
'auto_repair_duplicate' => true,
'importa_righe' => (string) ($this->option('importa_righe') ?? 'auto'),
'create_fornitore_if_missing' => (bool) ((int) $this->option('create_fornitore_if_missing') === 1),
'user_id' => $userId,
]);
} else {
$xml = $bytes;
if (! is_string($xml) || trim($xml) === '') {
throw new \RuntimeException('XML non valido/assente');
}
if (! $this->isFatturaElettronicaPayload($xml)) {
$skippedNonInvoice++;
continue;
}
$permanentXmlPath = $xmlBase . '/' . $filename;
if (! Storage::disk('local')->exists($permanentXmlPath)) {
Storage::disk('local')->put($permanentXmlPath, $xml);
}
$result = $importer->importXml($xml, 0, $filename, [
'xml_path' => $permanentXmlPath,
'force_stabile_id' => (int) $stabile->id,
'allow_fallback_stabile' => false,
'force_update' => (bool) ((int) $this->option('force_update') === 1),
'auto_repair_duplicate' => true,
'importa_righe' => (string) ($this->option('importa_righe') ?? 'auto'),
'create_fornitore_if_missing' => (bool) ((int) $this->option('create_fornitore_if_missing') === 1),
'user_id' => $userId,
]);
}
$status = (string) ($result['status'] ?? 'error');
if ($status === 'imported') {
$imported++;
} elseif ($status === 'duplicate') {
$duplicates++;
} else {
$errors++;
$msg = (string) ($result['message'] ?? 'Errore import');
if ($this->isRecipientMismatchMessage($msg)) {
$errors--;
$skippedRecipientMismatch++;
continue;
}
$errorDetails[] = $filename . ' — ' . $msg;
}
} catch (\Throwable $e) {
$errors++;
$message = $e->getMessage();
if ($this->isRecipientMismatchMessage($message)) {
$errors--;
$skippedRecipientMismatch++;
continue;
}
$errorDetails[] = $filename . ' — ' . $message;
try {
// Salva una copia del file problematico per debug.
$bytesForDebug = @file_get_contents($fileFsPath);
if (is_string($bytesForDebug) && $bytesForDebug !== '') {
$debugPath = $errorsDir . '/' . (string) Str::uuid() . '-' . $filename;
Storage::disk('local')->put($debugPath, $bytesForDebug);
}
} catch (\Throwable) {
// ignore
}
$this->warn($filename . ' — ' . $message);
}
}
$message = "source=local\npath={$path}";
if ($messageParts) {
$message .= "\n" . implode("\n", $messageParts);
}
$message .= "\nskipped_non_invoice={$skippedNonInvoice}";
$message .= "\nskipped_recipient_mismatch={$skippedRecipientMismatch}";
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'files_total' => $filesTotal,
'imported' => $imported,
'duplicates' => $duplicates,
'errors' => $errors,
'status' => 'ok',
'message' => $message,
'updated_at' => now(),
]);
$this->info('OK. log #' . $logId . ' | files=' . $filesTotal . ' imported=' . $imported . ' dup=' . $duplicates . ' skipped=' . ($skippedNonInvoice + $skippedRecipientMismatch) . ' err=' . $errors);
if ($riepilogoStoredPath) {
$this->line('Riepilogo salvato: ' . $riepilogoStoredPath);
}
if ((int) $this->option('print_errors') === 1 && $errorDetails) {
$this->line('--- Errori (' . count($errorDetails) . ') ---');
foreach (array_slice($errorDetails, 0, 50) as $line) {
$this->line($line);
}
if (count($errorDetails) > 50) {
$this->line('... (altri ' . (count($errorDetails) - 50) . ')');
}
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
} finally {
// Se era ZIP, pulisci temp estrazione.
try {
Storage::disk('local')->deleteDirectory($tmpDir);
} catch (\Throwable) {
// ignore
}
}
}
private function parseDateYmd(string $value): ?\DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
return null;
}
return \DateTimeImmutable::createFromFormat('Y-m-d', $value) ?: null;
}
private function parseDateDmy(string $value): ?\DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
return null;
}
return \DateTimeImmutable::createFromFormat('d/m/Y', $value) ?: null;
}
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;
}
/**
* @return list<string>
*/
private function collectFeFiles(string $rootDir, ?string $nestedZipExtractRoot = null): array
{
$out = [];
$directories = [$rootDir];
$visited = [];
while ($directories !== []) {
$currentDir = array_shift($directories);
if (! is_string($currentDir) || $currentDir === '' || isset($visited[$currentDir])) {
continue;
}
$visited[$currentDir] = true;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($currentDir, \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;
}
if (str_ends_with($lower, '.xml') || str_ends_with($lower, '.p7m')) {
$out[] = $file->getPathname();
continue;
}
if (
$nestedZipExtractRoot !== null
&& class_exists(\ZipArchive::class)
&& str_ends_with($lower, '.zip')
) {
$extractDir = rtrim($nestedZipExtractRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . (string) Str::uuid();
if (! @mkdir($extractDir, 0777, true) && ! is_dir($extractDir)) {
continue;
}
$zip = new \ZipArchive();
if ($zip->open($file->getPathname()) !== true) {
continue;
}
$zip->extractTo($extractDir);
$zip->close();
$directories[] = $extractDir;
}
}
}
$out = array_values(array_unique($out));
sort($out);
return $out;
}
private function isFatturaElettronicaPayload(string $xml): bool
{
$xml = trim($xml);
if ($xml === '') {
return false;
}
return preg_match('/<(?:[a-z0-9_\-]+:)?FatturaElettronica\b/i', $xml) === 1
&& preg_match('/<(?:[a-z0-9_\-]+:)?FatturaElettronicaBody\b/i', $xml) === 1;
}
private function isRecipientMismatchMessage(string $message): bool
{
return str_contains($message, 'Impossibile determinare lo stabile dalla FE');
}
/**
* @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(int $amministratoreId, int $stabileId, 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' => $amministratoreId,
'stabile_id' => $stabileId,
'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;
}
$v = str_replace(['.', ' '], ['', ''], $v);
$v = str_replace(',', '.', $v);
$v = preg_replace('/[^0-9+\-\.]/', '', $v) ?? $v;
return is_numeric($v) ? (float) $v : 0.0;
}
}