> */ private array $columnCache = []; public function handle(): int { $amministratoreId = (int) $this->argument('amministratore_id'); $mdb = (string) $this->option('mdb'); $table = (string) $this->option('table'); $dry = (bool) $this->option('dry-run'); $limit = max(0, (int) $this->option('limit')); if ($amministratoreId <= 0) { $this->error('amministratore_id non valido'); return self::FAILURE; } if ($mdb === '' || ! is_file($mdb)) { $this->error("File MDB non trovato: {$mdb}"); 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; } // NOTE: alcuni archivi GESCON contengono doppie-virgolette non escape-ate nei campi. // Se usiamo enclosure '"', l'output CSV diventa invalido e fgetcsv() può "mangiare" righe. // Usiamo quindi un enclosure alternativo (^) per rendere robusto il parsing. $cmd = sprintf( '%s -D %s -q %s %s %s', escapeshellarg($bin), escapeshellarg('%Y-%m-%d %H:%M:%S'), escapeshellarg('^'), escapeshellarg($mdb), escapeshellarg($table), ); $csv = @shell_exec($cmd . ' 2>/dev/null'); if (! is_string($csv) || trim($csv) === '') { $this->error('Nessun dato esportato (tabella inesistente o file non leggibile)'); return self::FAILURE; } [$rows, $headers] = $this->parseCsv($csv); if (empty($rows)) { $this->warn('Tabella vuota: nessun fornitore da importare'); return self::SUCCESS; } if ($limit > 0) { $rows = array_slice($rows, 0, $limit); } $this->info('Colonne rilevate: ' . implode(', ', array_slice($headers, 0, 20)) . (count($headers) > 20 ? ' ...' : '')); $stats = [ 'rows' => 0, 'created_rubrica' => 0, 'updated_rubrica' => 0, 'created_fornitori' => 0, 'updated_fornitori' => 0, 'created_dipendenti' => 0, 'updated_dipendenti' => 0, 'skipped' => 0, ]; $runner = function () use (&$stats, $rows, $amministratoreId, $dry): void { foreach ($rows as $row) { $stats['rows']++; $codForn = $this->toInt($row['cod_forn'] ?? null); $legacyId = $codForn; $legacyIdFornitore = $this->toInt($row['id_fornitore'] ?? null); $ragione = $this->clean($row['denominazione'] ?? null); // NB: `appoggio` in GESCON è un campo di servizio e spesso contiene blob di contatti. // Non deve mai diventare la ragione sociale. $appoggio = $this->clean($row['appoggio'] ?? null); // Alcuni archivi hanno il campo denominazione sporco (es. "Tel.: ... Cell. ..."). // In quel caso NON deve finire come ragione sociale. $contactBlob = null; if (is_string($ragione) && $ragione !== '' && $this->looksLikeContactBlob($ragione)) { $contactBlob = $ragione; $ragione = null; } $cognome = $this->clean($this->pick($row, ['cognome', 'cognome_fornitore'])); $nome = $this->clean($this->pick($row, ['nome', 'nome_fornitore'])); if ($ragione === null) { $full = trim(($cognome ?? '') . ' ' . ($nome ?? '')); $ragione = $full !== '' ? $full : null; } $legacyDtNas = $this->normalizeLegacyDate($this->pick($row, ['dt_nas', 'data_nascita', 'dt_nascita'])); $legacyLuoNas = $this->clean($this->pick($row, ['luo_nas', 'luogo_nascita'])); $legacyPrNas = $this->clean($this->pick($row, ['pr_nas', 'prov_nascita', 'provincia_nascita'])); $legacySesso = $this->normalizeSesso($this->pick($row, ['sesso', 'sex'])); $legacyTitoloRaw = $this->clean($this->pick($row, ['titolo', 'titolo_fornitore', 'tit'])); $legacyEmail = $this->clean($this->pick($row, ['indir_email', 'email', 'mail'])); $legacyIntestazioneCcEsatta = $this->clean($this->pick($row, [ 'intestaz_cc_esatta', 'intestaz_cc_esatto', 'intestazione_cc_esatta', 'intestatario_cc', 'intestatario_conto', ])); $note = $this->clean($row['note'] ?? null); if ($contactBlob) { $note = $note ? ($note . "\n" . $contactBlob) : $contactBlob; } if ($appoggio) { $note = $note ? ($note . "\n" . 'Appoggio: ' . $appoggio) : ('Appoggio: ' . $appoggio); } $legacyIdentityBits = array_values(array_filter([ $legacyTitoloRaw ? ('Titolo: ' . $legacyTitoloRaw) : null, $legacySesso ? ('Sesso: ' . $legacySesso) : null, $legacyDtNas ? ('Data nascita: ' . $legacyDtNas) : null, $legacyLuoNas ? ('Luogo nascita: ' . $legacyLuoNas) : null, $legacyPrNas ? ('Provincia nascita: ' . $legacyPrNas) : null, ])); $piva = $this->clean($row['p_iva'] ?? null); $cf = $this->clean($row['cod_fisc'] ?? null); if ($legacyId === null && $codForn === null && $ragione === null && $piva === null && $cf === null) { $stats['skipped']++; continue; } $rubricaPayload = [ 'amministratore_id' => $amministratoreId, 'ragione_sociale' => $ragione, 'nome' => $nome, 'cognome' => $cognome, 'partita_iva' => $piva, 'codice_fiscale' => $cf, 'sesso' => $legacySesso, 'data_nascita' => $legacyDtNas, 'luogo_nascita' => $legacyLuoNas, 'provincia_nascita' => $legacyPrNas, 'indirizzo' => $this->clean($row['indirizzo'] ?? null), 'cap' => $this->clean($row['cap'] ?? null), 'citta' => $this->clean($row['citta'] ?? null), 'provincia' => $this->clean($row['pr'] ?? null), 'telefono_ufficio' => $this->clean($row['telef_1'] ?? $row['telef_2'] ?? null), 'telefono_cellulare' => $this->clean($row['cellulare'] ?? null), 'fax' => $this->clean($row['fax'] ?? null), 'email' => $legacyEmail, 'pec' => $this->clean($row['pec_fornitore'] ?? null), 'note' => $note, 'categoria' => 'fornitore', 'stato' => 'attivo', 'tipo_contatto' => ($piva || ($ragione && ! $nome && ! $cognome)) ? 'persona_giuridica' : 'persona_fisica', ]; $titoloId = $this->resolveTitoloId($legacyTitoloRaw); if ($titoloId !== null) { $rubricaPayload['titolo_id'] = $titoloId; } $rubricaPayload = $this->filterPayloadForTable('rubrica_universale', $rubricaPayload); $rubricaResult = $this->upsertRubrica($rubricaPayload, $legacyId); $rubrica = $rubricaResult['rubrica'] ?? null; if (! $rubrica) { $stats['skipped']++; continue; } // Se il codice_univoco viene assegnato via trigger MySQL, il model Eloquent potrebbe non averlo valorizzato. // Lo rileggo dal DB per usarlo in dedup/merge e per evitare violazioni di unique sui fornitori. $rubricaCode = $rubrica->codice_univoco; if ((! is_string($rubricaCode) || $rubricaCode === '') && (int) $rubrica->id > 0) { $rubricaCode = RubricaUniversale::query()->whereKey((int) $rubrica->id)->value('codice_univoco'); if (is_string($rubricaCode) && $rubricaCode !== '') { $rubrica->codice_univoco = $rubricaCode; } } if (($rubricaResult['created'] ?? false) === true) { $stats['created_rubrica']++; } elseif (($rubricaResult['updated'] ?? false) === true) { $stats['updated_rubrica']++; } $regimeCode = $this->clean($row['regime_fiscale'] ?? null); $regimeId = $this->resolveRegimeFiscaleId($regimeCode); $tipoCassaCode = $this->clean($row['tipo_cassa'] ?? null); $tipoCassaId = $this->resolveTipologiaCassaId($tipoCassaCode); $aliqIvaAbituale = $this->clean($row['aliq_iva_abituale'] ?? $row['aliq_iva'] ?? null); $aliquotaIvaId = $this->resolveAliquotaIvaId($aliqIvaAbituale); $fornitorePayload = [ 'amministratore_id' => $amministratoreId, 'cod_forn' => $codForn, 'rubrica_id' => $rubrica->id, 'codice_univoco' => (is_string($rubricaCode) && $rubricaCode !== '') ? $rubricaCode : null, 'ragione_sociale' => $ragione ?? $rubrica->ragione_sociale, 'nome' => $nome ?? $rubrica->nome, 'cognome' => $cognome ?? $rubrica->cognome, 'partita_iva' => $piva ?? $rubrica->partita_iva, 'codice_fiscale' => $cf ?? $rubrica->codice_fiscale, 'indirizzo' => $rubricaPayload['indirizzo'] ?? $rubrica->indirizzo, 'cap' => $rubricaPayload['cap'] ?? $rubrica->cap, 'citta' => $rubricaPayload['citta'] ?? $rubrica->citta, 'provincia' => $rubricaPayload['provincia'] ?? $rubrica->provincia, 'email' => $rubricaPayload['email'] ?? $rubrica->email, 'pec' => $rubricaPayload['pec'] ?? $rubrica->pec, 'iban' => $this->clean($row['cod_iban'] ?? null), 'intestazione_cc_esatta' => $legacyIntestazioneCcEsatta, 'telefono' => $rubricaPayload['telefono_ufficio'] ?? $rubrica->telefono_ufficio, 'cellulare' => $rubricaPayload['telefono_cellulare'] ?? $rubrica->telefono_cellulare, 'note' => $rubricaPayload['note'] ?? $rubrica->note, 'regime_fiscale_id' => $regimeId, 'tipologia_cassa_id' => $tipoCassaId, 'aliquota_iva_id' => $aliquotaIvaId, 'old_id' => $legacyId, ]; $fornitorePayload = $this->filterPayloadForTable('fornitori', $fornitorePayload); $existing = null; if ($legacyId !== null) { $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('old_id', $legacyId) ->first(); } if (! $existing && $codForn !== null && Schema::hasColumn('fornitori', 'cod_forn')) { $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('cod_forn', $codForn) ->first(); } if (! $existing && ! empty($fornitorePayload['rubrica_id'])) { $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('rubrica_id', $fornitorePayload['rubrica_id']) ->first(); } if (! $existing && is_string($rubricaCode) && $rubricaCode !== '') { // Evita violazioni di unique(amministratore_id, codice_univoco) // Se esiste già un fornitore con lo stesso codice_univoco della rubrica, aggiorniamo quello. $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('codice_univoco', $rubricaCode) ->first(); } if (! $existing && $piva) { $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('partita_iva', $piva) ->first(); } if (! $existing && $cf) { $existing = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('codice_fiscale', $cf) ->first(); } $fornitorePayload = array_filter( $fornitorePayload, fn($v) => ! is_string($v) ? $v !== null : trim($v) !== '' ); $fornitoreModel = null; if ($existing) { if (! empty($fornitorePayload['codice_univoco'])) { $dup = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('codice_univoco', $fornitorePayload['codice_univoco']) ->where('id', '!=', $existing->id) ->exists(); if ($dup) { unset($fornitorePayload['codice_univoco']); } } $existing->fill($fornitorePayload); if ($existing->isDirty()) { if (! $dry) { $existing->save(); } $stats['updated_fornitori']++; } $fornitoreModel = $existing; } else { if (! empty($fornitorePayload['codice_univoco'])) { $dup = Fornitore::query() ->where('amministratore_id', $amministratoreId) ->where('codice_univoco', $fornitorePayload['codice_univoco']) ->exists(); if ($dup) { unset($fornitorePayload['codice_univoco']); } } if ($dry) { $fornitoreModel = new Fornitore($fornitorePayload); } else { $fornitoreModel = Fornitore::create($fornitorePayload); } $stats['created_fornitori']++; } if (! $fornitoreModel instanceof Fornitore) { continue; } $dipResult = $this->upsertDipendenteCollegato( $fornitoreModel, $legacyEmail, $nome, $cognome, $rubricaPayload['telefono_cellulare'] ?? null, $legacyTitoloRaw, $legacySesso, $legacyDtNas, $legacyLuoNas, $legacyPrNas, $dry, ); if ($dipResult === 'created') { $stats['created_dipendenti']++; } elseif ($dipResult === 'updated') { $stats['updated_dipendenti']++; } } }; if ($dry) { $runner(); } else { DB::transaction($runner); } $this->info('✅ Import completato' . ($dry ? ' (dry-run)' : '')); $this->line(json_encode($stats, JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } /** @return array{0: array>, 1: array} */ private function parseCsv(string $csv): array { $fh = fopen('php://temp', 'r+'); fwrite($fh, $csv); rewind($fh); $headers = fgetcsv($fh, 0, ',', '^'); if (! $headers) { return [[], []]; } $headers = array_map(fn($h) => strtolower(trim((string) $h)), $headers); $rows = []; while (($cols = fgetcsv($fh, 0, ',', '^')) !== false) { if (empty($cols)) { continue; } if (count($cols) < count($headers)) { $cols = array_pad($cols, count($headers), null); } if (count($cols) > count($headers)) { $cols = array_slice($cols, 0, count($headers)); } $assoc = @array_combine($headers, $cols); if (! $assoc) { continue; } $rows[] = $assoc; } return [$rows, $headers]; } /** @param array $payload * @return array{rubrica: RubricaUniversale|null, created: bool, updated: bool} */ private function upsertRubrica(array $payload, ?int $legacyId): array { $amministratoreId = $this->toInt($payload['amministratore_id'] ?? null); // Rubrica multi-tenant "soft": match per amministratore o globale (NULL). // Se amministratore_id non è presente nello schema, lo scope non viene applicato. $query = RubricaUniversale::query(); if (Schema::hasColumn('rubrica_universale', 'amministratore_id') && $amministratoreId !== null) { $query->where(function ($q) use ($amministratoreId) { $q->where('amministratore_id', $amministratoreId) ->orWhereNull('amministratore_id'); }); } $piva = $payload['partita_iva'] ?? null; $cf = $payload['codice_fiscale'] ?? null; $ragione = $payload['ragione_sociale'] ?? null; if ($piva) { $existing = (clone $query)->where('partita_iva', $piva)->first(); } elseif ($cf) { $existing = (clone $query)->where('codice_fiscale', $cf)->first(); } elseif ($ragione) { $existing = (clone $query)->whereRaw('LOWER(ragione_sociale) = ?', [mb_strtolower($ragione)]) ->first(); } else { $existing = null; } $payload = array_filter( $payload, fn($v) => ! is_string($v) ? $v !== null : trim($v) !== '' ); if ($this->option('dry-run')) { if ($existing) { $existing->fill($payload); return [ 'rubrica' => $existing, 'created' => false, 'updated' => $existing->isDirty(), ]; } return [ 'rubrica' => new RubricaUniversale($payload), 'created' => true, 'updated' => false, ]; } if ($existing) { $existing->fill($payload); $dirty = $existing->isDirty(); if ($dirty) { $existing->save(); } return [ 'rubrica' => $existing, 'created' => false, 'updated' => $dirty, ]; } return [ 'rubrica' => RubricaUniversale::create($payload), 'created' => true, 'updated' => false, ]; } /** @param array $payload * @return array */ private function filterPayloadForTable(string $table, array $payload): array { if (! Schema::hasTable($table)) { return $payload; } if (! isset($this->columnCache[$table])) { $this->columnCache[$table] = Schema::getColumnListing($table); } $allowed = array_flip($this->columnCache[$table]); $filtered = array_intersect_key($payload, $allowed); return array_filter( $filtered, fn($v) => ! is_string($v) ? $v !== null : trim($v) !== '' ); } private function resolveRegimeFiscaleId(?string $code): ?int { $code = $this->clean($code); if ($code === null) { return null; } $key = strtolower($code); $regime = RegimeFiscale::query() ->whereRaw('LOWER(codice) = ?', [$key]) ->orWhereRaw('LOWER(descrizione) = ?', [$key]) ->first(); return $regime?->id; } private function resolveTipologiaCassaId(?string $code): ?int { $code = $this->clean($code); if ($code === null) { return null; } $key = strtolower($code); $record = TipologiaCassa::query() ->whereRaw('LOWER(codice) = ?', [$key]) ->orWhereRaw('LOWER(descrizione) = ?', [$key]) ->first(); return $record?->id; } private function resolveAliquotaIvaId(?string $valueOrCode): ?int { $valueOrCode = $this->clean($valueOrCode); if ($valueOrCode === null) { return null; } // Supporta sia codici (es. IVA22) sia valori numerici (es. 22) $key = strtolower($valueOrCode); $byCode = AliquotaIva::query() ->whereRaw('LOWER(codice) = ?', [$key]) ->first(); if ($byCode) { return $byCode->id; } $numeric = str_replace(',', '.', $valueOrCode); if (is_numeric($numeric)) { $val = (float) $numeric; $byValue = AliquotaIva::query() ->where('valore', $val) ->first(); return $byValue?->id; } return null; } private function clean($v): ?string { if ($v === null) { return null; } $s = (string) $v; // Normalizza newline/controlli e compatta spazi, mantenendo una stringa leggibile. $s = str_replace(["\r\n", "\r"], "\n", $s); $s = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $s) ?? $s; $s = preg_replace('/\s+/u', ' ', $s) ?? $s; $s = trim($s); return $s === '' ? null : $s; } private function looksLikeContactBlob(string $s): bool { $s = trim($s); if ($s === '') { return false; } // Esempi tipici legacy: "Tel.: ...", "Cell. ...", "Fax: ..." if (preg_match('/^(tel\.?|cell\.?|fax\.?|telefono|mobile)\b\s*[:\.]/i', $s)) { return true; } // Stringhe composte solo da numeri/segni (telefono senza label) if (preg_match('/^[\d\s+().\-\/]+$/', $s)) { return true; } return false; } private function toInt($v): ?int { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '' || ! is_numeric($s)) { return null; } return (int) $s; } /** * @param array $row */ private function pick(array $row, array $keys): mixed { foreach ($keys as $key) { if (array_key_exists($key, $row)) { return $row[$key]; } $lower = strtolower((string) $key); if (array_key_exists($lower, $row)) { return $row[$lower]; } } return null; } private function normalizeLegacyDate(mixed $value): ?string { $raw = $this->clean($value); if ($raw === null) { return null; } $raw = trim($raw); foreach (['d/m/Y', 'd-m-Y', 'Y-m-d', 'm/d/Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'm/d/y', 'd.m.y'] as $format) { $dt = \DateTime::createFromFormat($format, $raw); if ($dt instanceof \DateTime) { return $this->normalizeBirthDateYear($dt); } } $ts = strtotime($raw); if ($ts !== false) { return $this->normalizeBirthDateYear((new \DateTime())->setTimestamp($ts)); } return null; } private function normalizeBirthDateYear(\DateTime $date): ?string { $year = (int) $date->format('Y'); $currentYear = (int) now()->format('Y'); if ($year > $currentYear + 1) { $date->modify('-100 years'); $year = (int) $date->format('Y'); } if ($year < 1900 || $year > $currentYear + 1) { return null; } return $date->format('Y-m-d'); } private function normalizeSesso(mixed $value): ?string { $raw = strtoupper((string) $this->clean($value)); if ($raw === 'M' || $raw === 'F') { return $raw; } if (str_starts_with($raw, 'MAS')) { return 'M'; } if (str_starts_with($raw, 'FEM')) { return 'F'; } return null; } private function resolveTitoloId(?string $raw): ?int { $raw = $this->clean($raw); if ($raw === null || ! Schema::hasTable('titoli')) { return null; } $key = mb_strtolower($raw); $title = Titolo::query() ->whereRaw('LOWER(sigla) = ?', [$key]) ->orWhereRaw('LOWER(nome) = ?', [$key]) ->first(); return $title?->id; } private function upsertDipendenteCollegato( Fornitore $fornitore, ?string $email, ?string $nome, ?string $cognome, ?string $telefono, ?string $titolo, ?string $sesso, ?string $dataNascita, ?string $luogoNascita, ?string $provinciaNascita, bool $dry, ): ?string { $email = $this->clean($email); $nome = $this->clean($nome); $cognome = $this->clean($cognome); $telefono = $this->clean($telefono); if ($email === null && $nome === null && $cognome === null) { return null; } $displayName = trim((string) (($nome ?? '') . ' ' . ($cognome ?? ''))); if ($displayName === '' && $email !== null) { $displayName = strtok($email, '@') ?: 'Referente'; $displayName = str_replace(['.', '_', '-'], ' ', $displayName); } $resolvedNome = $nome ?: trim((string) strtok($displayName, ' ')); $resolvedCognome = $cognome; if ($resolvedCognome === null) { $parts = preg_split('/\s+/', trim($displayName)) ?: []; if (count($parts) > 1) { array_shift($parts); $resolvedCognome = trim((string) implode(' ', $parts)) ?: null; } } $meta = array_values(array_filter([ $titolo ? ('Titolo: ' . $titolo) : null, $sesso ? ('Sesso: ' . $sesso) : null, $dataNascita ? ('Data nascita: ' . $dataNascita) : null, $luogoNascita ? ('Luogo nascita: ' . $luogoNascita) : null, $provinciaNascita ? ('Provincia nascita: ' . $provinciaNascita) : null, ])); $notes = $meta !== [] ? ('Import legacy GESCON. ' . implode(' | ', $meta)) : 'Import legacy GESCON.'; $query = FornitoreDipendente::query()->where('fornitore_id', (int) $fornitore->id); if ($email !== null) { $query->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]); } elseif ($resolvedNome !== null) { $query->whereRaw('LOWER(nome) = ?', [mb_strtolower($resolvedNome)]); if ($resolvedCognome !== null) { $query->whereRaw('LOWER(cognome) = ?', [mb_strtolower($resolvedCognome)]); } } $existing = $query->first(); $payload = [ 'nome' => $resolvedNome ?: 'Referente', 'cognome' => $resolvedCognome, 'email' => $email, 'telefono' => $telefono, 'attivo' => true, 'note' => $notes, ]; if ($existing) { $existing->fill($payload); if (! $existing->isDirty()) { return null; } if (! $dry) { $existing->save(); } return 'updated'; } if (! $dry) { FornitoreDipendente::query()->create(array_merge($payload, [ 'fornitore_id' => (int) $fornitore->id, ])); } return 'created'; } }