d2d.emegrendeles.hu/app/Services/ProducerDeduplicator.php
E98Developer 2ced04c086 ADD EV3-357 gyártó duplikációk összevonása (producers:dedupe)
A megelőzés után a már felhalmozódott adat takarítása: 70 duplikált névcsoport,
146 rekord, ebből 76 beolvasztandó.

Két parancs, mert a kivezetés három környezeten megy (local -> d2d -> éles), és
minden környezetnek SAJÁT duplikátum-készlete van - a döntési lapot ezért ott
kell újragenerálni, nem beégetett ID-listával dolgozunk.

- producers:dedupe-report — döntési xlsx a megrendelőnek. A megtartandó a
  legtöbb TERMÉKKEL rendelkező rekord (döntetlennél több rendelési tétel, majd
  régebbi rekord); a végleges nevet a megrendelő hagyja jóvá, mert 48 csoport
  csak kis/nagybetűben tér el, és az írásmód üzleti döntés.
- producers:dedupe — alapból csak kimutatás, --apply hajt végre.
  A lapot FEJLÉCNÉV alapján olvassa, nem oszlopbetű szerint, így a megrendelő
  beszúrhat oszlopot vagy átrendezheti a lapot anélkül, hogy eltörne.
  Hiányos csoportot és ismeretlen azonosítót visszautasít.
- Visszafordíthatóság: a jelentés SORONKÉNT tárolja a régi producer_id-t, mert
  a fordított leképezés azokat a sorokat is átírná, amelyek eredetileg is a
  megtartott rekordra mutattak. --rollback ebből állít vissza.
- Tartós nyom a jelentésfájltól függetlenül: a beolvasztott rekord archive +
  canSee=0 lesz, és a note-jába kerül, hova olvadt be.
- A két nagy táblán nincs index a producer_id-n, ezért táblánként EGY UPDATE
  fut CASE leképezéssel - 76 külön WHERE 76 teljes scant jelentene.

Mért eredmény a d2d másolaton: 27 mp alatt 314 termék + 27 272 archív + 115
árlista-sor átírva; a vizsgált árlistán a "Módosult" sorok 126 -> 23, a
gyártó-diffek 114 -> 2. Visszagörgetés után minden szám visszaállt, és az
újraszámolt diff ismét 126 - az ok-okozat mindkét irányban igazolt.

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

328 lines
12 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.
*
* @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();
$byId = [];
foreach ($groups as $normalized => $members) {
foreach ($members as $member) {
$byId[$member['producer']->id] = $normalized;
}
}
$rowsByGroup = [];
$finalNames = [];
$errors = [];
foreach ($decisions as $row) {
$id = (int) ($row['id'] ?? 0);
if (! isset($byId[$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;
}
$normalized = $byId[$id];
$rowsByGroup[$normalized][] = $id;
$name = trim((string) ($row['final_name'] ?? ''));
if ($name !== '') {
$finalNames[$normalized] ??= $name;
}
}
$merges = [];
foreach ($rowsByGroup as $normalized => $ids) {
$members = $groups[$normalized];
$memberIds = array_map(fn ($m) => $m['producer']->id, $members);
if (! isset($finalNames[$normalized])) {
$errors[] = 'Hiányzó végleges név ehhez a csoporthoz: '
. implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members));
continue;
}
$missing = array_diff($memberIds, $ids);
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.';
continue;
}
$keeper = $members[0]['producer'];
$losers = array_slice($members, 1);
$merges[] = [
'normalized' => $normalized,
'keeper_id' => $keeper->id,
'keeper_name' => $keeper->name,
'final_name' => $finalNames[$normalized],
'rename' => trim((string) $keeper->name) !== $finalNames[$normalized],
'from' => array_map(fn ($m) => [
'id' => $m['producer']->id,
'name' => $m['producer']->name,
'counts' => $m['counts'],
], $losers),
];
}
return ['merges' => $merges, 'errors' => $errors];
}
/**
* 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;
}
}