$name, 'status' => DbStatusFieldEnum::active, 'canSee' => 1]); } function productFor(Producer $producer, string $sku): Product { return Product::create([ 'name' => 'Termék ' . $sku, 'supplierProductNumber' => $sku, 'producer_id' => $producer->id, 'unitValue' => 1, 'note' => '', 'status' => DbStatusFieldEnum::active, 'canSee' => 1, ]); } /** * @param array $rows */ function decisionsFor(array $rows): array { return array_map(fn ($r) => ['group' => 1, 'id' => $r['id'], 'final_name' => $r['final_name'] ?? null], $rows); } beforeEach(function () { $this->deduplicator = app(ProducerDeduplicator::class); }); test('a duplikált csoportban a legtöbb termékkel rendelkező rekord a javasolt megtartandó', function () { $few = producer('Danone '); $many = producer('Danone'); productFor($many, 'A1'); productFor($many, 'A2'); productFor($few, 'B1'); $groups = $this->deduplicator->duplicateGroups(); expect($groups)->toHaveKey('DANONE') ->and($groups['DANONE'][0]['producer']->id)->toBe($many->id) ->and($groups['DANONE'][0]['counts']['products'])->toBe(2) ->and($groups['DANONE'][1]['producer']->id)->toBe($few->id); }); test('az egyedi nevű gyártó nem kerül a duplikátumok közé', function () { producer('Danone'); producer('Bonduelle'); expect($this->deduplicator->duplicateGroups())->toBe([]); }); test('az üres végleges név kihagyja a csoportot – ez a javaslat elutasításának módja', function () { $a = producer('Danone'); $b = producer('Danone '); $plan = $this->deduplicator->buildPlan(decisionsFor([ ['id' => $a->id, 'final_name' => null], ['id' => $b->id, 'final_name' => null], ])); // Nem hiba: a bizonytalan javaslatokat a megrendelő így utasítja el. De jelentjük, // hogy egy véletlen kihagyás se maradjon észrevétlen. expect($plan['merges'])->toBe([]) ->and($plan['errors'])->toBe([]) ->and($plan['skipped'])->toHaveCount(1) ->and($plan['skipped'][0]['names'])->toContain('Danone '); }); test('hiányos csoport esetén hibát jelez', function () { $a = producer('Danone'); producer('Danone '); // A lapról lemaradt a csoport másik tagja - ilyenkor nem szabad összevonni, // mert a döntés nem a teljes csoportra vonatkozott. $plan = $this->deduplicator->buildPlan(decisionsFor([ ['id' => $a->id, 'final_name' => 'Danone'], ])); expect($plan['merges'])->toBe([]) ->and($plan['errors'][0])->toContain('nem tartalmazza a csoport minden tagját'); }); test('ismeretlen azonosítót jelez, ha a lap más adatállapotból származik', function () { $plan = $this->deduplicator->buildPlan(decisionsFor([['id' => 999999, 'final_name' => 'Bármi']])); expect($plan['errors'][0])->toContain('nem található'); }); test('a laza jelöltek megfogják az elírásokat, amiket a szigorú szabály nem', function () { // Ezek egyike sem kerül össze a szigorú szabállyal: írásjel, kötőjel, egybeírás, // hiányzó ékezet. A megrendelő szemével viszont nyilvánvalóan ugyanaz a cég. producer('Békás Kft.'); producer('Békás Kft'); producer('Gast Food'); producer('Gast-Food'); producer('Alföldi Tej'); producer('Alfölditej'); expect($this->deduplicator->duplicateGroups())->toBe([]); $loose = $this->deduplicator->looseCandidateGroups(); expect($loose)->toHaveCount(3); $names = array_map( fn ($members) => array_map(fn ($m) => $m['producer']->name, $members), array_values($loose), ); expect(collect($names)->flatten()->all())->toContain('Békás Kft.', 'Gast-Food', 'Alfölditej'); }); test('a laza jelöltek NEM vonnak össze eltérő cégformát', function () { // A Kft és a Zrt valódi különbség, nem elírás - ezt a rendszer nem döntheti el. producer('Alföldi Tej Kft'); producer('Alföldi Tej Zrt'); expect($this->deduplicator->looseCandidateGroups())->toBe([]); }); test('a cégforma nélküli név párba állhat, ha csak egyféle forma van', function () { $bare = producer('Szegedi Sütödék'); $kft = producer('Szegedi Sütödék Kft.'); $loose = $this->deduplicator->looseCandidateGroups(); expect($loose)->toHaveCount(1); $ids = array_map(fn ($m) => $m['producer']->id, array_values($loose)[0]); expect($ids)->toContain($bare->id)->toContain($kft->id); }); test('az archivált gyártó nem kerül a laza jelöltek közé', function () { producer('Békás Kft.'); $merged = producer('Békás Kft'); DB::table('producers')->where('id', $merged->id)->update(['status' => DbStatusFieldEnum::archive]); expect($this->deduplicator->looseCandidateGroups())->toBe([]); }); 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 '); productFor($keeper, 'A1'); $moved = productFor($loser, 'B1'); $plan = $this->deduplicator->buildPlan(decisionsFor([ ['id' => $keeper->id, 'final_name' => 'Danone'], ['id' => $loser->id, 'final_name' => null], ])); expect($plan['errors'])->toBe([]); $report = $this->deduplicator->apply($plan['merges']); expect($moved->refresh()->producer_id)->toBe($keeper->id) ->and($report['totals']['products'])->toBe(1); $loser->refresh(); expect($loser->status)->toBe(DbStatusFieldEnum::archive) ->and((bool) $loser->canSee)->toBeFalse() // Tartós nyom az adatbázisban, a jelentésfájltól függetlenül ->and($loser->note)->toContain('Összevonva ide: #' . $keeper->id); }); test('a végleges név átnevezi a megtartott rekordot', function () { $keeper = producer('danone'); $loser = producer('Danone '); productFor($keeper, 'A1'); $plan = $this->deduplicator->buildPlan(decisionsFor([ ['id' => $keeper->id, 'final_name' => 'Danone Magyarország'], ['id' => $loser->id, 'final_name' => null], ])); expect($plan['merges'][0]['rename'])->toBeTrue(); $this->deduplicator->apply($plan['merges']); expect($keeper->refresh()->name)->toBe('Danone Magyarország'); }); test('a visszagörgetés minden érintett sort a saját eredeti értékére állít vissza', function () { $keeper = producer('Danone'); $loser = producer('Danone '); $stayed = productFor($keeper, 'A1'); // eredetileg is a megtartotton volt $moved = productFor($loser, 'B1'); // ezt mozgatjuk $plan = $this->deduplicator->buildPlan(decisionsFor([ ['id' => $keeper->id, 'final_name' => 'Danone'], ['id' => $loser->id, 'final_name' => null], ])); $report = $this->deduplicator->apply($plan['merges']); expect($moved->refresh()->producer_id)->toBe($keeper->id); $this->deduplicator->rollback($report); // A lényeg: a mozgatott sor visszakerül, a helyben maradt NEM mozdul el. // Egy egyszerű fordított leképezés mindkettőt átírná - ezért tárolunk soronként. expect($moved->refresh()->producer_id)->toBe($loser->id) ->and($stayed->refresh()->producer_id)->toBe($keeper->id); $loser->refresh(); expect($loser->name)->toBe('Danone ') ->and($loser->status)->toBe(DbStatusFieldEnum::active) ->and((bool) $loser->canSee)->toBeTrue() ->and($loser->note)->toBeNull(); }); test('a döntési lap legenerálható és visszaolvasható', function () { $keeper = producer('Danone'); $loser = producer('Danone '); productFor($keeper, 'A1'); $path = storage_path('app/private/teszt_dontesi_lap.xlsx'); @unlink($path); $this->artisan('producers:dedupe-report', ['--path' => $path])->assertExitCode(0); expect(file_exists($path))->toBeTrue(); $sheet = IOFactory::load($path)->getActiveSheet(); $found = []; for ($row = 1; $row <= $sheet->getHighestDataRow(); $row++) { for ($col = 1; $col <= 9; $col++) { $found[] = (string) $sheet->getCellByColumnAndRow($col, $row)->getValue(); } } // A fejlécnevek adják a gépi visszaolvasás horgonyait expect($found)->toContain(ProducersDedupeReport::COLUMN_ID) ->and($found)->toContain(ProducersDedupeReport::COLUMN_FINAL_NAME) ->and($found)->toContain((string) $keeper->id) ->and($found)->toContain((string) $loser->id) ->and($found)->toContain('"Danone "'); @unlink($path); }); test('a dry-run nem módosít semmit', function () { $keeper = producer('Danone'); $loser = producer('Danone '); $moved = productFor($loser, 'B1'); productFor($keeper, 'A1'); $path = storage_path('app/private/teszt_dry_run.xlsx'); @unlink($path); $this->artisan('producers:dedupe-report', ['--path' => $path])->assertExitCode(0); $this->artisan('producers:dedupe', ['file' => $path])->assertExitCode(0); expect($moved->refresh()->producer_id)->toBe($loser->id) ->and($loser->refresh()->status)->toBe(DbStatusFieldEnum::active) ->and(DB::table('producers')->where('status', 'archive')->count())->toBe(0); @unlink($path); });