d2d.emegrendeles.hu/app/Console/Commands/ProducersDedupe.php
E98Developer 4198c03f5a ADD EV3-357 laza duplikáció-jelöltek a döntési lapon (--loose)
A takarítás első köre után is maradtak nyilvánvaló duplikátumok: "Békás Kft." /
"Békás Kft", "Gast Food" / "Gast-Food", "Alföldi Tej" / "Alfölditej". A
NameNormalizer csak a kis/nagybetűt, a / és _ szeparátort és a többszörös
szóközt kezeli - az írásjelekhez, a kötőjelhez, az egybeíráshoz és az
ékezethez nem nyúl.

A szabályt SZÁNDÉKOSAN nem lazítottuk, mert két, ellentétes igényű feladatot
szolgál ki: az import-párosításnál egy téves egyezés csendben rossz gyártóhoz
rendelne termékeket, a takarítási javaslatnál viszont a megrendelő nemet mond
rá. Ezért a kettő szétválik.

- ProducerDeduplicator::looseCandidateGroups(): megengedőbb kulcs (írásjel,
  ékezet, szóköz elhagyása) KIZÁRÓLAG javaslatokhoz. A különböző cégformát
  (Kft vs Zrt) itt sem vonja össze - az valódi különbség, nem elírás -, a
  cégforma nélküli név viszont párba állhat, ha csak egyféle forma van.
- producers:dedupe-report --loose: a javaslatok "ellenőrizendő" típussal, ÜRES
  végleges névvel kerülnek a lapra, a javaslat a Megjegyzés oszlopban. Így az
  alapértelmezett viselkedés a "nem nyúlunk hozzá", a döntés tudatos kitöltés.
- buildPlan: az üres végleges név mostantól KIHAGYÁS, nem hiba - ez a javaslat
  elutasításának módja. A kihagyott csoportokat a parancs tételesen kiírja.
- buildPlan bármely aktív gyártót elfogad a lapon, nem csak a szigorú
  duplikátumok tagjait.

A d2d másolaton az első kör után: 0 biztos duplikáció, 101 ellenőrizendő
javaslat 241 rekorddal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:56:06 +02:00

264 lines
9.2 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;
}
$plan = $deduplicator->buildPlan($decisions);
$merges = $plan['merges'];
$errors = $plan['errors'];
foreach ($errors as $error) {
$this->warn(' ! ' . $error);
}
// Az üres végleges név a bizonytalan javaslatok elutasításának módja - nem hiba,
// de kiírjuk, hogy egy véletlen kihagyás se maradjon észrevétlen.
foreach ($plan['skipped'] ?? [] as $skip) {
$this->line(' - kimarad a(z) ' . $skip['group'] . '. csoport (nincs végleges név): '
. implode(' / ', array_map(fn ($n) => '"' . $n . '"', $skip['names'])));
}
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;
}
}