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

266 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Services\ProducerDeduplicator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
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}
{--loose : a bizonytalan jelöltek is kerüljenek a lapra (elírás, kötőjel, egybeírás, ékezet)}';
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ő)';
private const TYPE_CERTAIN = 'biztos';
private const TYPE_REVIEW = 'ellenőrizendő';
public function handle(ProducerDeduplicator $deduplicator): int
{
$certain = $deduplicator->duplicateGroups();
$loose = $this->option('loose') ? $deduplicator->looseCandidateGroups() : [];
if ($certain === [] && $loose === []) {
$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, $loose !== []);
$headerRow = 9;
$this->writeHeader($sheet, $headerRow);
$lastRow = $this->writeGroups($sheet, $headerRow, $certain, $loose);
$this->finishLayout($sheet, $headerRow, $lastRow, $certain, $loose);
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0775, true);
}
(new Xlsx($spreadsheet))->save($path);
$this->info('Döntési lap elkészült: ' . $path);
$this->line(' Biztos duplikáció: ' . count($certain) . ' csoport, '
. array_sum(array_map('count', $certain)) . ' rekord');
if ($this->option('loose')) {
$this->line(' Ellenőrizendő javaslat: ' . count($loose) . ' csoport, '
. array_sum(array_map('count', $loose)) . ' rekord');
} else {
$this->comment(' A bizonytalan jelöltekhez (elírás, kötőjel, egybeírás, ékezet): --loose');
}
return self::SUCCESS;
}
private function writeIntro($sheet, bool $hasLoose): 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, "Békás Kft." és "Békás Kft", vagy '
. '"Gast Food" és "Gast-Food". 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:J5');
$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. 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:J6');
$sheet->getStyle('A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getStyle('A6')->getFont()->setBold(true);
$sheet->getRowDimension(6)->setRowHeight(46);
if ($hasLoose) {
$sheet->setCellValue('A7', 'A "Típus" oszlop kétféle sort különböztet meg. A "' . self::TYPE_CERTAIN
. '" csoportoknál a nevek csak írásmódban térnek el, ott már beírtuk a javaslatunkat. '
. 'Az "' . self::TYPE_REVIEW . '" csoportoknál a nevek jobban különböznek (elírás, kötőjel, '
. 'egybeírás, hiányzó ékezet), ezért NEM töltöttük ki a végleges nevet: '
. 'ha ugyanaz a cég, írd be a nevet (a javaslat a "Megjegyzés" oszlopban van); '
. 'HA NEM UGYANAZ, HAGYD ÜRESEN akkor nem nyúlunk hozzájuk.');
$sheet->mergeCells('A7:J7');
$sheet->getStyle('A7')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getStyle('A7')->getFont()->setBold(true)->getColor()->setARGB('FF9C5700');
$sheet->getRowDimension(7)->setRowHeight(62);
}
}
private function writeHeader($sheet, int $headerRow): void
{
$headers = [self::COLUMN_GROUP, self::COLUMN_ID, 'Típus', '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 . ':J' . $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 $certain, array $loose): int
{
$row = $headerRow + 1;
$groupIndex = 0;
foreach ([self::TYPE_CERTAIN => $certain, self::TYPE_REVIEW => $loose] as $type => $groups) {
foreach ($groups as $members) {
$groupIndex++;
$row = $this->writeGroup($sheet, $row, $groupIndex, $type, $members);
}
}
return $row - 1;
}
private function writeGroup($sheet, int $row, int $groupIndex, string $type, array $members): int
{
$isReview = $type === self::TYPE_REVIEW;
$proposed = trim((string) $members[0]['producer']->name);
$shade = $isReview ? 'FFFDF2E9' : ($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->setCellValue('C' . $row, $type);
$sheet->setCellValueExplicit('D' . $row, '"' . $name . '"', DataType::TYPE_STRING);
$sheet->setCellValue('E' . $row, $index === 0
? ($isReview ? 'legtöbb termék' : 'javasolt megtartani')
: implode(', ', $this->describeDifference($name, $proposed)));
$sheet->setCellValue('F' . $row, $member['counts']['products']);
$sheet->setCellValue('G' . $row, $member['counts']['order_archives_items']);
$sheet->setCellValue('H' . $row, substr((string) $producer->created_at, 0, 10));
$sheet->getStyle('A' . $row . ':J' . $row)->getFill()
->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB($shade);
if ($index === 0) {
$sheet->getStyle('D' . $row)->getFont()->setBold(true);
if ($isReview) {
// Bizonytalan javaslatnál a végleges név ÜRESEN marad: az alapértelmezett
// viselkedés a "nem nyúlunk hozzá", a döntés pedig tudatos kitöltés.
$sheet->setCellValue('J' . $row, 'ha ugyanaz a cég: ' . $proposed);
$sheet->getStyle('J' . $row)->getFont()->setItalic(true);
} else {
$sheet->setCellValueExplicit('I' . $row, $proposed, DataType::TYPE_STRING);
$sheet->getStyle('I' . $row)->getFont()->setBold(true);
}
$sheet->getStyle('I' . $row)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB($isReview ? 'FFFFE0B2' : 'FFFFF2CC');
}
$row++;
}
$sheet->getStyle('A' . $groupFirstRow . ':J' . ($row - 1))->getBorders()->getTop()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBFBFBF');
return $row;
}
/**
* @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, array $certain, array $loose): void
{
$first = $headerRow + 1;
$sheet->getStyle('F' . $first . ':G' . $lastRow)->getNumberFormat()->setFormatCode('# ##0');
$sheet->getStyle('A' . $first . ':C' . $lastRow)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('I' . $first . ':I' . $lastRow)->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBF8F00');
$widths = ['A' => 9, 'B' => 11, 'C' => 15, 'D' => 34, 'E' => 20,
'F' => 11, 'G' => 17, 'H' => 13, 'I' => 34, 'J' => 34];
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A' . $first);
$sheet->setAutoFilter('A' . $headerRow . ':J' . $lastRow);
$summaryRow = $lastRow + 2;
$summary = 'Biztos duplikáció: ' . count($certain) . ' csoport, '
. array_sum(array_map('count', $certain)) . ' rekord.';
if ($loose !== []) {
$summary .= ' Ellenőrizendő javaslat: ' . count($loose) . ' csoport, '
. array_sum(array_map('count', $loose)) . ' rekord ezeknél az üresen hagyott '
. 'végleges név azt jelenti, hogy nem vonjuk össze őket.';
}
$sheet->setCellValue('A' . $summaryRow, $summary);
$sheet->mergeCells('A' . $summaryRow . ':J' . $summaryRow);
$sheet->getStyle('A' . $summaryRow)->getFont()->setItalic(true);
}
}