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>
This commit is contained in:
E98Developer 2026-08-27 08:09:54 +02:00
parent c0888db4a2
commit 2ced04c086
4 changed files with 1014 additions and 0 deletions

View File

@ -0,0 +1,254 @@
<?php
namespace App\Console\Commands;
use App\Services\ProducerDeduplicator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ProducersDedupe extends Command
{
protected $signature = 'producers:dedupe
{file? : a kitöltött döntési lap (producers:dedupe-report kimenete)}
{--apply : ténylegesen végrehajtja az összevonást (alapértelmezés: csak kimutatás)}
{--report= : hova írja a jelentést (ez a visszagörgetés bemenete)}
{--rollback= : egy korábbi jelentés visszagörgetése}
{--force : megerősítő kérdések nélkül fut (szkriptelt futtatáshoz)}';
protected $description = 'Duplikált gyártó rekordok összevonása a kitöltött döntési lap alapján';
public function handle(ProducerDeduplicator $deduplicator): int
{
if ($rollbackPath = $this->option('rollback')) {
return $this->rollback($deduplicator, $rollbackPath);
}
$file = $this->argument('file');
if (! $file || ! is_file($file)) {
$this->error('Add meg a kitöltött döntési lapot. Ha még nincs: php artisan producers:dedupe-report');
return self::FAILURE;
}
$decisions = $this->readDecisions($file);
if ($decisions === []) {
$this->error('A lapon nem találtam feldolgozható sort. Megvan az "'
. ProducersDedupeReport::COLUMN_ID . '" oszlop a fejlécben?');
return self::FAILURE;
}
['merges' => $merges, 'errors' => $errors] = $deduplicator->buildPlan($decisions);
foreach ($errors as $error) {
$this->warn(' ! ' . $error);
}
if ($merges === []) {
$this->error('Nincs végrehajtható összevonás.');
return self::FAILURE;
}
$this->summarize($merges);
if (! $this->option('apply')) {
$this->newLine();
$this->info('Ez csak kimutatás volt. A végrehajtáshoz: --apply');
return self::SUCCESS;
}
if ($errors !== [] && ! $this->confirmed('Voltak figyelmeztetések. Biztosan folytatod?')) {
return self::FAILURE;
}
$database = DB::connection()->getDatabaseName();
if (! $this->confirmed("Végrehajtás a(z) '{$database}' adatbázison. Folytatod?")) {
return self::FAILURE;
}
$report = $deduplicator->apply($merges);
$reportPath = $this->writeReport($report);
$this->newLine();
$this->info('Az összevonás megtörtént.');
foreach ($report['totals'] as $table => $count) {
$this->line(' ' . str_pad($table, 24) . number_format($count) . ' sor átírva');
}
$this->line(' Jelentés: ' . $reportPath);
$this->newLine();
$this->comment('Visszagörgetés: php artisan producers:dedupe --rollback="' . $reportPath . '"');
return self::SUCCESS;
}
/**
* Megerősítés, --force esetén kérdés nélkül.
*/
private function confirmed(string $question): bool
{
return $this->option('force') || $this->confirm($question, false);
}
/**
* A lap fejlécnév alapján olvasódik, nem oszlopbetű szerint: a megrendelő
* beszúrhat oszlopot vagy átrendezheti a lapot anélkül, hogy ez eltörne.
*
* @return array<int, array{group: mixed, id: int, final_name: ?string}>
*/
private function readDecisions(string $file): array
{
$sheet = IOFactory::load($file)->getActiveSheet();
$highestRow = $sheet->getHighestDataRow();
$highestColumn = $sheet->getHighestDataColumn();
$columnCount = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$headerRow = null;
$columns = [];
for ($row = 1; $row <= min($highestRow, 30); $row++) {
for ($column = 1; $column <= $columnCount; $column++) {
$value = trim((string) $sheet->getCellByColumnAndRow($column, $row)->getValue());
if ($value === ProducersDedupeReport::COLUMN_ID) {
$headerRow = $row;
}
}
if ($headerRow !== null) {
for ($column = 1; $column <= $columnCount; $column++) {
$value = trim((string) $sheet->getCellByColumnAndRow($column, $headerRow)->getValue());
if ($value !== '') {
$columns[$value] = $column;
}
}
break;
}
}
if ($headerRow === null) {
return [];
}
$idColumn = $columns[ProducersDedupeReport::COLUMN_ID] ?? null;
$groupColumn = $columns[ProducersDedupeReport::COLUMN_GROUP] ?? null;
$nameColumn = $columns[ProducersDedupeReport::COLUMN_FINAL_NAME] ?? null;
if (! $idColumn || ! $nameColumn) {
return [];
}
$decisions = [];
for ($row = $headerRow + 1; $row <= $highestRow; $row++) {
$id = (int) $sheet->getCellByColumnAndRow($idColumn, $row)->getValue();
if ($id <= 0) {
continue;
}
$decisions[] = [
'group' => $groupColumn ? $sheet->getCellByColumnAndRow($groupColumn, $row)->getValue() : null,
'id' => $id,
'final_name' => trim((string) $sheet->getCellByColumnAndRow($nameColumn, $row)->getValue()),
];
}
return $decisions;
}
private function summarize(array $merges): void
{
$rows = [];
$totals = ['products' => 0, 'order_archives_items' => 0, 'pricelist_file_lines' => 0];
foreach ($merges as $merge) {
foreach ($merge['from'] as $from) {
foreach ($totals as $key => $value) {
$totals[$key] += $from['counts'][$key];
}
$rows[] = [
'"' . $from['name'] . '"',
'→ #' . $merge['keeper_id'] . ' "' . $merge['final_name'] . '"'
. ($merge['rename'] ? ' (átnevezés)' : ''),
number_format($from['counts']['products']),
number_format($from['counts']['order_archives_items']),
];
}
}
$this->table(['Beolvasztandó', 'Célrekord', 'Termék', 'Rendelési tétel'], $rows);
$this->line('Csoport: ' . count($merges)
. ' | beolvasztandó rekord: ' . array_sum(array_map(fn ($m) => count($m['from']), $merges)));
$this->line('Átírandó sorok: products ' . number_format($totals['products'])
. ' · order_archives_items ' . number_format($totals['order_archives_items'])
. ' · pricelist_file_lines ' . number_format($totals['pricelist_file_lines']));
}
private function writeReport(array $report): string
{
$path = $this->option('report') ?: storage_path(
'app/private/producers-dedupe-' . now()->format('Y-m-d_His') . '.json'
);
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $path;
}
private function rollback(ProducerDeduplicator $deduplicator, string $path): int
{
if (! is_file($path)) {
$this->error('A jelentésfájl nem található: ' . $path);
return self::FAILURE;
}
$report = json_decode((string) file_get_contents($path), true);
if (! is_array($report) || ! isset($report['restore'])) {
$this->error('A fájl nem érvényes producers:dedupe jelentés.');
return self::FAILURE;
}
$database = DB::connection()->getDatabaseName();
if (($report['database'] ?? null) !== $database) {
$this->warn('A jelentés a(z) "' . ($report['database'] ?? '?')
. '" adatbázisról készült, most viszont "' . $database . '" az aktív.');
if (! $this->confirmed('Biztosan folytatod?')) {
return self::FAILURE;
}
}
$this->line('Visszagörgetés: ' . count($report['merges'] ?? []) . ' összevonás ('
. ($report['created_at'] ?? '?') . ')');
if (! $this->confirmed("Visszagörgetés a(z) '{$database}' adatbázison. Folytatod?")) {
return self::FAILURE;
}
$restored = $deduplicator->rollback($report);
$this->info('Visszagörgetve.');
foreach ($restored as $table => $count) {
$this->line(' ' . str_pad($table, 24) . number_format($count) . ' sor visszaállítva');
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace App\Console\Commands;
use App\Services\ProducerDeduplicator;
use Illuminate\Console\Command;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class ProducersDedupeReport extends Command
{
protected $signature = 'producers:dedupe-report {--path= : a kimeneti xlsx útvonala}';
protected $description = 'Döntési lapot készít a duplikált gyártónevekről (a producers:dedupe bemenete)';
/**
* A lap oszlopai. A producers:dedupe FEJLÉCNÉV alapján olvassa vissza, nem
* oszlopbetű szerint - így a megrendelő beszúrhat oszlopot vagy átrendezheti a
* lapot anélkül, hogy a beolvasás eltörne.
*/
public const COLUMN_GROUP = 'Csoport';
public const COLUMN_ID = 'Azonosító';
public const COLUMN_FINAL_NAME = 'VÉGLEGES NÉV (kitöltendő)';
public function handle(ProducerDeduplicator $deduplicator): int
{
$groups = $deduplicator->duplicateGroups();
if ($groups === []) {
$this->info('Nincs duplikált gyártónév ebben az adatbázisban.');
return self::SUCCESS;
}
$path = $this->option('path') ?: storage_path(
'app/private/gyarto_duplikaciok_' . now()->format('Y-m-d') . '.xlsx'
);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Gyártó duplikációk');
$this->writeIntro($sheet);
$headerRow = 8;
$this->writeHeader($sheet, $headerRow);
$lastRow = $this->writeGroups($sheet, $headerRow, $groups);
$this->finishLayout($sheet, $headerRow, $lastRow, count($groups), $groups);
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0775, true);
}
(new Xlsx($spreadsheet))->save($path);
$records = array_sum(array_map('count', $groups));
$this->info('Döntési lap elkészült: ' . $path);
$this->line(' Csoportok: ' . count($groups) . ' | érintett gyártó rekord: ' . $records);
return self::SUCCESS;
}
private function writeIntro($sheet): void
{
$sheet->setCellValue('A1', 'Gyártó nevek összevonása döntési lap');
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(16);
$sheet->setCellValue('A2', 'EV3-357 · Árlista feltöltés és feldolgozás újragondolása');
$sheet->setCellValue('A3', 'Adatforrás: ' . \DB::connection()->getDatabaseName()
. ' adatbázis · Készült: ' . now()->format('Y-m-d H:i'));
$sheet->setCellValue('A5', 'A rendszerben ugyanaz a gyártó több néven is szerepel például '
. '"Danone" és "Danone " egy záró szóközzel, vagy "Pick" és "PICK". Ez két gondot okoz: az '
. 'árlista feldolgozó valódi változás nélkül is módosulást jelez, a statisztikában pedig egy '
. 'gyártóra szűrve a másik név alá könyvelt tételek kimaradnak a riportból.');
$sheet->mergeCells('A5:I5');
$sheet->getStyle('A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getRowDimension(5)->setRowHeight(46);
$sheet->setCellValue('A6', 'TEENDŐ: minden csoportnál töltsd ki a sárga "VÉGLEGES NÉV" mezőt ez lesz '
. 'a megmaradó, egységes név. Előre beírtuk a javaslatunkat: a legtöbb terméket tartalmazó '
. 'változat, szóközöktől megtisztítva. Ha egyetértesz vele, hagyd úgy. A kis- és nagybetűs '
. 'írásmód a te döntésed. A többi név nem vész el: összevonjuk őket a véglegesbe, a termékek '
. 'és a rendelési előzmények megmaradnak. Az "Azonosító" oszlopot kérjük ne módosítsd.');
$sheet->mergeCells('A6:I6');
$sheet->getStyle('A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getStyle('A6')->getFont()->setBold(true);
$sheet->getRowDimension(6)->setRowHeight(64);
}
private function writeHeader($sheet, int $headerRow): void
{
$headers = [self::COLUMN_GROUP, self::COLUMN_ID, 'Gyártó neve a rendszerben', 'Eltérés',
'Termékek', 'Rendelési tételek', 'Létrehozva', self::COLUMN_FINAL_NAME, 'Megjegyzés'];
foreach ($headers as $i => $text) {
$sheet->setCellValueByColumnAndRow($i + 1, $headerRow, $text);
}
$range = 'A' . $headerRow . ':I' . $headerRow;
$sheet->getStyle($range)->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle($range)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF44546A');
$sheet->getStyle($range)->getAlignment()->setWrapText(true)
->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getRowDimension($headerRow)->setRowHeight(32);
}
private function writeGroups($sheet, int $headerRow, array $groups): int
{
$row = $headerRow + 1;
$groupIndex = 0;
foreach ($groups as $members) {
$groupIndex++;
$proposed = trim((string) $members[0]['producer']->name);
$shade = $groupIndex % 2 === 0 ? 'FFF2F2F2' : 'FFFFFFFF';
$groupFirstRow = $row;
foreach ($members as $index => $member) {
$producer = $member['producer'];
$name = (string) $producer->name;
// A csoport sorszáma MINDEN soron szerepel: így a lap szűrhető és
// rendezhető anélkül, hogy a csoportok összekeverednének.
$sheet->setCellValue('A' . $row, $groupIndex);
$sheet->setCellValue('B' . $row, $producer->id);
$sheet->setCellValueExplicit('C' . $row, '"' . $name . '"', DataType::TYPE_STRING);
$sheet->setCellValue('D' . $row, $index === 0
? 'javasolt megtartani'
: implode(', ', $this->describeDifference($name, $proposed)));
$sheet->setCellValue('E' . $row, $member['counts']['products']);
$sheet->setCellValue('F' . $row, $member['counts']['order_archives_items']);
$sheet->setCellValue('G' . $row, substr((string) $producer->created_at, 0, 10));
$sheet->getStyle('A' . $row . ':I' . $row)->getFill()
->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB($shade);
if ($index === 0) {
$sheet->setCellValueExplicit('H' . $row, $proposed, DataType::TYPE_STRING);
$sheet->getStyle('H' . $row)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFFFF2CC');
$sheet->getStyle('H' . $row)->getFont()->setBold(true);
$sheet->getStyle('C' . $row)->getFont()->setBold(true);
}
$row++;
}
$sheet->getStyle('A' . $groupFirstRow . ':I' . ($row - 1))->getBorders()->getTop()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBFBFBF');
}
return $row - 1;
}
/**
* @return array<int, string>
*/
private function describeDifference(string $name, string $proposed): array
{
$differences = [];
if ($name !== trim($name)) {
$differences[] = 'szóköz a név szélén';
}
if (trim($name) !== $proposed && mb_strtoupper(trim($name)) === mb_strtoupper($proposed)) {
$differences[] = 'eltérő kis/nagybetű';
}
if (str_contains($name, '/') || str_contains($name, '_')) {
$differences[] = 'eltérő elválasztó';
}
if (preg_match('/\s{2,}/', $name)) {
$differences[] = 'dupla szóköz';
}
return $differences === [] ? ['eltérő írásmód'] : $differences;
}
private function finishLayout($sheet, int $headerRow, int $lastRow, int $groupCount, array $groups): void
{
$first = $headerRow + 1;
$sheet->getStyle('E' . $first . ':F' . $lastRow)->getNumberFormat()->setFormatCode('# ##0');
$sheet->getStyle('A' . $first . ':B' . $lastRow)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('H' . $first . ':H' . $lastRow)->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBF8F00');
$widths = ['A' => 9, 'B' => 11, 'C' => 34, 'D' => 22, 'E' => 11,
'F' => 17, 'G' => 13, 'H' => 34, 'I' => 26];
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A' . $first);
$sheet->setAutoFilter('A' . $headerRow . ':I' . $lastRow);
$summaryRow = $lastRow + 2;
$sheet->setCellValue('A' . $summaryRow, 'Összesen ' . $groupCount . ' gyártó-csoport, '
. array_sum(array_map('count', $groups)) . ' rekord. A csoportok többségénél mindkét névhez '
. 'tartoznak termékek, ezért az összevonás után is minden termék megmarad.');
$sheet->mergeCells('A' . $summaryRow . ':I' . $summaryRow);
$sheet->getStyle('A' . $summaryRow)->getFont()->setItalic(true);
}
}

View File

@ -0,0 +1,327 @@
<?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;
}
}

View File

@ -0,0 +1,222 @@
<?php
use App\Console\Commands\ProducersDedupeReport;
use App\Enums\DbStatusFieldEnum;
use App\Models\Producer;
use App\Models\Product;
use App\Services\ProducerDeduplicator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function producer(string $name): Producer
{
return Producer::create(['name' => $name, 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
}
function productFor(Producer $producer, string $sku): Product
{
return Product::create([
'name' => 'Termék ' . $sku,
'supplierProductNumber' => $sku,
'producer_id' => $producer->id,
'unitValue' => 1,
'note' => '',
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
}
/**
* @param array<int, array{id: int, final_name: ?string}> $rows
*/
function decisionsFor(array $rows): array
{
return array_map(fn ($r) => ['group' => 1, 'id' => $r['id'], 'final_name' => $r['final_name'] ?? null], $rows);
}
beforeEach(function () {
$this->deduplicator = app(ProducerDeduplicator::class);
});
test('a duplikált csoportban a legtöbb termékkel rendelkező rekord a javasolt megtartandó', function () {
$few = producer('Danone ');
$many = producer('Danone');
productFor($many, 'A1');
productFor($many, 'A2');
productFor($few, 'B1');
$groups = $this->deduplicator->duplicateGroups();
expect($groups)->toHaveKey('DANONE')
->and($groups['DANONE'][0]['producer']->id)->toBe($many->id)
->and($groups['DANONE'][0]['counts']['products'])->toBe(2)
->and($groups['DANONE'][1]['producer']->id)->toBe($few->id);
});
test('az egyedi nevű gyártó nem kerül a duplikátumok közé', function () {
producer('Danone');
producer('Bonduelle');
expect($this->deduplicator->duplicateGroups())->toBe([]);
});
test('hiányzó végleges név esetén hibát jelez, és nem tervez összevonást', function () {
$a = producer('Danone');
$b = producer('Danone ');
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $a->id, 'final_name' => null],
['id' => $b->id, 'final_name' => null],
]));
expect($plan['merges'])->toBe([])
->and($plan['errors'][0])->toContain('Hiányzó végleges név');
});
test('hiányos csoport esetén hibát jelez', function () {
$a = producer('Danone');
producer('Danone ');
// A lapról lemaradt a csoport másik tagja - ilyenkor nem szabad összevonni,
// mert a döntés nem a teljes csoportra vonatkozott.
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $a->id, 'final_name' => 'Danone'],
]));
expect($plan['merges'])->toBe([])
->and($plan['errors'][0])->toContain('nem tartalmazza a csoport minden tagját');
});
test('ismeretlen azonosítót jelez, ha a lap más adatállapotból származik', function () {
$plan = $this->deduplicator->buildPlan(decisionsFor([['id' => 999999, 'final_name' => 'Bármi']]));
expect($plan['errors'][0])->toContain('nem szerepel duplikált csoportban');
});
test('az összevonás átírja a hivatkozásokat és archiválja a beolvasztott rekordot', function () {
$keeper = producer('Danone');
$loser = producer('Danone ');
productFor($keeper, 'A1');
$moved = productFor($loser, 'B1');
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $keeper->id, 'final_name' => 'Danone'],
['id' => $loser->id, 'final_name' => null],
]));
expect($plan['errors'])->toBe([]);
$report = $this->deduplicator->apply($plan['merges']);
expect($moved->refresh()->producer_id)->toBe($keeper->id)
->and($report['totals']['products'])->toBe(1);
$loser->refresh();
expect($loser->status)->toBe(DbStatusFieldEnum::archive)
->and((bool) $loser->canSee)->toBeFalse()
// Tartós nyom az adatbázisban, a jelentésfájltól függetlenül
->and($loser->note)->toContain('Összevonva ide: #' . $keeper->id);
});
test('a végleges név átnevezi a megtartott rekordot', function () {
$keeper = producer('danone');
$loser = producer('Danone ');
productFor($keeper, 'A1');
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $keeper->id, 'final_name' => 'Danone Magyarország'],
['id' => $loser->id, 'final_name' => null],
]));
expect($plan['merges'][0]['rename'])->toBeTrue();
$this->deduplicator->apply($plan['merges']);
expect($keeper->refresh()->name)->toBe('Danone Magyarország');
});
test('a visszagörgetés minden érintett sort a saját eredeti értékére állít vissza', function () {
$keeper = producer('Danone');
$loser = producer('Danone ');
$stayed = productFor($keeper, 'A1'); // eredetileg is a megtartotton volt
$moved = productFor($loser, 'B1'); // ezt mozgatjuk
$plan = $this->deduplicator->buildPlan(decisionsFor([
['id' => $keeper->id, 'final_name' => 'Danone'],
['id' => $loser->id, 'final_name' => null],
]));
$report = $this->deduplicator->apply($plan['merges']);
expect($moved->refresh()->producer_id)->toBe($keeper->id);
$this->deduplicator->rollback($report);
// A lényeg: a mozgatott sor visszakerül, a helyben maradt NEM mozdul el.
// Egy egyszerű fordított leképezés mindkettőt átírná - ezért tárolunk soronként.
expect($moved->refresh()->producer_id)->toBe($loser->id)
->and($stayed->refresh()->producer_id)->toBe($keeper->id);
$loser->refresh();
expect($loser->name)->toBe('Danone ')
->and($loser->status)->toBe(DbStatusFieldEnum::active)
->and((bool) $loser->canSee)->toBeTrue()
->and($loser->note)->toBeNull();
});
test('a döntési lap legenerálható és visszaolvasható', function () {
$keeper = producer('Danone');
$loser = producer('Danone ');
productFor($keeper, 'A1');
$path = storage_path('app/private/teszt_dontesi_lap.xlsx');
@unlink($path);
$this->artisan('producers:dedupe-report', ['--path' => $path])->assertExitCode(0);
expect(file_exists($path))->toBeTrue();
$sheet = IOFactory::load($path)->getActiveSheet();
$found = [];
for ($row = 1; $row <= $sheet->getHighestDataRow(); $row++) {
for ($col = 1; $col <= 9; $col++) {
$found[] = (string) $sheet->getCellByColumnAndRow($col, $row)->getValue();
}
}
// A fejlécnevek adják a gépi visszaolvasás horgonyait
expect($found)->toContain(ProducersDedupeReport::COLUMN_ID)
->and($found)->toContain(ProducersDedupeReport::COLUMN_FINAL_NAME)
->and($found)->toContain((string) $keeper->id)
->and($found)->toContain((string) $loser->id)
->and($found)->toContain('"Danone "');
@unlink($path);
});
test('a dry-run nem módosít semmit', function () {
$keeper = producer('Danone');
$loser = producer('Danone ');
$moved = productFor($loser, 'B1');
productFor($keeper, 'A1');
$path = storage_path('app/private/teszt_dry_run.xlsx');
@unlink($path);
$this->artisan('producers:dedupe-report', ['--path' => $path])->assertExitCode(0);
$this->artisan('producers:dedupe', ['file' => $path])->assertExitCode(0);
expect($moved->refresh()->producer_id)->toBe($loser->id)
->and($loser->refresh()->status)->toBe(DbStatusFieldEnum::active)
->and(DB::table('producers')->where('status', 'archive')->count())->toBe(0);
@unlink($path);
});