d2d.emegrendeles.hu/app/Services/ProducerDeduplicator.php
E98Developer 4198c03f5a ADD EV3-357 laza duplikáció-jelöltek a döntési lapon (--loose)
A takarítás első köre után is maradtak nyilvánvaló duplikátumok: "Békás Kft." /
"Békás Kft", "Gast Food" / "Gast-Food", "Alföldi Tej" / "Alfölditej". A
NameNormalizer csak a kis/nagybetűt, a / és _ szeparátort és a többszörös
szóközt kezeli - az írásjelekhez, a kötőjelhez, az egybeíráshoz és az
ékezethez nem nyúl.

A szabályt SZÁNDÉKOSAN nem lazítottuk, mert két, ellentétes igényű feladatot
szolgál ki: az import-párosításnál egy téves egyezés csendben rossz gyártóhoz
rendelne termékeket, a takarítási javaslatnál viszont a megrendelő nemet mond
rá. Ezért a kettő szétválik.

- ProducerDeduplicator::looseCandidateGroups(): megengedőbb kulcs (írásjel,
  ékezet, szóköz elhagyása) KIZÁRÓLAG javaslatokhoz. A különböző cégformát
  (Kft vs Zrt) itt sem vonja össze - az valódi különbség, nem elírás -, a
  cégforma nélküli név viszont párba állhat, ha csak egyféle forma van.
- producers:dedupe-report --loose: a javaslatok "ellenőrizendő" típussal, ÜRES
  végleges névvel kerülnek a lapra, a javaslat a Megjegyzés oszlopban. Így az
  alapértelmezett viselkedés a "nem nyúlunk hozzá", a döntés tudatos kitöltés.
- buildPlan: az üres végleges név mostantól KIHAGYÁS, nem hiba - ez a javaslat
  elutasításának módja. A kihagyott csoportokat a parancs tételesen kiírja.
- buildPlan bármely aktív gyártót elfogad a lapon, nem csak a szigorú
  duplikátumok tagjait.

A d2d másolaton az első kör után: 0 biztos duplikáció, 101 ellenőrizendő
javaslat 241 rekorddal.

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

