d2d.emegrendeles.hu/tests/Feature/ProducerDedupeTest.php
E98Developer 4198c03f5a ADD EV3-357 laza duplikáció-jelöltek a döntési lapon (--loose)
A takarítás első köre után is maradtak nyilvánvaló duplikátumok: "Békás Kft." /
"Békás Kft", "Gast Food" / "Gast-Food", "Alföldi Tej" / "Alfölditej". A
NameNormalizer csak a kis/nagybetűt, a / és _ szeparátort és a többszörös
szóközt kezeli - az írásjelekhez, a kötőjelhez, az egybeíráshoz és az
ékezethez nem nyúl.

A szabályt SZÁNDÉKOSAN nem lazítottuk, mert két, ellentétes igényű feladatot
szolgál ki: az import-párosításnál egy téves egyezés csendben rossz gyártóhoz
rendelne termékeket, a takarítási javaslatnál viszont a megrendelő nemet mond
rá. Ezért a kettő szétválik.

- ProducerDeduplicator::looseCandidateGroups(): megengedőbb kulcs (írásjel,
  ékezet, szóköz elhagyása) KIZÁRÓLAG javaslatokhoz. A különböző cégformát
  (Kft vs Zrt) itt sem vonja össze - az valódi különbség, nem elírás -, a
  cégforma nélküli név viszont párba állhat, ha csak egyféle forma van.
- producers:dedupe-report --loose: a javaslatok "ellenőrizendő" típussal, ÜRES
  végleges névvel kerülnek a lapra, a javaslat a Megjegyzés oszlopban. Így az
  alapértelmezett viselkedés a "nem nyúlunk hozzá", a döntés tudatos kitöltés.
- buildPlan: az üres végleges név mostantól KIHAGYÁS, nem hiba - ez a javaslat
  elutasításának módja. A kihagyott csoportokat a parancs tételesen kiírja.
- buildPlan bármely aktív gyártót elfogad a lapon, nem csak a szigorú
  duplikátumok tagjait.

A d2d másolaton az első kör után: 0 biztos duplikáció, 101 ellenőrizendő
javaslat 241 rekorddal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:56:06 +02:00

326 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Console\Commands\ProducersDedupeReport;
use App\Enums\DbStatusFieldEnum;
use App\Models\Producer;
use App\Models\Product;
use App\Services\ProducerDeduplicator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function producer(string $name): Producer
{
return Producer::create(['name' => $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<int, array{id: int, final_name: ?string}> $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);
});