d2d.emegrendeles.hu/app/Services/ProducerDeduplicator.php
E98Developer 87cab4cad5 ADD EV3-357 a laza javaslatok eltérés-jelleg szerint kategorizálva
101 javaslat egy kupacban túl sok egy kézi átnézéshez, ráadásul nagyon eltérő
mérlegelést igényelnek: a "Békás Kft." és a "Békás Kft" között nincs mit
eldönteni, a "Dénes Natura" és a "Dénes Natura Kft" között viszont van.

- ProducerDeduplicator::differenceCategory(): a LEGKEVÉSBÉ agresszív átalakítás,
  ami már egy kulcsra hozza a csoport tagjait - csak írásjel / egybeírás /
  ékezet / cégforma / vegyes.
- a riport ebben a sorrendben írja ki a csoportokat (elöl a gyakorlatilag
  eldöntött esetek), és a "csak írásjel" kategóriánál előre beírja a javaslatot;
  a többinél a végleges név üresen marad, a javaslat a Megjegyzés oszlopban.

A d2d másolaton: 14 csak írásjel (előre kitöltve, azonnal végrehajtható),
7 egybeírás, 8 ékezet, 72 cégforma - utóbbi 87 vár a megrendelő döntésére.

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

584 lines
21 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;
/** Az eltérés jellege egy laza jelölt-csoportban (lásd differenceCategory) */
public const DIFF_PUNCTUATION = 'csak írásjel';
public const DIFF_SPACING = 'egybeírás';
public const DIFF_ACCENT = 'ékezet';
public const DIFF_COMPANY_FORM = 'cégforma';
public const DIFF_MIXED = 'vegyes';
/**
* 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;
}
/**
* Egy laza jelölt-csoport eltérésének jellege - a LEGKEVÉSBÉ agresszív átalakítás,
* ami már egy kulcsra hozza a tagokat.
*
* Ez adja meg a megrendelőnek, mennyi mérlegelést igényel a sor: a "csak írásjel"
* eseteknél gyakorlatilag nincs döntés, a "cégforma" és a "vegyes" viszont valódi
* ismeretet kíván a cégekről.
*
* @param array<int, array{producer: object, counts: array<string, int>}> $members
*/
public function differenceCategory(array $members): string
{
$names = array_map(fn ($m) => (string) $m['producer']->name, $members);
$levels = [
self::DIFF_PUNCTUATION => fn ($n) => $this->stripPunctuation(NameNormalizer::normalize($n)),
self::DIFF_SPACING => fn ($n) => preg_replace('/\s+/u', '',
$this->stripPunctuation(NameNormalizer::normalize($n))),
self::DIFF_ACCENT => fn ($n) => preg_replace('/\s+/u', '',
$this->stripAccents($this->stripPunctuation(NameNormalizer::normalize($n)))),
self::DIFF_COMPANY_FORM => fn ($n) => $this->splitCompanyForm($n)[0],
];
foreach ($levels as $category => $key) {
if (count(array_unique(array_map($key, $names))) === 1) {
return $category;
}
}
return self::DIFF_MIXED;
}
private function stripPunctuation(string $text): string
{
return trim(preg_replace('/\s+/u', ' ', preg_replace('/[.,\-\'"()]/u', ' ', $text) ?? $text) ?? $text);
}
private function stripAccents(string $text): string
{
return strtr($text, [
'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ö' => 'O',
'Ő' => 'O', 'Ú' => 'U', 'Ü' => 'U', 'Ű' => 'U',
]);
}
/**
* 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 = $this->stripAccents($this->stripPunctuation(NameNormalizer::normalize($name)));
$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;
}
}