From 7e32a870aec95f4dfcc46ceabc66fc5eaa6a5daa Mon Sep 17 00:00:00 2001 From: E98Developer Date: Thu, 27 Aug 2026 17:30:24 +0200 Subject: [PATCH] =?UTF-8?q?ADD=20EV3-357=20a=20d=C3=B6nt=C3=A9si=20lap=20a?= =?UTF-8?q?dja=20a=20csoportos=C3=ADt=C3=A1st=20(csoportokon=20=C3=A1t?= =?UTF-8?q?=C3=ADvel=C5=91=20=C3=B6sszevon=C3=A1s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A megrendelő 2026-08-27-én hat esetben jelezte, hogy két külön névcsoportunk valójában ugyanaz a cég: a rövid név és a cégformás név (Kőröstej / Kőröstej Kft, Pick / Pick Szeged Zrt, White Lake / White Lake Kft, ...). A névnormalizálás ezt nem tudhatja, mert a nevek érdemben különböznek. - buildPlan a LAP "Csoport" oszlopa szerint csoportosít, nem az adatbázis normalizálása szerint. Így a lap szélesebb csoportot is kijelölhet - a végrehajtásba nem kell csoportokon átívelő logika. - validateGrouping: a lap szélesebb csoportot csinálhat, SZŰKEBBET nem. Ha az azonos nevű rekordok külön 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ő. Ilyenkor a csoport kimarad a végrehajtásból, nem csak figyelmeztetés. - a megtartott REKORD továbbra is a legtöbb terméket tartalmazó (a legkevesebb sor mozdul), a NEVE viszont a választott cégnév 2 új teszt a csoportokon átívelő összevonásra és a szétszórás elutasítására. Co-Authored-By: Claude Opus 5 --- app/Services/ProducerDeduplicator.php | 122 +++++++++++++++++++++----- tests/Feature/ProducerDedupeTest.php | 46 ++++++++++ 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/app/Services/ProducerDeduplicator.php b/app/Services/ProducerDeduplicator.php index 0b242cc..19ad302 100644 --- a/app/Services/ProducerDeduplicator.php +++ b/app/Services/ProducerDeduplicator.php @@ -99,72 +99,99 @@ private function referenceCounts(): array /** * Ö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(); - $byId = []; + + $normalizedById = []; + $memberById = []; foreach ($groups as $normalized => $members) { foreach ($members as $member) { - $byId[$member['producer']->id] = $normalized; + $normalizedById[$member['producer']->id] = $normalized; + $memberById[$member['producer']->id] = $member; } } - $rowsByGroup = []; + $sheetGroups = []; $finalNames = []; $errors = []; foreach ($decisions as $row) { $id = (int) ($row['id'] ?? 0); - if (! isset($byId[$id])) { + 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; } - $normalized = $byId[$id]; - $rowsByGroup[$normalized][] = $id; + $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[$normalized] ??= $name; + $finalNames[$key] ??= $name; } } + $validation = $this->validateGrouping($sheetGroups, $normalizedById, $groups); + $errors = array_merge($errors, $validation['errors']); + $merges = []; - foreach ($rowsByGroup as $normalized => $ids) { - $members = $groups[$normalized]; - $memberIds = array_map(fn ($m) => $m['producer']->id, $members); + 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; + } - if (! isset($finalNames[$normalized])) { - $errors[] = 'Hiányzó végleges név ehhez a csoporthoz: ' + $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; } - $missing = array_diff($memberIds, $ids); - 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.'; - - 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[] = [ - 'normalized' => $normalized, + 'group' => $key, 'keeper_id' => $keeper->id, 'keeper_name' => $keeper->name, - 'final_name' => $finalNames[$normalized], - 'rename' => trim((string) $keeper->name) !== $finalNames[$normalized], + 'final_name' => $finalNames[$key], + 'rename' => trim((string) $keeper->name) !== $finalNames[$key], 'from' => array_map(fn ($m) => [ 'id' => $m['producer']->id, 'name' => $m['producer']->name, @@ -176,6 +203,57 @@ public function buildPlan(array $decisions): array 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. * diff --git a/tests/Feature/ProducerDedupeTest.php b/tests/Feature/ProducerDedupeTest.php index 14c7621..a3ab2b7 100644 --- a/tests/Feature/ProducerDedupeTest.php +++ b/tests/Feature/ProducerDedupeTest.php @@ -98,6 +98,52 @@ function decisionsFor(array $rows): array expect($plan['errors'][0])->toContain('nem szerepel duplikált csoportban'); }); +test('a lap egy csoportba tehet olyan gyártókat is, amiket a normalizálás nem köt össze', function () { + // A megrendelő döntése: a "Kőröstej" és a "Kőröstej Kft" ugyanaz a cég. A + // névnormalizálás ezt nem tudhatja, a lap Csoport oszlopa viszont kifejezi. + $short = producer('Kőröstej'); + $shortDup = producer('Kőröstej '); + $long = producer('Kőröstej Kft'); + $longDup = producer('KŐRÖSTEJ KFT'); + + productFor($short, 'A1'); + productFor($short, 'A2'); + productFor($long, 'B1'); + + $plan = app(ProducerDeduplicator::class)->buildPlan([ + ['group' => 7, 'id' => $short->id, 'final_name' => 'Kőröstej Kft'], + ['group' => 7, 'id' => $shortDup->id, 'final_name' => null], + ['group' => 7, 'id' => $long->id, 'final_name' => null], + ['group' => 7, 'id' => $longDup->id, 'final_name' => null], + ]); + + expect($plan['errors'])->toBe([]) + ->and($plan['merges'])->toHaveCount(1); + + $merge = $plan['merges'][0]; + + // A megtartott REKORD a legtöbb terméket tartalmazó, a NEVE viszont a választott + // cégnév - így a legkevesebb sor mozdul, mégis a kívánt név marad. + expect($merge['keeper_id'])->toBe($short->id) + ->and($merge['final_name'])->toBe('Kőröstej Kft') + ->and($merge['rename'])->toBeTrue() + ->and($merge['from'])->toHaveCount(3); +}); + +test('az azonos nevű rekordok szétszórása külön csoportokba hibát ad', function () { + // Ez duplikátumot hagyna maga után, ezért nem hajtható végre. + $a = producer('Danone'); + $b = producer('Danone '); + + $plan = app(ProducerDeduplicator::class)->buildPlan([ + ['group' => 1, 'id' => $a->id, 'final_name' => 'Danone'], + ['group' => 2, 'id' => $b->id, 'final_name' => 'Danone'], + ]); + + expect($plan['merges'])->toBe([]) + ->and($plan['errors'][0])->toContain('több csoportba kerültek'); +}); + test('az összevonás átírja a hivatkozásokat és archiválja a beolvasztott rekordot', function () { $keeper = producer('Danone'); $loser = producer('Danone ');