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

481 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconFornitoriDedupCommand extends Command
{
protected $signature = 'gescon:fornitori-dedup
{--apply : Applica la bonifica}
{--limit= : Limita il numero di gruppi elaborati}
{--admin-id= : In caso di parita preferisce il fornitore di questo amministratore}';
protected $description = 'Bonifica i doppioni fornitori mantenendo un record canonico per identita e salvando gli alias cod_forn legacy.';
/** @var array<int, int> */
private array $referenceCounts = [];
/** @var array<int, array{table:string,column:string}> */
private array $referenceColumns = [];
public function handle(): int
{
if (! Schema::hasTable('fornitori')) {
$this->error('Tabella fornitori non disponibile.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$limit = $this->option('limit') ? max(1, (int) $this->option('limit')) : null;
$preferredAdminId = $this->option('admin-id') !== null ? max(0, (int) $this->option('admin-id')) : null;
$groups = $this->loadDuplicateGroups($limit);
if ($groups === []) {
$this->info('Nessun doppione fornitore rilevato.');
return self::SUCCESS;
}
$this->referenceColumns = $this->discoverSupplierReferenceColumns();
$this->referenceCounts = $this->loadSupplierReferenceCounts();
$stats = [
'groups' => 0,
'duplicates' => 0,
'merged' => 0,
'deleted' => 0,
'moved_links' => 0,
'skipped_delete' => 0,
'errors' => 0,
];
$previewRows = [];
foreach ($groups as $groupKey => $suppliers) {
$stats['groups']++;
$stats['duplicates'] += count($suppliers) - 1;
$master = $this->pickMaster($suppliers, $preferredAdminId);
$duplicates = array_values(array_filter($suppliers, fn(Fornitore $supplier): bool => (int) $supplier->id !== (int) $master->id));
$previewRows[] = [
'group' => $groupKey,
'keep' => '#' . (int) $master->id . ' ' . trim((string) ($master->ragione_sociale ?? '')),
'keep_code' => $master->cod_forn ?: '—',
'drop' => implode(', ', array_map(fn(Fornitore $supplier): string => '#' . (int) $supplier->id, $duplicates)),
];
if (! $apply) {
continue;
}
try {
DB::transaction(function () use ($master, $duplicates, &$stats): void {
$masterModel = Fornitore::query()->findOrFail((int) $master->id);
$duplicatesModels = Fornitore::query()
->whereIn('id', array_map(fn(Fornitore $supplier): int => (int) $supplier->id, $duplicates))
->get();
if ($duplicatesModels->isEmpty()) {
return;
}
$masterPayload = $this->buildMasterPayload($masterModel, $duplicatesModels->all());
$masterModel->fill($masterPayload);
if ($masterModel->isDirty()) {
$masterModel->save();
}
foreach ($duplicatesModels as $duplicate) {
$stats['moved_links'] += $this->moveSupplierReferences((int) $duplicate->id, (int) $masterModel->id);
if ($this->hasRemainingReferences((int) $duplicate->id)) {
$stats['skipped_delete']++;
continue;
}
$duplicate->delete();
$stats['deleted']++;
$stats['merged']++;
}
});
} catch (\Throwable $exception) {
$stats['errors']++;
$this->warn('Gruppo ' . $groupKey . ' non bonificato: ' . $exception->getMessage());
}
}
$this->table(['Gruppo', 'Canonico', 'cod_forn', 'Duplicati'], array_slice($previewRows, 0, 20));
$this->line(json_encode($stats, JSON_UNESCAPED_UNICODE));
$this->info($apply ? 'Bonifica fornitori completata.' : 'Dry-run bonifica fornitori completato.');
return self::SUCCESS;
}
/** @return array<string, array<int, Fornitore>> */
private function loadDuplicateGroups(?int $limit = null): array
{
$suppliers = Fornitore::query()->get([
'id',
'amministratore_id',
'cod_forn',
'old_id',
'rubrica_id',
'ragione_sociale',
'partita_iva',
'codice_fiscale',
'email',
'pec',
'telefono',
'cellulare',
'iban',
'codice_univoco',
'fe_features',
'operational_config',
'note',
]);
$groups = [];
foreach ($suppliers as $supplier) {
$groupKey = $this->buildIdentityKey($supplier);
if ($groupKey === null) {
continue;
}
$groups[$groupKey] ??= [];
$groups[$groupKey][] = $supplier;
}
$groups = array_filter($groups, fn(array $items): bool => count($items) > 1);
ksort($groups);
if ($limit !== null) {
$groups = array_slice($groups, 0, $limit, true);
}
return $groups;
}
private function buildIdentityKey(Fornitore $supplier): ?string
{
$rubricaId = (int) ($supplier->rubrica_id ?? 0);
if ($rubricaId > 0) {
return 'rubrica:' . $rubricaId;
}
$piva = $this->normalizeNumericIdentity($supplier->partita_iva);
if ($piva !== null) {
return 'piva:' . $piva;
}
$cf = $this->normalizeAlphaNumericIdentity($supplier->codice_fiscale);
if ($cf !== null) {
return 'cf:' . $cf;
}
return null;
}
/** @param array<int, Fornitore> $suppliers */
private function pickMaster(array $suppliers, ?int $preferredAdminId): Fornitore
{
$best = null;
$bestScore = null;
foreach ($suppliers as $supplier) {
$score = 0;
if ((int) ($supplier->cod_forn ?? 0) > 0) {
$score += 2000;
}
if ((int) ($supplier->rubrica_id ?? 0) > 0) {
$score += 400;
}
if ($preferredAdminId !== null && (int) ($supplier->amministratore_id ?? 0) === $preferredAdminId) {
$score += 300;
}
if (! empty($supplier->fe_features)) {
$score += 150;
}
if (! empty($supplier->operational_config)) {
$score += 100;
}
foreach (['partita_iva', 'codice_fiscale', 'email', 'pec', 'telefono', 'cellulare', 'iban', 'codice_univoco'] as $field) {
if (trim((string) ($supplier->{$field} ?? '')) !== '') {
$score += 20;
}
}
$score += ((int) ($this->referenceCounts[(int) $supplier->id] ?? 0)) * 25;
if ($best === null || $bestScore === null || $score > $bestScore || ($score === $bestScore && (int) $supplier->id < (int) $best->id)) {
$best = $supplier;
$bestScore = $score;
}
}
return $best ?? $suppliers[0];
}
/** @param array<int, Fornitore> $duplicates */
private function buildMasterPayload(Fornitore $master, array $duplicates): array
{
$payload = [];
foreach (['rubrica_id', 'partita_iva', 'codice_fiscale', 'email', 'pec', 'telefono', 'cellulare', 'iban', 'codice_univoco'] as $field) {
if (trim((string) ($master->{$field} ?? '')) !== '') {
continue;
}
foreach ($duplicates as $duplicate) {
$value = $duplicate->{$field} ?? null;
if (trim((string) $value) !== '') {
$payload[$field] = $value;
break;
}
}
}
$masterFeFeatures = is_array($master->fe_features ?? null) ? $master->fe_features : [];
$masterOperationalConfig = is_array($master->operational_config ?? null) ? $master->operational_config : [];
$legacyCodFornAliases = [];
$legacyOldIdAliases = [];
$mergedSupplierIds = [];
if ((int) ($master->cod_forn ?? 0) > 0) {
$legacyCodFornAliases[] = (int) $master->cod_forn;
}
if ((int) ($master->old_id ?? 0) > 0) {
$legacyOldIdAliases[] = (int) $master->old_id;
}
foreach ($duplicates as $duplicate) {
if ((int) ($duplicate->cod_forn ?? 0) > 0) {
$legacyCodFornAliases[] = (int) $duplicate->cod_forn;
}
if ((int) ($duplicate->old_id ?? 0) > 0) {
$legacyOldIdAliases[] = (int) $duplicate->old_id;
}
$mergedSupplierIds[] = (int) $duplicate->id;
$masterFeFeatures = $this->mergeConfigRecursive($masterFeFeatures, is_array($duplicate->fe_features ?? null) ? $duplicate->fe_features : []);
$masterOperationalConfig = $this->mergeConfigRecursive($masterOperationalConfig, is_array($duplicate->operational_config ?? null) ? $duplicate->operational_config : []);
}
$legacyCodFornAliases = array_values(array_unique(array_filter(array_map('intval', array_merge(
$legacyCodFornAliases,
array_map('intval', (array) ($masterOperationalConfig['legacy_cod_forn_aliases'] ?? []))
)))));
$legacyOldIdAliases = array_values(array_unique(array_filter(array_map('intval', array_merge(
$legacyOldIdAliases,
array_map('intval', (array) ($masterOperationalConfig['legacy_old_id_aliases'] ?? []))
)))));
$mergedSupplierIds = array_values(array_unique(array_filter(array_map('intval', array_merge(
$mergedSupplierIds,
array_map('intval', (array) ($masterOperationalConfig['merged_supplier_ids'] ?? []))
)))));
$masterOperationalConfig['legacy_cod_forn_aliases'] = $legacyCodFornAliases;
$masterOperationalConfig['legacy_old_id_aliases'] = $legacyOldIdAliases;
$masterOperationalConfig['merged_supplier_ids'] = $mergedSupplierIds;
$payload['fe_features'] = $masterFeFeatures;
$payload['operational_config'] = $masterOperationalConfig;
if (trim((string) ($master->note ?? '')) === '' && $duplicates !== []) {
$payload['note'] = 'Fornitore canonico dopo bonifica duplicati.';
}
return $payload;
}
/** @return array<int, array{table:string,column:string}> */
private function discoverSupplierReferenceColumns(): array
{
$database = DB::getDatabaseName();
$rows = DB::select(
"SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = ?
AND table_name <> 'fornitori'
AND column_name IN ('fornitore_id', 'default_fornitore_id', 'assegnato_a_fornitore_id', 'fornitore_esterno_id')
ORDER BY table_name, column_name",
[$database]
);
return array_values(array_filter(array_map(function (object $row): ?array {
$table = (string) ($row->TABLE_NAME ?? $row->table_name ?? '');
$column = (string) ($row->COLUMN_NAME ?? $row->column_name ?? '');
if ($table === '' || $column === '') {
return null;
}
return ['table' => $table, 'column' => $column];
}, $rows)));
}
/** @return array<int, int> */
private function loadSupplierReferenceCounts(): array
{
$counts = [];
foreach ($this->discoverSupplierReferenceColumns() as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
$rows = DB::table($table)
->selectRaw($column . ' as supplier_id, COUNT(*) as aggregate_count')
->whereNotNull($column)
->groupBy($column)
->get();
foreach ($rows as $row) {
$supplierId = is_numeric($row->supplier_id ?? null) ? (int) $row->supplier_id : 0;
if ($supplierId <= 0) {
continue;
}
$counts[$supplierId] = (int) ($counts[$supplierId] ?? 0) + (int) ($row->aggregate_count ?? 0);
}
}
return $counts;
}
private function moveSupplierReferences(int $fromSupplierId, int $toSupplierId): int
{
if ($fromSupplierId <= 0 || $toSupplierId <= 0 || $fromSupplierId === $toSupplierId) {
return 0;
}
$moved = 0;
foreach ($this->referenceColumns as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
if ($table === 'fornitore_stabile_impostazioni' && $column === 'fornitore_id') {
$moved += $this->mergeFornitoreStabileImpostazioni($fromSupplierId, $toSupplierId);
continue;
}
$moved += (int) DB::table($table)
->where($column, $fromSupplierId)
->update([$column => $toSupplierId]);
}
return $moved;
}
private function mergeFornitoreStabileImpostazioni(int $fromSupplierId, int $toSupplierId): int
{
if (! Schema::hasTable('fornitore_stabile_impostazioni')) {
return 0;
}
$moved = 0;
$rows = DB::table('fornitore_stabile_impostazioni')
->where('fornitore_id', $fromSupplierId)
->get();
foreach ($rows as $row) {
$target = DB::table('fornitore_stabile_impostazioni')
->where('stabile_id', (int) $row->stabile_id)
->where('fornitore_id', $toSupplierId)
->first();
if (! $target) {
$moved += (int) DB::table('fornitore_stabile_impostazioni')
->where('id', (int) $row->id)
->update(['fornitore_id' => $toSupplierId]);
continue;
}
if (Schema::hasColumn('fornitore_stabile_impostazioni', 'meta')) {
$sourceMeta = json_decode((string) ($row->meta ?? '[]'), true);
$targetMeta = json_decode((string) ($target->meta ?? '[]'), true);
$mergedMeta = $this->mergeConfigRecursive(is_array($targetMeta) ? $targetMeta : [], is_array($sourceMeta) ? $sourceMeta : []);
DB::table('fornitore_stabile_impostazioni')
->where('id', (int) $target->id)
->update([
'meta' => json_encode($mergedMeta, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
DB::table('fornitore_stabile_impostazioni')->where('id', (int) $row->id)->delete();
$moved++;
}
return $moved;
}
private function hasRemainingReferences(int $supplierId): bool
{
foreach ($this->referenceColumns as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
if (DB::table($table)->where($column, $supplierId)->exists()) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $master
* @param array<string, mixed> $duplicate
* @return array<string, mixed>
*/
private function mergeConfigRecursive(array $master, array $duplicate): array
{
foreach ($duplicate as $key => $value) {
if (! array_key_exists($key, $master)) {
$master[$key] = $value;
continue;
}
if (is_array($master[$key]) && is_array($value)) {
$master[$key] = $this->mergeConfigRecursive($master[$key], $value);
}
}
return $master;
}
private function normalizeNumericIdentity(mixed $value): ?string
{
$digits = preg_replace('/\D+/', '', trim((string) $value)) ?? '';
if ($digits === '' || preg_match('/^0+$/', $digits) === 1) {
return null;
}
return strlen($digits) < 11 ? str_pad($digits, 11, '0', STR_PAD_LEFT) : $digits;
}
private function normalizeAlphaNumericIdentity(mixed $value): ?string
{
$normalized = strtoupper(trim((string) $value));
$normalized = preg_replace('/\s+/', '', $normalized) ?? '';
if ($normalized === '' || in_array($normalized, ['0', '00', '000', 'ND', 'N/D', 'NULL'], true)) {
return null;
}
return $normalized;
}
}