d2d.emegrendeles.hu/tests/Feature/ProducerDedupeTest.php
E98Developer 2ced04c086 ADD EV3-357 gyártó duplikációk összevonása (producers:dedupe)
A megelőzés után a már felhalmozódott adat takarítása: 70 duplikált névcsoport,
146 rekord, ebből 76 beolvasztandó.

Két parancs, mert a kivezetés három környezeten megy (local -> d2d -> éles), és
minden környezetnek SAJÁT duplikátum-készlete van - a döntési lapot ezért ott
kell újragenerálni, nem beégetett ID-listával dolgozunk.

- producers:dedupe-report — döntési xlsx a megrendelőnek. A megtartandó a
  legtöbb TERMÉKKEL rendelkező rekord (döntetlennél több rendelési tétel, majd
  régebbi rekord); a végleges nevet a megrendelő hagyja jóvá, mert 48 csoport
  csak kis/nagybetűben tér el, és az írásmód üzleti döntés.
- producers:dedupe — alapból csak kimutatás, --apply hajt végre.
  A lapot FEJLÉCNÉV alapján olvassa, nem oszlopbetű szerint, így a megrendelő
  beszúrhat oszlopot vagy átrendezheti a lapot anélkül, hogy eltörne.
  Hiányos csoportot és ismeretlen azonosítót visszautasít.
- Visszafordíthatóság: a jelentés SORONKÉNT tárolja a régi producer_id-t, mert
  a fordított leképezés azokat a sorokat is átírná, amelyek eredetileg is a
  megtartott rekordra mutattak. --rollback ebből állít vissza.
- Tartós nyom a jelentésfájltól függetlenül: a beolvasztott rekord archive +
  canSee=0 lesz, és a note-jába kerül, hova olvadt be.
- A két nagy táblán nincs index a producer_id-n, ezért táblánként EGY UPDATE
  fut CASE leképezéssel - 76 külön WHERE 76 teljes scant jelentene.

Mért eredmény a d2d másolaton: 27 mp alatt 314 termék + 27 272 archív + 115
árlista-sor átírva; a vizsgált árlistán a "Módosult" sorok 126 -> 23, a
gyártó-diffek 114 -> 2. Visszagörgetés után minden szám visszaállt, és az
újraszámolt diff ismét 126 - az ok-okozat mindkét irányban igazolt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:09:54 +02:00

223 lines
7.4 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('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);
});