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>
266 lines
12 KiB
PHP
266 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|