d2d.emegrendeles.hu/tests/Feature/ProducerDuplicationTest.php
E98Developer c0888db4a2 FIX EV3-357 gyártó duplikációk megelőzése az árlista importban
A rendszerben 70 duplikált gyártónév halmozódott fel (146 rekord, 1166-ból),
mert a legacy import karakterre pontos egyezést követelt: a "Danone " nem
találta meg a meglévő "Danone"-t, és felvett egy újat - a nyers, trimmeletlen
Excel-értékkel. Gyártóhoz nincs admin felület, ez az egyetlen keletkezési út.

Következménye kettős: az árlista feldolgozó valódi változás nélkül is
módosulást jelez (a vizsgált fájlban 126 "Módosult" sorból 103 emiatt), a
Termék mennyiség statisztika pedig egy gyártóra szűrve a másik ID alá könyvelt
tételeket kihagyja a riportból.

- új App\Support\NameNormalizer: a normalizálás egyetlen definíciója. Három
  független pont használja (legacy import, új feldolgozó, jövőbeli összevonó
  parancs); ha ezek elcsúsznak, az újra duplikált törzsadatot szül.
- PriceListService::getProducerByName(): a karakterre pontos találat továbbra
  is elsőbbséget élvez, de ha nincs, jön a normalizált egyeztetés. Duplikátum
  esetén a legrégebbi rekordot adja vissza - a termékek arra mutatnak.
- PriceListService::addNewProducer(): trimmel mentés előtt
- getProducerLookupMap(): kihagyja az archive/deleted státuszú gyártókat.
  Enélkül a beolvasztott rekord visszakerülne a feloldásba, és a takarítás
  hatástalan lenne. Ma nem változtat semmin, csak felkészít.

Az új feldolgozó lookupjának tie-breakjéhez SZÁNDÉKOSAN nem nyúltam: egy
sorral eltüntetné a hamis "Módosult" jelzéseket, de az a funkcióban fedné el
az adathibát. A javulás a tényleges adattakarításból jöjjön.

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

114 lines
4.5 KiB
PHP

<?php
use App\Enums\DbStatusFieldEnum;
use App\Models\Producer;
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use App\Support\NameNormalizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function callPrivate(object $object, string $method, mixed ...$args): mixed
{
$reflection = new ReflectionMethod($object, $method);
$reflection->setAccessible(true);
return $reflection->invoke($object, ...$args);
}
function makeProducer(string $name, string $status = DbStatusFieldEnum::active): Producer
{
return Producer::create(['name' => $name, 'status' => $status, 'canSee' => 1]);
}
test('a normalizálás összevonja a kis/nagybetű, szóköz és szeparátor eltéréseket', function () {
expect(NameNormalizer::normalize('Danone'))->toBe('DANONE')
->and(NameNormalizer::normalize('Danone '))->toBe('DANONE')
->and(NameNormalizer::normalize(' danone '))->toBe('DANONE')
->and(NameNormalizer::normalize('Alba-Gel Kft'))->toBe('ALBA-GEL KFT')
->and(NameNormalizer::normalize('Tej/Sajt'))->toBe('TEJ SAJT')
->and(NameNormalizer::normalize('Tej_Sajt'))->toBe('TEJ SAJT')
->and(NameNormalizer::normalize(null))->toBe('');
});
test('a matches() üres nevet nem tekint egyezésnek', function () {
expect(NameNormalizer::matches('Danone', 'danone '))->toBeTrue()
->and(NameNormalizer::matches('', ''))->toBeFalse()
->and(NameNormalizer::matches(null, 'Danone'))->toBeFalse();
});
test('a legacy import megtalálja a meglévő gyártót eltérő írásmód esetén is', function () {
$existing = makeProducer('Danone');
$service = app(PriceListService::class);
callPrivate($service, 'loadProducers', 'name');
// Ezek korábban mind ÚJ gyártót hoztak létre volna - így keletkezett 70 duplikátum.
foreach (['Danone ', ' danone', 'DANONE', 'Danone '] as $variant) {
$found = callPrivate($service, 'getProducerByName', $variant);
expect($found)->not->toBeFalse()
->and($found['id'])->toBe($existing->id);
}
});
test('a legacy import változatlanul hagyja a valóban új gyártót', function () {
makeProducer('Danone');
$service = app(PriceListService::class);
callPrivate($service, 'loadProducers', 'name');
expect(callPrivate($service, 'getProducerByName', 'Bonduelle'))->toBeFalse()
->and(callPrivate($service, 'getProducerByName', ' '))->toBeFalse();
});
test('a karakterre pontos találat elsőbbséget élvez', function () {
$old = makeProducer('Voyagex ');
$new = makeProducer('Voyagex');
$service = app(PriceListService::class);
callPrivate($service, 'loadProducers', 'name');
// Ha a fájlban pontosan az egyik rekord neve szerepel, azt kell visszaadni -
// a normalizált egyeztetés csak tartalék útvonal.
expect(callPrivate($service, 'getProducerByName', 'Voyagex')['id'])->toBe($new->id)
->and(callPrivate($service, 'getProducerByName', 'Voyagex ')['id'])->toBe($old->id);
});
test('pontos találat híján duplikátumból a legrégebbi rekordot adja vissza', function () {
// Ez a takarítás előtti adatállapot: a termékek túlnyomó része a régi rekordra
// mutat, tehát az a kanonikus. Korábban a feloldás a LEGÚJABBAT választotta.
$old = makeProducer('Voyagex ');
$new = makeProducer('Voyagex');
$service = app(PriceListService::class);
callPrivate($service, 'loadProducers', 'name');
// Egyik rekord nevével sem egyezik karakterre
$found = callPrivate($service, 'getProducerByName', 'VOYAGEX');
expect($found['id'])->toBe($old->id)
->and($found['id'])->not->toBe($new->id);
});
test('az új gyártó neve körülvágva kerül mentésre', function () {
$service = app(PriceListService::class);
$id = callPrivate($service, 'addNewProducer', ['name' => ' Vadonatúj Gyártó ']);
expect(Producer::find($id)->name)->toBe('Vadonatúj Gyártó');
});
test('az archivált gyártó kimarad az árlista feldolgozó feloldásából', function () {
$active = makeProducer('Danone');
$merged = makeProducer('Danone ', DbStatusFieldEnum::archive);
$lookup = callPrivate(app(PricelistFileProcessService::class), 'getProducerLookupMap');
// Enélkül a beolvasztott rekord visszakerülne a feloldásba, és a takarítás
// hatástalan maradna.
expect($lookup['DANONE'] ?? null)->toBe($active->id)
->and(in_array($merged->id, $lookup, true))->toBeFalse();
});