d2d.emegrendeles.hu/app/Services/ProducerDeduplicator.php
E98Developer 7e32a870ae ADD EV3-357 a döntési lap adja a csoportosítást (csoportokon átívelő összevonás)
A megrendelő 2026-08-27-én hat esetben jelezte, hogy két külön névcsoportunk
valójában ugyanaz a cég: a rövid név és a cégformás név (Kőröstej / Kőröstej
Kft, Pick / Pick Szeged Zrt, White Lake / White Lake Kft, ...). A
névnormalizálás ezt nem tudhatja, mert a nevek érdemben különböznek.

- buildPlan a LAP "Csoport" oszlopa szerint csoportosít, nem az adatbázis
  normalizálása szerint. Így a lap szélesebb csoportot is kijelölhet - a
  végrehajtásba nem kell csoportokon átívelő logika.
- validateGrouping: a lap szélesebb csoportot csinálhat, SZŰKEBBET nem. Ha az
  azonos nevű rekordok külön 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ő. Ilyenkor a csoport
  kimarad a végrehajtásból, nem csak figyelmeztetés.
- a megtartott REKORD továbbra is a legtöbb terméket tartalmazó (a legkevesebb
  sor mozdul), a NEVE viszont a választott cégnév

2 új teszt a csoportokon átívelő összevonásra és a szétszórás elutasítására.

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

406 lines
15 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
{
$counts = $this->referenceCounts();
$byNormalized = [];
foreach (DB::table('producers')->orderBy('id')->get() as $producer) {
$byNormalized[NameNormalizer::normalize($producer->name)][] = $producer;
}
$groups = [];
foreach ($byNormalized as $normalized => $members) {
if (count($members) < 2) {
continue;
}
$decorated = array_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,
],
], $members);
// A megtartandó a legtöbb TERMÉKKEL rendelkező rekord: a termékek a napi
// működés alapja. Döntetlennél a több rendelési tétel, majd a régebbi rekord.
usort($decorated, 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];
});
$groups[$normalized] = $decorated;
}
ksort($groups);
return $groups;
}
/**
* @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();
$normalizedById = [];
$memberById = [];
foreach ($groups as $normalized => $members) {
foreach ($members as $member) {
$normalizedById[$member['producer']->id] = $normalized;
$memberById[$member['producer']->id] = $member;
}
}
$sheetGroups = [];
$finalNames = [];
$errors = [];
foreach ($decisions as $row) {
$id = (int) ($row['id'] ?? 0);
if (! isset($normalizedById[$id])) {
$errors[] = "A(z) {$id} azonosítójú gyártó nem szerepel duplikált csoportban "
. '(időközben megváltozott az adat, 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)));
if (! isset($finalNames[$key])) {
$errors[] = 'Hiányzó végleges név a(z) ' . $key . '. csoportnál: '
. implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members));
continue;
}
// A megtartott REKORD a legtöbb terméket tartalmazó (így mozdul a legkevesebb
// sor), a NEVE viszont a megrendelő által választott végleges név lesz.
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];
});
$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];
}
/**
* 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) {
$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;
}
}