oszlop. FK csak a pricelist_file_lines-on van. */ public const REFERENCING_TABLES = [ 'products' => 'producer_id', 'order_archives_items' => 'producer_id', 'pricelist_file_lines' => 'producer_id', ]; private const UPDATE_CHUNK = 5000; /** * Duplikált névcsoportok, csoportonként a tagokkal és a hivatkozás-darabszámokkal. * A tagok rendezettek: az első a javasolt megtartandó (legtöbb termék). * * @return array}>> */ public function duplicateGroups(): array { $counts = $this->referenceCounts(); $byNormalized = []; foreach (DB::table('producers')->orderBy('id')->get() as $producer) { $byNormalized[NameNormalizer::normalize($producer->name)][] = $producer; } $groups = []; foreach ($byNormalized as $normalized => $members) { if (count($members) < 2) { continue; } $decorated = array_map(fn ($producer) => [ 'producer' => $producer, 'counts' => [ 'products' => $counts['products'][$producer->id] ?? 0, 'order_archives_items' => $counts['order_archives_items'][$producer->id] ?? 0, 'pricelist_file_lines' => $counts['pricelist_file_lines'][$producer->id] ?? 0, ], ], $members); // A megtartandó a legtöbb TERMÉKKEL rendelkező rekord: a termékek a napi // működés alapja. Döntetlennél a több rendelési tétel, majd a régebbi rekord. usort($decorated, function ($a, $b) { return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id] <=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id]; }); $groups[$normalized] = $decorated; } ksort($groups); return $groups; } /** * @return array> tábla => [producer_id => darab] */ private function referenceCounts(): array { $counts = []; foreach (self::REFERENCING_TABLES as $table => $column) { $counts[$table] = DB::table($table) ->selectRaw("{$column} as pid, COUNT(*) as c") ->whereNotNull($column) ->groupBy($column) ->pluck('c', 'pid') ->toArray(); } return $counts; } /** * Összevonási terv a döntési lap sorai alapján. * * A csoportosítás forrása a LAP "Csoport" oszlopa, nem az adatbázis normalizálása. * Ez azért fontos, mert a megrendelő olyan gyártókat is egybe akarhat vonni, amiket a * névnormalizálás nem tud összekötni: a "Kőröstej" és a "Kőröstej Kft" ugyanaz a cég, * de a nevük érdemben különbözik. Ha a lap ezeket egy csoportba teszi, itt is egy * összevonás lesz belőlük - így nem kell a végrehajtásba csoportokon átívelő logika. * * @param array $decisions * @return array{merges: array, errors: array} */ public function buildPlan(array $decisions): array { $groups = $this->duplicateGroups(); $normalizedById = []; $memberById = []; foreach ($groups as $normalized => $members) { foreach ($members as $member) { $normalizedById[$member['producer']->id] = $normalized; $memberById[$member['producer']->id] = $member; } } $sheetGroups = []; $finalNames = []; $errors = []; foreach ($decisions as $row) { $id = (int) ($row['id'] ?? 0); if (! isset($normalizedById[$id])) { $errors[] = "A(z) {$id} azonosítójú gyártó nem szerepel duplikált csoportban " . '(időközben megváltozott az adat, vagy más környezetből származik a lap).'; continue; } $key = trim((string) ($row['group'] ?? '')); if ($key === '') { $errors[] = "A(z) {$id} azonosítójú sorból hiányzik a csoport száma."; continue; } $sheetGroups[$key][] = $id; $name = trim((string) ($row['final_name'] ?? '')); if ($name !== '') { $finalNames[$key] ??= $name; } } $validation = $this->validateGrouping($sheetGroups, $normalizedById, $groups); $errors = array_merge($errors, $validation['errors']); $merges = []; foreach ($sheetGroups as $key => $ids) { // A hibás csoportot NEM hajtjuk végre: egy hiányzó tag azt jelentené, hogy // az összevonás után is maradna azonos nevű rekord. if (isset($validation['invalid'][$key])) { continue; } $members = array_values(array_map(fn ($id) => $memberById[$id], array_unique($ids))); if (! isset($finalNames[$key])) { $errors[] = 'Hiányzó végleges név a(z) ' . $key . '. csoportnál: ' . implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members)); continue; } // A megtartott REKORD a legtöbb terméket tartalmazó (így mozdul a legkevesebb // sor), a NEVE viszont a megrendelő által választott végleges név lesz. usort($members, function ($a, $b) { return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id] <=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id]; }); $keeper = $members[0]['producer']; $losers = array_slice($members, 1); if ($losers === []) { continue; // egytagú csoport: nincs mit összevonni } $merges[] = [ 'group' => $key, 'keeper_id' => $keeper->id, 'keeper_name' => $keeper->name, 'final_name' => $finalNames[$key], 'rename' => trim((string) $keeper->name) !== $finalNames[$key], 'from' => array_map(fn ($m) => [ 'id' => $m['producer']->id, 'name' => $m['producer']->name, 'counts' => $m['counts'], ], $losers), ]; } return ['merges' => $merges, 'errors' => $errors]; } /** * A lap-csoportosítás ellenőrzése. * * A lap SZÉLESEBB csoportot csinálhat, mint a névnormalizálás (ez a cél), de * SZŰKEBBET nem: ha az azonos nevű rekordok külön lap-csoportba kerülnének, a * végén két azonos nevű gyártó maradna - vagyis pont a duplikációt állítanánk elő. * * @return array{errors: array, invalid: array} */ private function validateGrouping(array $sheetGroups, array $normalizedById, array $groups): array { $errors = []; $invalid = []; $sheetKeysByNormalized = []; $idsByNormalized = []; foreach ($sheetGroups as $key => $ids) { foreach ($ids as $id) { $normalized = $normalizedById[$id]; $sheetKeysByNormalized[$normalized][$key] = true; $idsByNormalized[$normalized][$id] = true; } } foreach ($sheetKeysByNormalized as $normalized => $keys) { if (count($keys) > 1) { $errors[] = 'Az azonos nevű gyártók ("' . $normalized . '") több csoportba kerültek a lapon (' . implode(', ', array_keys($keys)) . '). Ezeket egy csoportba kell tenni, ' . 'különben duplikátum maradna utánuk.'; foreach (array_keys($keys) as $key) { $invalid[$key] = true; } } $expected = array_map(fn ($m) => $m['producer']->id, $groups[$normalized]); $missing = array_diff($expected, array_keys($idsByNormalized[$normalized])); if ($missing !== []) { $errors[] = 'A döntési lap nem tartalmazza a csoport minden tagját (hiányzó azonosító: ' . implode(', ', $missing) . '). Generáld újra a lapot ebben a környezetben.'; foreach (array_keys($keys) as $key) { $invalid[$key] = true; } } } return ['errors' => $errors, 'invalid' => $invalid]; } /** * A terv végrehajtása. Előbb visszaállítási pontot ír, csak utána módosít. * * @return array a jelentés, ami egyben a visszagörgetés bemenete is */ public function apply(array $merges): array { $report = [ 'created_at' => now()->toDateTimeString(), 'database' => DB::connection()->getDatabaseName(), 'merges' => [], 'restore' => [], 'totals' => array_fill_keys(array_keys(self::REFERENCING_TABLES), 0), ]; // producer_id leképezés: honnan => hova $map = []; foreach ($merges as $merge) { foreach ($merge['from'] as $from) { $map[$from['id']] = $merge['keeper_id']; } } if ($map === []) { return $report; } // 1. Visszaállítási pont: soronként a régi érték. Enélkül a művelet nem lenne // visszafordítható, mert a fordított leképezés azokat a sorokat is átírná, // amelyek eredetileg is a megtartott rekordra mutattak. foreach (self::REFERENCING_TABLES as $table => $column) { $report['restore'][$table] = DB::table($table) ->whereIn($column, array_keys($map)) ->pluck($column, 'id') ->toArray(); } $report['restore']['producers'] = DB::table('producers') ->whereIn('id', array_merge(array_keys($map), array_column($merges, 'keeper_id'))) ->get(['id', 'name', 'status', 'canSee', 'note']) ->keyBy('id') ->toArray(); // 2. Hivatkozások átírása - táblánként EGY menetben, mert a producer_id-n a nagy // táblákon nincs index, tehát minden külön WHERE teljes scant jelentene. foreach (self::REFERENCING_TABLES as $table => $column) { $report['totals'][$table] = $this->remap($table, $column, $map); } // 3. A gyártó rekordok rendezése foreach ($merges as $merge) { if ($merge['rename']) { DB::table('producers')->where('id', $merge['keeper_id']) ->update(['name' => $merge['final_name'], 'updated_at' => now()]); } foreach ($merge['from'] as $from) { DB::table('producers')->where('id', $from['id'])->update([ 'status' => DbStatusFieldEnum::archive, 'canSee' => 0, // Tartós nyom az adatbázisban: a jelentésfájl elveszhet, ez nem. 'note' => trim(sprintf( 'Összevonva ide: #%d (%s) – %s producers:dedupe', $merge['keeper_id'], $merge['final_name'], now()->toDateString(), )), 'updated_at' => now(), ]); } $report['merges'][] = [ 'keeper_id' => $merge['keeper_id'], 'final_name' => $merge['final_name'], 'from' => array_map(fn ($f) => ['id' => $f['id'], 'name' => $f['name']], $merge['from']), ]; } return $report; } /** * Egyetlen UPDATE táblánként, CASE leképezéssel. */ private function remap(string $table, string $column, array $map): int { $ids = array_map('intval', array_keys($map)); $updated = 0; foreach (array_chunk($ids, 200) as $chunk) { $cases = ''; foreach ($chunk as $from) { $cases .= sprintf(' WHEN %d THEN %d', $from, (int) $map[$from]); } $updated += DB::update(sprintf( 'UPDATE `%s` SET `%s` = CASE `%s`%s END WHERE `%s` IN (%s)', $table, $column, $column, $cases, $column, implode(',', $chunk), )); } return $updated; } /** * Visszagörgetés a jelentésfájlból: minden érintett sor visszakapja a saját, * eredeti producer_id-ját, a gyártó rekordok pedig az eredeti nevüket/státuszukat. */ public function rollback(array $report): array { $restored = array_fill_keys(array_keys(self::REFERENCING_TABLES), 0); foreach (self::REFERENCING_TABLES as $table => $column) { $rows = (array) ($report['restore'][$table] ?? []); foreach (array_chunk($rows, self::UPDATE_CHUNK, true) as $chunk) { $byValue = []; foreach ($chunk as $rowId => $producerId) { $byValue[(int) $producerId][] = (int) $rowId; } foreach ($byValue as $producerId => $rowIds) { $restored[$table] += DB::table($table) ->whereIn('id', $rowIds) ->update([$column => $producerId]); } } } foreach ((array) ($report['restore']['producers'] ?? []) as $id => $producer) { $producer = (array) $producer; DB::table('producers')->where('id', (int) $id)->update([ 'name' => $producer['name'], 'status' => $producer['status'], 'canSee' => $producer['canSee'], 'note' => $producer['note'], 'updated_at' => now(), ]); } return $restored; } }