d2d.emegrendeles.hu/tests/Feature/ProducerDedupeTest.php
E98Developer 7e32a870ae ADD EV3-357 a döntési lap adja a csoportosítást (csoportokon átívelő összevonás)
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 <noreply@anthropic.com>
2026-08-27 17:30:24 +02:00

269 lines
9.3 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('hiányzó végleges név esetén hibát jelez, és nem tervez összevonást', function () {
$a = producer('Danone');
$b = producer('Danone ');
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $a->id, 'final_name' => null],
['id' => $b->id, 'final_name' => null],
]));
expect($plan['merges'])->toBe([])
->and($plan['errors'][0])->toContain('Hiányzó végleges név');
});
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 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 ');
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);
});