diff --git a/app/Console/Commands/ProducersDedupe.php b/app/Console/Commands/ProducersDedupe.php new file mode 100644 index 0000000..225bcf5 --- /dev/null +++ b/app/Console/Commands/ProducersDedupe.php @@ -0,0 +1,254 @@ +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 + */ + 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; + } +} diff --git a/app/Console/Commands/ProducersDedupeReport.php b/app/Console/Commands/ProducersDedupeReport.php new file mode 100644 index 0000000..7d203d7 --- /dev/null +++ b/app/Console/Commands/ProducersDedupeReport.php @@ -0,0 +1,211 @@ +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 + */ + 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); + } +} diff --git a/app/Services/ProducerDeduplicator.php b/app/Services/ProducerDeduplicator.php new file mode 100644 index 0000000..0b242cc --- /dev/null +++ b/app/Services/ProducerDeduplicator.php @@ -0,0 +1,327 @@ + 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}>> + */ + 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> 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 $decisions + * @return array{merges: array, errors: array} + */ + 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; + } +} diff --git a/tests/Feature/ProducerDedupeTest.php b/tests/Feature/ProducerDedupeTest.php new file mode 100644 index 0000000..14c7621 --- /dev/null +++ b/tests/Feature/ProducerDedupeTest.php @@ -0,0 +1,222 @@ + $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 $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); +});