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>
264 lines
9.2 KiB
PHP
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;
|
|
}
|
|
}
|