argument('tenant'); $mdb = (string) ($this->option('mdb') ?: ''); $table = (string) ($this->option('table') ?: 'Fatture'); $into = (string) ($this->option('into') ?: 'stg_fatture_gescon'); if ($mdb === '' || !is_file($mdb)) { $this->error('File MDB non trovato. Passa --mdb=/path/generale_stabile.mdb'); return self::FAILURE; } if (!DB::getSchemaBuilder()->hasTable($into)) { $this->error("Tabella staging non trovata: {$into} (esegui migrate)"); return self::FAILURE; } $bin = trim((string) @shell_exec('command -v mdb-export')) ?: ''; if ($bin === '') { $this->error('mdb-export non trovato (installa mdbtools)'); return self::FAILURE; } $csv = $this->runShell(sprintf( "%s -D %s -d %s -q %s -R \"\\n\" %s %s", escapeshellarg($bin), escapeshellarg('%Y-%m-%d %H:%M:%S'), escapeshellarg('|'), escapeshellarg('"'), escapeshellarg($mdb), escapeshellarg($table), )); if ($csv === null || trim($csv) === '') { $this->error('Nessun dato esportato (tabella inesistente o file non leggibile)'); return self::FAILURE; } $rows = $this->parseDelimited($csv, '|'); if ($this->option('preview')) { $limit = max(1, (int) $this->option('limit')); $this->line('Preview:'); foreach (array_slice($rows, 0, $limit) as $r) { $this->line(json_encode($this->mapRow($tenant, $mdb, $r), JSON_UNESCAPED_UNICODE)); } $this->line('Totale righe: ' . count($rows)); return self::SUCCESS; } if ($this->option('truncate')) { DB::table($into)->truncate(); } $now = now(); $payloads = []; $count = 0; foreach ($rows as $row) { $payload = $this->mapRow($tenant, $mdb, $row); if ($payload['legacy_id_fatture'] === null) { continue; } $payload['created_at'] = $now; $payload['updated_at'] = $now; $payloads[] = $payload; if (count($payloads) >= 500) { DB::table($into)->upsert($payloads, ['tenant_id', 'legacy_id_fatture'], [ 'legacy_id_stabile', 'riferimento', 'cod_fornitore', 'data_fattura', 'data_pagamento', 'numero_fattura', 'descrizione_sintetica', 'descrizione_corpo', 'imponibile', 'importo_iva', 'totale_fattura', 'importo_netto', 'onorario', 'rimborsi', 'aliq_cassa', 'importo_cassa', 'aliq_rda', 'importo_rda', 'aliq_inps', 'importo_inps', 'aliq_iva', 'legacy_id_anno', 'reg_gestione', 'reg_nstra', 'note', 'raw', 'sorgente_archivio', 'updated_at', ]); $count += count($payloads); $payloads = []; } } if (!empty($payloads)) { DB::table($into)->upsert($payloads, ['tenant_id', 'legacy_id_fatture'], [ 'legacy_id_stabile', 'riferimento', 'cod_fornitore', 'data_fattura', 'data_pagamento', 'numero_fattura', 'descrizione_sintetica', 'descrizione_corpo', 'imponibile', 'importo_iva', 'totale_fattura', 'importo_netto', 'onorario', 'rimborsi', 'aliq_cassa', 'importo_cassa', 'aliq_rda', 'importo_rda', 'aliq_inps', 'importo_inps', 'aliq_iva', 'legacy_id_anno', 'reg_gestione', 'reg_nstra', 'note', 'raw', 'sorgente_archivio', 'updated_at', ]); $count += count($payloads); } $this->info("✅ Import completato: {$count} righe elaborate (upsert)"); return self::SUCCESS; } private function runShell(string $cmd): ?string { $out = shell_exec($cmd . ' 2>/dev/null'); if ($out === null || $out === '') { return null; } return $out; } private function parseDelimited(string $text, string $sep): array { $lines = preg_split("/\r?\n/", trim($text)); if (!$lines || count($lines) === 0) { return []; } $headers = str_getcsv(array_shift($lines), $sep); $headers = array_map(fn($h) => strtolower(trim((string) $h)), $headers); $rows = []; foreach ($lines as $line) { if ($line === '') { continue; } $cols = str_getcsv($line, $sep); if (count($cols) < count($headers)) { $cols = array_pad($cols, count($headers), null); } if (count($cols) > count($headers)) { $cols = array_slice($cols, 0, count($headers)); } $row = @array_combine($headers, $cols); if (!$row) { continue; } $rows[] = $row; } return $rows; } private function mapRow(string $tenant, string $mdb, array $row): array { $get = function (string ...$keys) use ($row) { foreach ($keys as $k) { $lk = strtolower($k); if (array_key_exists($lk, $row)) { $v = $row[$lk]; return is_string($v) ? trim($v) : $v; } } return null; }; $legacyId = $this->toInt($get('id_fatture', 'id_fattura', 'id')); $raw = $row; return [ 'tenant_id' => $tenant, 'sorgente_archivio' => $mdb, 'legacy_id_fatture' => $legacyId, 'legacy_id_stabile' => $this->toInt($get('id_stabile')), 'riferimento' => $this->toInt($get('riferimento')), 'cod_fornitore' => $this->toInt($get('cod_fornitore', 'cod_fornitore')), 'data_fattura' => $this->toDate($get('data_fattura')), 'data_pagamento' => $this->toDate($get('data_pagamento')), 'numero_fattura' => $this->nullIfEmpty($get('num_fattura', 'numero_fattura')), 'descrizione_sintetica' => $this->nullIfEmpty($get('descrizione_sintetica')), 'descrizione_corpo' => $this->nullIfEmpty($get('descriz_corpo', 'descrizione_corpo')), 'imponibile' => $this->money($get('imponibile') ?? 0), 'importo_iva' => $this->money($get('importo_iva') ?? 0), 'totale_fattura' => $this->money($get('totale_fattura') ?? 0), 'importo_netto' => $this->money($get('importo_netto') ?? 0), 'onorario' => $this->money($get('onorario') ?? 0), 'rimborsi' => $this->money($get('rimborsi') ?? 0), 'aliq_cassa' => $this->decimal($get('aliq_cassa')), 'importo_cassa' => $this->money($get('importo_cassa') ?? 0), 'aliq_rda' => $this->decimal($get('aliq_rda')), 'importo_rda' => $this->money($get('importo_rda') ?? 0), 'aliq_inps' => $this->decimal($get('aliq_inps')), 'importo_inps' => $this->money($get('importo_inps') ?? 0), 'aliq_iva' => $this->decimal($get('aliq_iva')), 'legacy_id_anno' => $this->toInt($get('id_anno')), 'reg_gestione' => $this->nullIfEmpty($get('reg_gestione')), 'reg_nstra' => $this->toInt($get('reg_nstra')), 'note' => $this->nullIfEmpty($get('note')), 'raw' => json_encode($raw, JSON_UNESCAPED_UNICODE), ]; } private function nullIfEmpty($v): ?string { if ($v === null) { return null; } $s = trim((string) $v); return $s === '' ? null : $s; } private function toInt($v): ?int { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '' || !is_numeric($s)) { return null; } return (int) $s; } private function toDate($v): ?string { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}/', $s)) { return substr($s, 0, 10); } if (preg_match('/^\d{2}\/\d{2}\/\d{2} /', $s)) { $dt = \DateTime::createFromFormat('m/d/y H:i:s', $s); if (!$dt) { return null; } // Fix 2-digit years (e.g. 69 should be 1969, not 2069) $yy = (int) $dt->format('y'); $curYY = (int) now()->format('y'); $year = ($yy > $curYY) ? (1900 + $yy) : (2000 + $yy); return sprintf('%04d-%02d-%02d', $year, (int) $dt->format('m'), (int) $dt->format('d')); } if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $s)) { $dt = \DateTime::createFromFormat('d/m/Y', $s); return $dt?->format('Y-m-d'); } return null; } private function money($v): float { if ($v === null || $v === '') { return 0.0; } $s = trim((string) $v); if ($s === '') { return 0.0; } // Remove spaces (including non-breaking) $s = str_replace([" ", "\u{00A0}"], '', $s); // If it looks like a number with dots as thousand separators and comma as decimal: // e.g. 1.234.567,89 if (preg_match('/^-?\d{1,3}(?:\.\d{3})+(?:,\d+)?$/', $s)) { $s = str_replace('.', '', $s); $s = str_replace(',', '.', $s); return (float) $s; } // If it looks like a number with commas as thousand separators and dot as decimal: // e.g. 1,234,567.89 if (preg_match('/^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/', $s)) { $s = str_replace(',', '', $s); return (float) $s; } // Otherwise: treat comma as decimal separator if present, keep dot as decimal. // This correctly handles MDB exports like 79.95999999999999 if (str_contains($s, ',') && !str_contains($s, '.')) { $s = str_replace(',', '.', $s); } elseif (str_contains($s, ',') && str_contains($s, '.')) { // Mixed but not matching a clear thousands pattern: assume comma is decimal and dots are thousands $s = str_replace('.', '', $s); $s = str_replace(',', '.', $s); } // Strip anything not number-ish $s = preg_replace('/[^0-9\-\.]/', '', $s) ?? ''; if ($s === '' || $s === '-' || $s === '.') { return 0.0; } return (float) $s; } private function decimal($v): ?float { if ($v === null || $v === '') { return null; } return $this->money($v); } }