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>
326 lines
11 KiB
PHP
326 lines
11 KiB
PHP
<?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);
|
||
});
|