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:
parent
c0888db4a2
commit
2ced04c086
254
app/Console/Commands/ProducersDedupe.php
Normal file
254
app/Console/Commands/ProducersDedupe.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/Console/Commands/ProducersDedupeReport.php
Normal file
211
app/Console/Commands/ProducersDedupeReport.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
327
app/Services/ProducerDeduplicator.php
Normal file
327
app/Services/ProducerDeduplicator.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
tests/Feature/ProducerDedupeTest.php
Normal file
222
tests/Feature/ProducerDedupeTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user