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>
328 lines
12 KiB
PHP
328 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|