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>
This commit is contained in:
E98Developer 2026-08-27 07:56:30 +02:00
parent 67d2f73eee
commit c0888db4a2
5 changed files with 203 additions and 14 deletions

View File

@ -1759,7 +1759,7 @@ private function updateSend(Request $request, int $orderId): JsonResponse
}
if (config('mail.MailToOrderAddress') != 'city99@e98.hu') {
$m->bcc('city@e98.hu');
$m->bcc('city99@e98.hu');
}
// $m->attach($attach,['as'=>$fileNameWithExt]);
if ($fileType !== 'none') {

View File

@ -11,6 +11,7 @@
use App\Repositories\ProducerRepository;
use App\Repositories\ProductRepository;
use App\Repositories\SupplierRepository;
use App\Support\NameNormalizer;
use Illuminate\Support\Collection;
use Illuminate\Support\MessageBag;
@ -22,6 +23,9 @@ class PriceListService
protected ProducerRepository $producerRepository;
/** @var array<string, array>|null normalizált gyártónév => rekord (lusta index) */
private ?array $producersByNormalizedName = null;
protected ProductRepository $productRepository;
protected MessageBag $error;
@ -567,6 +571,7 @@ private function loadProducers(?string $index = null)
$this->producerIndexedBy = $index;
}
$this->producers = $this->producerRepository->allByIndex($index);
$this->producersByNormalizedName = null;
}
@ -577,7 +582,32 @@ private function getProducerByName($name)
return $this->producers[$name];
}
return false;
// Normalizált egyeztetés, ha a karakterre pontos találat elmarad.
//
// A beszállítói Excelekben a gyártó neve rendszeresen eltérő kis/nagybetűvel
// vagy fölösleges szóközzel érkezik ("Danone " vs "Danone"). Korábban ilyenkor
// ÚJ gyártó jött létre - így halmozódott fel a rendszerben 70 duplikált név,
// ami az árlista feldolgozóban hamis módosulást, a statisztikában pedig hiányzó
// tételeket okozott (egy névre szűrve a másik ID alá könyvelt sorok kimaradtak).
$normalized = NameNormalizer::normalize((string) $name);
if ($normalized === '') {
return false;
}
if ($this->producersByNormalizedName === null) {
$this->producersByNormalizedName = [];
foreach ($this->producers as $producerName => $producer) {
// Az ELSŐ előfordulás nyer: a Producer::all() id szerint jön, tehát a
// legrégebbi rekord a kanonikus - jellemzően az, amelyikre a termékek
// többsége mutat.
$key = NameNormalizer::normalize((string) $producerName);
$this->producersByNormalizedName[$key] ??= $producer;
}
}
return $this->producersByNormalizedName[$normalized] ?? false;
}
private function initProducerRepository()
@ -590,6 +620,9 @@ private function initProducerRepository()
private function addNewProducer($data)
{
$this->initProducerRepository();
// Körülvágás mentés előtt: a nyers Excel-értékből származó záró szóköz miatt
// keletkezett a duplikátumok egyharmada.
$data['name'] = trim((string) ($data['name'] ?? ''));
$data['canSee'] = 1;
$data['status'] = \App\Enums\DbStatusFieldEnum::active;
if ($id = $this->producerRepository->add($data)) {

View File

@ -7,6 +7,7 @@
use App\Enums\PricelistWorkflowStep;
use App\Models\PriceList;
use App\Models\PricelistFile;
use App\Support\NameNormalizer;
use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFileLine;
use App\Enums\PricelistFileLineStatusEnum;
@ -814,17 +815,10 @@ public function validate(PricelistFile $pricelistFile): bool
*/
protected function normalizeForComparison(string $text): string
{
// 1. Kis/Nagybetű érzéketlenség UTF-8 támogatással
$text = mb_strtoupper(trim($text), 'UTF-8');
// 2. Szeparátorok egységesítése (perjel és alulvonás cseréje)
// A hibaüzenetben látható alulvonás és a felhasználó által említett perjel miatt
$text = str_replace(['/', '_'], ' ', $text);
// 3. Felesleges szóközök eltávolítása (ha a csere után több szóköz maradt)
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
// A szabály a NameNormalizerben él, mert a legacy import és a gyártó-összevonó
// karbantartás is UGYANEZT az egyeztetést kell hogy használja - ha ezek
// elcsúsznak egymástól, az újra duplikált törzsadatot eredményez.
return NameNormalizer::normalize($text);
}
/**
@ -857,7 +851,12 @@ protected function getProductGroupLookupMap(): array
*/
protected function getProducerLookupMap(): array
{
return \App\Models\Producer::all()
// Az összevont (archive) és a törölt gyártók kimaradnak: enélkül a
// duplikáció-takarítás hatástalan lenne, mert a beolvasztott rekord
// továbbra is visszakerülne a feloldásba.
return \App\Models\Producer::query()
->whereNotIn('status', [DbStatusFieldEnum::archive, DbStatusFieldEnum::deleted])
->get()
->keyBy(fn($p) => $this->normalizeForComparison($p->name))
->map->id
->toArray();

View File

@ -0,0 +1,44 @@
<?php
namespace App\Support;
/**
* Nevek egységesítése összehasonlításhoz.
*
* Egy helyen van definiálva, mert három független ponton kell UGYANAZT a szabályt
* alkalmazni, és ha ezek elcsúsznak egymástól, az duplikált törzsadatot szül:
*
* - a legacy árlista import gyártó-párosítása (`PriceListService`),
* - az új árlista feldolgozó gyártó- és termékcsoport-feloldása
* (`PricelistFileProcessService`),
* - a gyártó-összevonó karbantartás.
*
* Pontosan ennek a hiánya okozta, hogy a `products` mellé 70 duplikált gyártónév
* keletkezett: a legacy import nyers, karakterre pontos egyezést követelt, ezért a
* "Danone " nem találta meg a meglévő "Danone"-t, és felvett egy újat.
*/
class NameNormalizer
{
/**
* Kis/nagybetű-érzéketlen, a `/` és `_` szeparátorokat szóközzé alakító,
* a többszörös szóközöket összevonó, körülvágott alak.
*/
public static function normalize(?string $text): string
{
$text = mb_strtoupper(trim((string) $text), 'UTF-8');
$text = str_replace(['/', '_'], ' ', $text);
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
}
/**
* Egyeznek-e a nevek a fenti szabály szerint.
*/
public static function matches(?string $a, ?string $b): bool
{
$normalizedA = self::normalize($a);
return $normalizedA !== '' && $normalizedA === self::normalize($b);
}
}

View File

@ -0,0 +1,113 @@
<?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();
});