From c0888db4a2ef7d3b7f2f62a8350d185ea4591dac Mon Sep 17 00:00:00 2001 From: E98Developer Date: Thu, 27 Aug 2026 07:56:30 +0200 Subject: [PATCH] =?UTF-8?q?FIX=20EV3-357=20gy=C3=A1rt=C3=B3=20duplik=C3=A1?= =?UTF-8?q?ci=C3=B3k=20megel=C5=91z=C3=A9se=20az=20=C3=A1rlista=20importba?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Http/Controllers/OrderController.php | 2 +- app/Services/PriceListService.php | 35 +++++- app/Services/PricelistFileProcessService.php | 23 ++-- app/Support/NameNormalizer.php | 44 ++++++++ tests/Feature/ProducerDuplicationTest.php | 113 +++++++++++++++++++ 5 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 app/Support/NameNormalizer.php create mode 100644 tests/Feature/ProducerDuplicationTest.php diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php index 8d524da..eeebbee 100644 --- a/app/Http/Controllers/OrderController.php +++ b/app/Http/Controllers/OrderController.php @@ -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') { diff --git a/app/Services/PriceListService.php b/app/Services/PriceListService.php index 26c625f..86e05c7 100644 --- a/app/Services/PriceListService.php +++ b/app/Services/PriceListService.php @@ -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|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)) { diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index beb27e6..f08fa17 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -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(); diff --git a/app/Support/NameNormalizer.php b/app/Support/NameNormalizer.php new file mode 100644 index 0000000..38ab764 --- /dev/null +++ b/app/Support/NameNormalizer.php @@ -0,0 +1,44 @@ +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(); +});