533 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Enums\DbStatusFieldEnum;
use App\Support\NameNormalizer;
use Illuminate\Support\Facades\DB;
/**
* Duplikált gyártó törzsadat felderítése és összevonása.
*
* A duplikátumok onnan származnak, hogy a legacy árlista import karakterre pontos
* egyezést követelt a gyártónévre, és találat híján újat vett fel a nyers Excel
* értékkel - így lett a "Danone" mellett "Danone " is. Két helyen fáj:
*
* - az árlista feldolgozó valódi változás nélkül is módosulást jelez, mert a termék
* az egyik, a névfeloldás a másik rekordra mutat,
* - a Termék mennyiség statisztika gyártóra szűrve a másik ID alá könyvelt tételeket
* kihagyja a riportból.
*
* A keletkezés útját a NameNormalizer bevezetése zárta le; ez az osztály a már
* felhalmozódott adatot takarítja.
*/
class ProducerDeduplicator
{
/** A hivatkozó táblák: tábla => oszlop. FK csak a pricelist_file_lines-on van. */
public const REFERENCING_TABLES = [
'products' => 'producer_id',
'order_archives_items' => 'producer_id',
'pricelist_file_lines' => 'producer_id',
];
private const UPDATE_CHUNK = 5000;
/**
* Duplikált névcsoportok, csoportonként a tagokkal és a hivatkozás-darabszámokkal.
* A tagok rendezettek: az első a javasolt megtartandó (legtöbb termék).
*
* @return array<string, array<int, array{producer: object, counts: array<string, int>}>>
*/
public function duplicateGroups(): array
{
$byNormalized = [];
foreach ($this->activeMembers() as $member) {
$byNormalized[NameNormalizer::normalize($member['producer']->name)][] = $member;
}
$groups = [];
foreach ($byNormalized as $normalized => $members) {
if (count($members) < 2) {
continue;
}
$groups[$normalized] = $this->sortByKeeperPriority($members);
}
ksort($groups);
return $groups;
}
/**
* Laza jelöltek: olyan gyártók, amelyeket a szigorú szabály NEM köt össze, de nagy
* eséllyel ugyanaz a cég - elírás, kötőjel, egybeírás, hiányzó ékezet miatt térnek el.
*
* Ezek SOSEM olvadnak össze maguktól: a riportba javaslatként kerülnek, a döntést a
* megrendelő hozza. Ezért lehet itt megengedőbb a szabály, mint az import-párosításnál,
* ahol egy téves egyezés csendben rossz gyártóhoz rendelne termékeket.
*
* Egy dolgot viszont itt is tiszteletben tartunk: a KÜLÖNBÖZŐ cégforma (Kft vs Zrt)
* valódi különbség, nem elírás - azokat nem javasoljuk összevonásra. A cégforma
* nélküli név viszont párba állhat egy cégformással, ha csak egyféle forma van.
*
* @return array<string, array<int, array{producer: object, counts: array<string, int>}>>
*/
public function looseCandidateGroups(): array
{
$byBase = [];
foreach ($this->activeMembers() as $member) {
[$base, $form] = $this->splitCompanyForm($member['producer']->name);
if ($base === '') {
continue;
}
$byBase[$base][$form][] = $member;
}
$candidates = [];
foreach ($byBase as $base => $byForm) {
$explicitForms = array_diff(array_keys($byForm), ['']);
if (count($explicitForms) > 1) {
// Kft ÉS Zrt is van ugyanarra a névre: ezek nem egymás elírásai. A
// formánként külön csoportok mehetnek, a forma nélküli viszont nem
// rendelhető egyértelműen egyikhez sem, ezért kimarad.
foreach ($explicitForms as $form) {
$candidates[$base . '|' . $form] = $byForm[$form];
}
continue;
}
$candidates[$base] = array_merge(...array_values($byForm));
}
// Csak az érdekes: ami több szigorú csoportot fog össze (vagy olyan rekordokat,
// amiket a szigorú szabály külön hagyott).
$strictKeys = [];
foreach ($candidates as $key => $members) {
$strictKeys[$key] = array_unique(array_map(
fn ($m) => NameNormalizer::normalize($m['producer']->name),
$members,
));
}
$result = [];
foreach ($candidates as $key => $members) {
if (count($members) < 2 || count($strictKeys[$key]) < 2) {
continue;
}
$result[$key] = $this->sortByKeeperPriority($members);
}
ksort($result);
return $result;
}
/**
* Név szétbontása alapnévre és cégforma-toldatra, a "csak elírás" eltérések
* kiszűrésével: írásjelek, ékezetek és szóközök elhagyásával.
*
* @return array{0: string, 1: string} [alapnév, cégforma]
*/
private function splitCompanyForm(string $name): array
{
$normalized = NameNormalizer::normalize($name);
$normalized = preg_replace('/[.,\-\'"()]/u', ' ', $normalized) ?? $normalized;
$normalized = strtr($normalized, [
'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ö' => 'O',
'Ő' => 'O', 'Ú' => 'U', 'Ü' => 'U', 'Ű' => 'U',
]);
$form = '';
$pattern = '/\b(KFT|ZRT|BT|NYRT|KKT|RT|GMBH|LTD|INC|SRL|NV|BV|AG|SA|SPA)\b/u';
if (preg_match($pattern, $normalized, $matches)) {
$form = $matches[1];
$normalized = preg_replace($pattern, ' ', $normalized) ?? $normalized;
}
// A szóközök teljes elhagyása fogja meg az egybeírást ("Alfölditej" = "Alföldi Tej")
return [preg_replace('/\s+/u', '', $normalized) ?? '', $form];
}
/**
* @return array<int, array{producer: object, counts: array<string, int>}>
*/
private function activeMembers(): array
{
$counts = $this->referenceCounts();
return DB::table('producers')
->whereNotIn('status', [DbStatusFieldEnum::archive, DbStatusFieldEnum::deleted])
->orderBy('id')
->get()
->map(fn ($producer) => [
'producer' => $producer,
'counts' => [
'products' => $counts['products'][$producer->id] ?? 0,
'order_archives_items' => $counts['order_archives_items'][$producer->id] ?? 0,
'pricelist_file_lines' => $counts['pricelist_file_lines'][$producer->id] ?? 0,
],
])
->all();
}
/**
* A megtartandó rekord kerül előre: a legtöbb TERMÉKKEL rendelkező, mert így mozdul
* a legkevesebb sor. Döntetlennél a több rendelési tétel, majd a régebbi rekord.
*
* @param array<int, array> $members
* @return array<int, array>
*/
private function sortByKeeperPriority(array $members): array
{
usort($members, function ($a, $b) {
return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id]
<=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id];
});
return array_values($members);
}
/**
* @return array<string, array<int, int>> tábla => [producer_id => darab]
*/
private function referenceCounts(): array
{
$counts = [];
foreach (self::REFERENCING_TABLES as $table => $column) {
$counts[$table] = DB::table($table)
->selectRaw("{$column} as pid, COUNT(*) as c")
->whereNotNull($column)
->groupBy($column)
->pluck('c', 'pid')
->toArray();
}
return $counts;
}
/**
* Összevonási terv a döntési lap sorai alapján.
*
* A csoportosítás forrása a LAP "Csoport" oszlopa, nem az adatbázis normalizálása.
* Ez azért fontos, mert a megrendelő olyan gyártókat is egybe akarhat vonni, amiket a
* névnormalizálás nem tud összekötni: a "Kőröstej" és a "Kőröstej Kft" ugyanaz a cég,
* de a nevük érdemben különbözik. Ha a lap ezeket egy csoportba teszi, itt is egy
* összevonás lesz belőlük - így nem kell a végrehajtásba csoportokon átívelő logika.
*
* @param array<int, array{group: int|string, id: int, final_name: ?string}> $decisions
* @return array{merges: array<int, array>, errors: array<int, string>}
*/
public function buildPlan(array $decisions): array
{
$groups = $this->duplicateGroups();
$memberById = [];
foreach ($this->activeMembers() as $member) {
$memberById[$member['producer']->id] = $member;
}
$normalizedById = [];
foreach ($groups as $normalized => $members) {
foreach ($members as $member) {
$normalizedById[$member['producer']->id] = $normalized;
}
}
$sheetGroups = [];
$finalNames = [];
$skipped = [];
$errors = [];
foreach ($decisions as $row) {
$id = (int) ($row['id'] ?? 0);
if (! isset($memberById[$id])) {
$errors[] = "A(z) {$id} azonosítójú gyártó nem található (vagy már összevonták, "
. 'vagy más környezetből származik a lap).';
continue;
}
$key = trim((string) ($row['group'] ?? ''));
if ($key === '') {
$errors[] = "A(z) {$id} azonosítójú sorból hiányzik a csoport száma.";
continue;
}
$sheetGroups[$key][] = $id;
$name = trim((string) ($row['final_name'] ?? ''));
if ($name !== '') {
$finalNames[$key] ??= $name;
}
}
$validation = $this->validateGrouping($sheetGroups, $normalizedById, $groups);
$errors = array_merge($errors, $validation['errors']);
$merges = [];
foreach ($sheetGroups as $key => $ids) {
// A hibás csoportot NEM hajtjuk végre: egy hiányzó tag azt jelentené, hogy
// az összevonás után is maradna azonos nevű rekord.
if (isset($validation['invalid'][$key])) {
continue;
}
$members = array_values(array_map(fn ($id) => $memberById[$id], array_unique($ids)));
// Üres végleges név = a megrendelő NEM kéri az összevonást. Ez a laza
// javaslatok elutasításának módja, ezért nem hiba, hanem kihagyás - de
// jelentjük, hogy egy véletlen kihagyás se maradjon észrevétlen.
if (! isset($finalNames[$key])) {
$skipped[] = [
'group' => $key,
'names' => array_map(fn ($m) => (string) $m['producer']->name, $members),
];
continue;
}
$members = $this->sortByKeeperPriority($members);
$keeper = $members[0]['producer'];
$losers = array_slice($members, 1);
if ($losers === []) {
continue; // egytagú csoport: nincs mit összevonni
}
$merges[] = [
'group' => $key,
'keeper_id' => $keeper->id,
'keeper_name' => $keeper->name,
'final_name' => $finalNames[$key],
'rename' => trim((string) $keeper->name) !== $finalNames[$key],
'from' => array_map(fn ($m) => [
'id' => $m['producer']->id,
'name' => $m['producer']->name,
'counts' => $m['counts'],
], $losers),
];
}
return ['merges' => $merges, 'errors' => $errors, 'skipped' => $skipped];
}
/**
* A lap-csoportosítás ellenőrzése.
*
* A lap SZÉLESEBB csoportot csinálhat, mint a névnormalizálás (ez a cél), de
* SZŰKEBBET nem: ha az azonos nevű rekordok külön lap-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ő.
*
* @return array{errors: array<int, string>, invalid: array<string, true>}
*/
private function validateGrouping(array $sheetGroups, array $normalizedById, array $groups): array
{
$errors = [];
$invalid = [];
$sheetKeysByNormalized = [];
$idsByNormalized = [];
foreach ($sheetGroups as $key => $ids) {
foreach ($ids as $id) {
// A lapon szerepelhet olyan gyártó is, ami NEM tagja szigorú duplikátum
// csoportnak (a laza javaslatok ilyenek) - ott nincs mit ellenőrizni.
if (! isset($normalizedById[$id])) {
continue;
}
$normalized = $normalizedById[$id];
$sheetKeysByNormalized[$normalized][$key] = true;
$idsByNormalized[$normalized][$id] = true;
}
}
foreach ($sheetKeysByNormalized as $normalized => $keys) {
if (count($keys) > 1) {
$errors[] = 'Az azonos nevű gyártók ("' . $normalized . '") több csoportba kerültek a lapon ('
. implode(', ', array_keys($keys)) . '). Ezeket egy csoportba kell tenni, '
. 'különben duplikátum maradna utánuk.';
foreach (array_keys($keys) as $key) {
$invalid[$key] = true;
}
}
$expected = array_map(fn ($m) => $m['producer']->id, $groups[$normalized]);
$missing = array_diff($expected, array_keys($idsByNormalized[$normalized]));
if ($missing !== []) {
$errors[] = 'A döntési lap nem tartalmazza a csoport minden tagját (hiányzó azonosító: '
. implode(', ', $missing) . '). Generáld újra a lapot ebben a környezetben.';
foreach (array_keys($keys) as $key) {
$invalid[$key] = true;
}
}
}
return ['errors' => $errors, 'invalid' => $invalid];
}
/**
* A terv végrehajtása. Előbb visszaállítási pontot ír, csak utána módosít.
*
* @return array a jelentés, ami egyben a visszagörgetés bemenete is
*/
public function apply(array $merges): array
{
$report = [
'created_at' => now()->toDateTimeString(),
'database' => DB::connection()->getDatabaseName(),
'merges' => [],
'restore' => [],
'totals' => array_fill_keys(array_keys(self::REFERENCING_TABLES), 0),
];
// producer_id leképezés: honnan => hova
$map = [];
foreach ($merges as $merge) {
foreach ($merge['from'] as $from) {
$map[$from['id']] = $merge['keeper_id'];
}
}
if ($map === []) {
return $report;
}
// 1. Visszaállítási pont: soronként a régi érték. Enélkül a művelet nem lenne
// visszafordítható, mert a fordított leképezés azokat a sorokat is átírná,
// amelyek eredetileg is a megtartott rekordra mutattak.
foreach (self::REFERENCING_TABLES as $table => $column) {
$report['restore'][$table] = DB::table($table)
->whereIn($column, array_keys($map))
->pluck($column, 'id')
->toArray();
}
$report['restore']['producers'] = DB::table('producers')
->whereIn('id', array_merge(array_keys($map), array_column($merges, 'keeper_id')))
->get(['id', 'name', 'status', 'canSee', 'note'])
->keyBy('id')
->toArray();
// 2. Hivatkozások átírása - táblánként EGY menetben, mert a producer_id-n a nagy
// táblákon nincs index, tehát minden külön WHERE teljes scant jelentene.
foreach (self::REFERENCING_TABLES as $table => $column) {
$report['totals'][$table] = $this->remap($table, $column, $map);
}
// 3. A gyártó rekordok rendezése
foreach ($merges as $merge) {
if ($merge['rename']) {
DB::table('producers')->where('id', $merge['keeper_id'])
->update(['name' => $merge['final_name'], 'updated_at' => now()]);
}
foreach ($merge['from'] as $from) {
DB::table('producers')->where('id', $from['id'])->update([
'status' => DbStatusFieldEnum::archive,
'canSee' => 0,
// Tartós nyom az adatbázisban: a jelentésfájl elveszhet, ez nem.
'note' => trim(sprintf(
'Összevonva ide: #%d (%s) %s producers:dedupe',
$merge['keeper_id'],
$merge['final_name'],
now()->toDateString(),
)),
'updated_at' => now(),
]);
}
$report['merges'][] = [
'keeper_id' => $merge['keeper_id'],
'final_name' => $merge['final_name'],
'from' => array_map(fn ($f) => ['id' => $f['id'], 'name' => $f['name']], $merge['from']),
];
}
return $report;
}
/**
* Egyetlen UPDATE táblánként, CASE leképezéssel.
*/
private function remap(string $table, string $column, array $map): int
{
$ids = array_map('intval', array_keys($map));
$updated = 0;
foreach (array_chunk($ids, 200) as $chunk) {
$cases = '';
foreach ($chunk as $from) {
$cases .= sprintf(' WHEN %d THEN %d', $from, (int) $map[$from]);
}
$updated += DB::update(sprintf(
'UPDATE `%s` SET `%s` = CASE `%s`%s END WHERE `%s` IN (%s)',
$table,
$column,
$column,
$cases,
$column,
implode(',', $chunk),
));
}
return $updated;
}
/**
* Visszagörgetés a jelentésfájlból: minden érintett sor visszakapja a saját,
* eredeti producer_id-ját, a gyártó rekordok pedig az eredeti nevüket/státuszukat.
*/
public function rollback(array $report): array
{
$restored = array_fill_keys(array_keys(self::REFERENCING_TABLES), 0);
foreach (self::REFERENCING_TABLES as $table => $column) {
$rows = (array) ($report['restore'][$table] ?? []);
foreach (array_chunk($rows, self::UPDATE_CHUNK, true) as $chunk) {
$byValue = [];
foreach ($chunk as $rowId => $producerId) {
$byValue[(int) $producerId][] = (int) $rowId;
}
foreach ($byValue as $producerId => $rowIds) {
$restored[$table] += DB::table($table)
->whereIn('id', $rowIds)
->update([$column => $producerId]);
}
}
}
foreach ((array) ($report['restore']['producers'] ?? []) as $id => $producer) {
$producer = (array) $producer;
DB::table('producers')->where('id', (int) $id)->update([
'name' => $producer['name'],
'status' => $producer['status'],
'canSee' => $producer['canSee'],
'note' => $producer['note'],
'updated_at' => now(),
]);
}
return $restored;
}
}