d2d.emegrendeles.hu/app/Console/Commands/ProducersDedupe.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

255 lines
8.7 KiB
PHP

<?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;
}
}