559 lines
22 KiB
PHP
559 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Stabile;
|
|
use App\Models\StgFatturaAde;
|
|
use App\Support\ArchivioPaths;
|
|
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
|
|
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
|
|
use App\Services\FattureElettroniche\P7mExtractor;
|
|
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);
|
|
|
|
$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;
|
|
|
|
$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)');
|
|
}
|
|
|
|
$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');
|
|
}
|
|
|
|
$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');
|
|
$errorDetails[] = $filename . ' — ' . $msg;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$errors++;
|
|
$errorDetails[] = $filename . ' — ' . $e->getMessage();
|
|
|
|
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 . ' — ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$message = "source=local\npath={$path}";
|
|
if ($messageParts) {
|
|
$message .= "\n" . implode("\n", $messageParts);
|
|
}
|
|
|
|
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 . ' 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 {
|
|
if ($sourceIsZip) {
|
|
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): 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;
|
|
}
|
|
|
|
if (str_ends_with($lower, '.xml') || str_ends_with($lower, '.p7m')) {
|
|
$out[] = $file->getPathname();
|
|
}
|
|
}
|
|
|
|
sort($out);
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|