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 { $byNormalized = []; foreach ($this->activeMembers() as $member) { $byNormalized[NameNormalizer::normalize($member['producer']->name)][] = $member; } $groups = []; foreach ($byNormalized as $normalized => $members) { if (count($members) < 2) { continue; } $groups[$normalized] = $this->sortByKeeperPriority($members); } ksort($groups); return $groups; } /** * Laza jelöltek: olyan gyártók, amelyeket a szigorú szabály NEM köt össze, de nagy * eséllyel ugyanaz a cég - elírás, kötőjel, egybeírás, hiányzó ékezet miatt térnek el. * * Ezek SOSEM olvadnak össze maguktól: a riportba javaslatként kerülnek, a döntést a * megrendelő hozza. Ezért lehet itt megengedőbb a szabály, mint az import-párosításnál, * ahol egy téves egyezés csendben rossz gyártóhoz rendelne termékeket. * * Egy dolgot viszont itt is tiszteletben tartunk: a KÜLÖNBÖZŐ cégforma (Kft vs Zrt) * valódi különbség, nem elírás - azokat nem javasoljuk összevonásra. A cégforma * nélküli név viszont párba állhat egy cégformással, ha csak egyféle forma van. * * @return array}>> */ public function looseCandidateGroups(): array { $byBase = []; foreach ($this->activeMembers() as $member) { [$base, $form] = $this->splitCompanyForm($member['producer']->name); if ($base === '') { continue; } $byBase[$base][$form][] = $member; } $candidates = []; foreach ($byBase as $base => $byForm) { $explicitForms = array_diff(array_keys($byForm), ['']); if (count($explicitForms) > 1) { // Kft ÉS Zrt is van ugyanarra a névre: ezek nem egymás elírásai. A // formánként külön csoportok mehetnek, a forma nélküli viszont nem // rendelhető egyértelműen egyikhez sem, ezért kimarad. foreach ($explicitForms as $form) { $candidates[$base . '|' . $form] = $byForm[$form]; } continue; } $candidates[$base] = array_merge(...array_values($byForm)); } // Csak az érdekes: ami több szigorú csoportot fog össze (vagy olyan rekordokat, // amiket a szigorú szabály külön hagyott). $strictKeys = []; foreach ($candidates as $key => $members) { $strictKeys[$key] = array_unique(array_map( fn ($m) => NameNormalizer::normalize($m['producer']->name), $members, )); } $result = []; foreach ($candidates as $key => $members) { if (count($members) < 2 || count($strictKeys[$key]) < 2) { continue; } $result[$key] = $this->sortByKeeperPriority($members); } ksort($result); return $result; } /** * Név szétbontása alapnévre és cégforma-toldatra, a "csak elírás" eltérések * kiszűrésével: írásjelek, ékezetek és szóközök elhagyásával. * * @return array{0: string, 1: string} [alapnév, cégforma] */ private function splitCompanyForm(string $name): array { $normalized = NameNormalizer::normalize($name); $normalized = preg_replace('/[.,\-\'"()]/u', ' ', $normalized) ?? $normalized; $normalized = strtr($normalized, [ 'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ö' => 'O', 'Ő' => 'O', 'Ú' => 'U', 'Ü' => 'U', 'Ű' => 'U', ]); $form = ''; $pattern = '/\b(KFT|ZRT|BT|NYRT|KKT|RT|GMBH|LTD|INC|SRL|NV|BV|AG|SA|SPA)\b/u'; if (preg_match($pattern, $normalized, $matches)) { $form = $matches[1]; $normalized = preg_replace($pattern, ' ', $normalized) ?? $normalized; } // A szóközök teljes elhagyása fogja meg az egybeírást ("Alfölditej" = "Alföldi Tej") return [preg_replace('/\s+/u', '', $normalized) ?? '', $form]; } /** * @return array}> */ private function activeMembers(): array { $counts = $this->referenceCounts(); return DB::table('producers') ->whereNotIn('status', [DbStatusFieldEnum::archive, DbStatusFieldEnum::deleted]) ->orderBy('id') ->get() ->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, ], ]) ->all(); } /** * A megtartandó rekord kerül előre: a legtöbb TERMÉKKEL rendelkező, mert így mozdul * a legkevesebb sor. Döntetlennél a több rendelési tétel, majd a régebbi rekord. * * @param array $members * @return array */ private function sortByKeeperPriority(array $members): array { 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]; }); return array_values($members); } /** * @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(); $memberById = []; foreach ($this->activeMembers() as $member) { $memberById[$member['producer']->id] = $member; } $normalizedById = []; foreach ($groups as $normalized => $members) { foreach ($members as $member) { $normalizedById[$member['producer']->id] = $normalized; } } $sheetGroups = []; $finalNames = []; $skipped = []; $errors = []; foreach ($decisions as $row) { $id = (int) ($row['id'] ?? 0); if (! isset($memberById[$id])) { $errors[] = "A(z) {$id} azonosítójú gyártó nem található (vagy már összevonták, " . '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))); // Üres végleges név = a megrendelő NEM kéri az összevonást. Ez a laza // javaslatok elutasításának módja, ezért nem hiba, hanem kihagyás - de // jelentjük, hogy egy véletlen kihagyás se maradjon észrevétlen. if (! isset($finalNames[$key])) { $skipped[] = [ 'group' => $key, 'names' => array_map(fn ($m) => (string) $m['producer']->name, $members), ]; continue; } $members = $this->sortByKeeperPriority($members); $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, 'skipped' => $skipped]; } /** * 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) { // A lapon szerepelhet olyan gyártó is, ami NEM tagja szigorú duplikátum // csoportnak (a laza javaslatok ilyenek) - ott nincs mit ellenőrizni. if (! isset($normalizedById[$id])) { continue; } $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; } }