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>
This commit is contained in:
E98Developer 2026-08-28 06:56:06 +02:00
parent 7e32a870ae
commit 4198c03f5a
4 changed files with 356 additions and 109 deletions

View File

@ -41,12 +41,21 @@ public function handle(ProducerDeduplicator $deduplicator): int
return self::FAILURE; return self::FAILURE;
} }
['merges' => $merges, 'errors' => $errors] = $deduplicator->buildPlan($decisions); $plan = $deduplicator->buildPlan($decisions);
$merges = $plan['merges'];
$errors = $plan['errors'];
foreach ($errors as $error) { foreach ($errors as $error) {
$this->warn(' ! ' . $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 === []) { if ($merges === []) {
$this->error('Nincs végrehajtható összevonás.'); $this->error('Nincs végrehajtható összevonás.');

View File

@ -4,6 +4,7 @@
use App\Services\ProducerDeduplicator; use App\Services\ProducerDeduplicator;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Alignment;
@ -13,7 +14,9 @@
class ProducersDedupeReport extends Command class ProducersDedupeReport extends Command
{ {
protected $signature = 'producers:dedupe-report {--path= : a kimeneti xlsx útvonala}'; 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)'; protected $description = 'Döntési lapot készít a duplikált gyártónevekről (a producers:dedupe bemenete)';
@ -28,11 +31,16 @@ class ProducersDedupeReport extends Command
public const COLUMN_FINAL_NAME = 'VÉGLEGES NÉV (kitöltendő)'; 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 public function handle(ProducerDeduplicator $deduplicator): int
{ {
$groups = $deduplicator->duplicateGroups(); $certain = $deduplicator->duplicateGroups();
$loose = $this->option('loose') ? $deduplicator->looseCandidateGroups() : [];
if ($groups === []) { if ($certain === [] && $loose === []) {
$this->info('Nincs duplikált gyártónév ebben az adatbázisban.'); $this->info('Nincs duplikált gyártónév ebben az adatbázisban.');
return self::SUCCESS; return self::SUCCESS;
@ -46,11 +54,11 @@ public function handle(ProducerDeduplicator $deduplicator): int
$sheet = $spreadsheet->getActiveSheet(); $sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Gyártó duplikációk'); $sheet->setTitle('Gyártó duplikációk');
$this->writeIntro($sheet); $this->writeIntro($sheet, $loose !== []);
$headerRow = 8; $headerRow = 9;
$this->writeHeader($sheet, $headerRow); $this->writeHeader($sheet, $headerRow);
$lastRow = $this->writeGroups($sheet, $headerRow, $groups); $lastRow = $this->writeGroups($sheet, $headerRow, $certain, $loose);
$this->finishLayout($sheet, $headerRow, $lastRow, count($groups), $groups); $this->finishLayout($sheet, $headerRow, $lastRow, $certain, $loose);
$directory = dirname($path); $directory = dirname($path);
if (! is_dir($directory)) { if (! is_dir($directory)) {
@ -59,50 +67,69 @@ public function handle(ProducerDeduplicator $deduplicator): int
(new Xlsx($spreadsheet))->save($path); (new Xlsx($spreadsheet))->save($path);
$records = array_sum(array_map('count', $groups));
$this->info('Döntési lap elkészült: ' . $path); $this->info('Döntési lap elkészült: ' . $path);
$this->line(' Csoportok: ' . count($groups) . ' | érintett gyártó rekord: ' . $records); $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; return self::SUCCESS;
} }
private function writeIntro($sheet): void private function writeIntro($sheet, bool $hasLoose): void
{ {
$sheet->setCellValue('A1', 'Gyártó nevek összevonása döntési lap'); $sheet->setCellValue('A1', 'Gyártó nevek összevonása döntési lap');
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(16); $sheet->getStyle('A1')->getFont()->setBold(true)->setSize(16);
$sheet->setCellValue('A2', 'EV3-357 · Árlista feltöltés és feldolgozás újragondolása'); $sheet->setCellValue('A2', 'EV3-357 · Árlista feltöltés és feldolgozás újragondolása');
$sheet->setCellValue('A3', 'Adatforrás: ' . \DB::connection()->getDatabaseName() $sheet->setCellValue('A3', 'Adatforrás: ' . DB::connection()->getDatabaseName()
. ' adatbázis · Készült: ' . now()->format('Y-m-d H:i')); . ' 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 ' $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 ' . '"Danone" és "Danone " egy záró szóközzel, "Békás Kft." és "Békás Kft", vagy '
. 'árlista feldolgozó valódi változás nélkül is módosulást jelez, a statisztikában pedig egy ' . '"Gast Food" és "Gast-Food". Ez két gondot okoz: az árlista feldolgozó valódi változás '
. 'gyártóra szűrve a másik név alá könyvelt tételek kimaradnak a riportból.'); . 'nélkül is módosulást jelez, a statisztikában pedig egy gyártóra szűrve a másik név alá '
$sheet->mergeCells('A5:I5'); . 'könyvelt tételek kimaradnak a riportból.');
$sheet->mergeCells('A5:J5');
$sheet->getStyle('A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP); $sheet->getStyle('A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getRowDimension(5)->setRowHeight(46); $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 ' $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ó ' . 'a megmaradó, egységes név. A többi név nem vész el: összevonjuk őket a véglegesbe, a '
. 'változat, szóközöktől megtisztítva. Ha egyetértesz vele, hagyd úgy. A kis- és nagybetűs ' . 'termékek és a rendelési előzmények megmaradnak. Az "Azonosító" oszlopot kérjük ne módosítsd.');
. 'í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 ' $sheet->mergeCells('A6:J6');
. 'é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')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
$sheet->getStyle('A6')->getFont()->setBold(true); $sheet->getStyle('A6')->getFont()->setBold(true);
$sheet->getRowDimension(6)->setRowHeight(64); $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 private function writeHeader($sheet, int $headerRow): void
{ {
$headers = [self::COLUMN_GROUP, self::COLUMN_ID, 'Gyártó neve a rendszerben', 'Eltérés', $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']; 'Termékek', 'Rendelési tételek', 'Létrehozva', self::COLUMN_FINAL_NAME, 'Megjegyzés'];
foreach ($headers as $i => $text) { foreach ($headers as $i => $text) {
$sheet->setCellValueByColumnAndRow($i + 1, $headerRow, $text); $sheet->setCellValueByColumnAndRow($i + 1, $headerRow, $text);
} }
$range = 'A' . $headerRow . ':I' . $headerRow; $range = 'A' . $headerRow . ':J' . $headerRow;
$sheet->getStyle($range)->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); $sheet->getStyle($range)->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle($range)->getFill()->setFillType(Fill::FILL_SOLID) $sheet->getStyle($range)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF44546A'); ->getStartColor()->setARGB('FF44546A');
@ -111,15 +138,26 @@ private function writeHeader($sheet, int $headerRow): void
$sheet->getRowDimension($headerRow)->setRowHeight(32); $sheet->getRowDimension($headerRow)->setRowHeight(32);
} }
private function writeGroups($sheet, int $headerRow, array $groups): int private function writeGroups($sheet, int $headerRow, array $certain, array $loose): int
{ {
$row = $headerRow + 1; $row = $headerRow + 1;
$groupIndex = 0; $groupIndex = 0;
foreach ([self::TYPE_CERTAIN => $certain, self::TYPE_REVIEW => $loose] as $type => $groups) {
foreach ($groups as $members) { foreach ($groups as $members) {
$groupIndex++; $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); $proposed = trim((string) $members[0]['producer']->name);
$shade = $groupIndex % 2 === 0 ? 'FFF2F2F2' : 'FFFFFFFF'; $shade = $isReview ? 'FFFDF2E9' : ($groupIndex % 2 === 0 ? 'FFF2F2F2' : 'FFFFFFFF');
$groupFirstRow = $row; $groupFirstRow = $row;
foreach ($members as $index => $member) { foreach ($members as $index => $member) {
@ -130,33 +168,42 @@ private function writeGroups($sheet, int $headerRow, array $groups): int
// rendezhető anélkül, hogy a csoportok összekeverednének. // rendezhető anélkül, hogy a csoportok összekeverednének.
$sheet->setCellValue('A' . $row, $groupIndex); $sheet->setCellValue('A' . $row, $groupIndex);
$sheet->setCellValue('B' . $row, $producer->id); $sheet->setCellValue('B' . $row, $producer->id);
$sheet->setCellValueExplicit('C' . $row, '"' . $name . '"', DataType::TYPE_STRING); $sheet->setCellValue('C' . $row, $type);
$sheet->setCellValue('D' . $row, $index === 0 $sheet->setCellValueExplicit('D' . $row, '"' . $name . '"', DataType::TYPE_STRING);
? 'javasolt megtartani' $sheet->setCellValue('E' . $row, $index === 0
? ($isReview ? 'legtöbb termék' : 'javasolt megtartani')
: implode(', ', $this->describeDifference($name, $proposed))); : implode(', ', $this->describeDifference($name, $proposed)));
$sheet->setCellValue('E' . $row, $member['counts']['products']); $sheet->setCellValue('F' . $row, $member['counts']['products']);
$sheet->setCellValue('F' . $row, $member['counts']['order_archives_items']); $sheet->setCellValue('G' . $row, $member['counts']['order_archives_items']);
$sheet->setCellValue('G' . $row, substr((string) $producer->created_at, 0, 10)); $sheet->setCellValue('H' . $row, substr((string) $producer->created_at, 0, 10));
$sheet->getStyle('A' . $row . ':I' . $row)->getFill() $sheet->getStyle('A' . $row . ':J' . $row)->getFill()
->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB($shade); ->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB($shade);
if ($index === 0) { if ($index === 0) {
$sheet->setCellValueExplicit('H' . $row, $proposed, DataType::TYPE_STRING); $sheet->getStyle('D' . $row)->getFont()->setBold(true);
$sheet->getStyle('H' . $row)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFFFF2CC'); if ($isReview) {
$sheet->getStyle('H' . $row)->getFont()->setBold(true); // Bizonytalan javaslatnál a végleges név ÜRESEN marad: az alapértelmezett
$sheet->getStyle('C' . $row)->getFont()->setBold(true); // 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++; $row++;
} }
$sheet->getStyle('A' . $groupFirstRow . ':I' . ($row - 1))->getBorders()->getTop() $sheet->getStyle('A' . $groupFirstRow . ':J' . ($row - 1))->getBorders()->getTop()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBFBFBF'); ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBFBFBF');
}
return $row - 1; return $row;
} }
/** /**
@ -182,30 +229,37 @@ private function describeDifference(string $name, string $proposed): array
return $differences === [] ? ['eltérő írásmód'] : $differences; return $differences === [] ? ['eltérő írásmód'] : $differences;
} }
private function finishLayout($sheet, int $headerRow, int $lastRow, int $groupCount, array $groups): void private function finishLayout($sheet, int $headerRow, int $lastRow, array $certain, array $loose): void
{ {
$first = $headerRow + 1; $first = $headerRow + 1;
$sheet->getStyle('E' . $first . ':F' . $lastRow)->getNumberFormat()->setFormatCode('# ##0'); $sheet->getStyle('F' . $first . ':G' . $lastRow)->getNumberFormat()->setFormatCode('# ##0');
$sheet->getStyle('A' . $first . ':B' . $lastRow)->getAlignment() $sheet->getStyle('A' . $first . ':C' . $lastRow)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER); ->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('H' . $first . ':H' . $lastRow)->getBorders()->getAllBorders() $sheet->getStyle('I' . $first . ':I' . $lastRow)->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBF8F00'); ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBF8F00');
$widths = ['A' => 9, 'B' => 11, 'C' => 34, 'D' => 22, 'E' => 11, $widths = ['A' => 9, 'B' => 11, 'C' => 15, 'D' => 34, 'E' => 20,
'F' => 17, 'G' => 13, 'H' => 34, 'I' => 26]; 'F' => 11, 'G' => 17, 'H' => 13, 'I' => 34, 'J' => 34];
foreach ($widths as $column => $width) { foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width); $sheet->getColumnDimension($column)->setWidth($width);
} }
$sheet->freezePane('A' . $first); $sheet->freezePane('A' . $first);
$sheet->setAutoFilter('A' . $headerRow . ':I' . $lastRow); $sheet->setAutoFilter('A' . $headerRow . ':J' . $lastRow);
$summaryRow = $lastRow + 2; $summaryRow = $lastRow + 2;
$sheet->setCellValue('A' . $summaryRow, 'Összesen ' . $groupCount . ' gyártó-csoport, ' $summary = 'Biztos duplikáció: ' . count($certain) . ' csoport, '
. array_sum(array_map('count', $groups)) . ' rekord. A csoportok többségénél mindkét névhez ' . array_sum(array_map('count', $certain)) . ' rekord.';
. 'tartoznak termékek, ezért az összevonás után is minden termék megmarad.');
$sheet->mergeCells('A' . $summaryRow . ':I' . $summaryRow); 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); $sheet->getStyle('A' . $summaryRow)->getFont()->setItalic(true);
} }
} }

View File

@ -40,11 +40,9 @@ class ProducerDeduplicator
*/ */
public function duplicateGroups(): array public function duplicateGroups(): array
{ {
$counts = $this->referenceCounts();
$byNormalized = []; $byNormalized = [];
foreach (DB::table('producers')->orderBy('id')->get() as $producer) { foreach ($this->activeMembers() as $member) {
$byNormalized[NameNormalizer::normalize($producer->name)][] = $producer; $byNormalized[NameNormalizer::normalize($member['producer']->name)][] = $member;
} }
$groups = []; $groups = [];
@ -53,28 +51,148 @@ public function duplicateGroups(): array
continue; continue;
} }
$decorated = array_map(fn ($producer) => [ $groups[$normalized] = $this->sortByKeeperPriority($members);
}
ksort($groups);
return $groups;
}
/**
* Laza jelöltek: olyan gyártók, amelyeket a szigorú szabály NEM köt össze, de nagy
* eséllyel ugyanaz a cég - elírás, kötőjel, egybeírás, hiányzó ékezet miatt térnek el.
*
* Ezek SOSEM olvadnak össze maguktól: a riportba javaslatként kerülnek, a döntést a
* megrendelő hozza. Ezért lehet itt megengedőbb a szabály, mint az import-párosításnál,
* ahol egy téves egyezés csendben rossz gyártóhoz rendelne termékeket.
*
* Egy dolgot viszont itt is tiszteletben tartunk: a KÜLÖNBÖZŐ cégforma (Kft vs Zrt)
* valódi különbség, nem elírás - azokat nem javasoljuk összevonásra. A cégforma
* nélküli név viszont párba állhat egy cégformással, ha csak egyféle forma van.
*
* @return array<string, array<int, array{producer: object, counts: array<string, int>}>>
*/
public function looseCandidateGroups(): array
{
$byBase = [];
foreach ($this->activeMembers() as $member) {
[$base, $form] = $this->splitCompanyForm($member['producer']->name);
if ($base === '') {
continue;
}
$byBase[$base][$form][] = $member;
}
$candidates = [];
foreach ($byBase as $base => $byForm) {
$explicitForms = array_diff(array_keys($byForm), ['']);
if (count($explicitForms) > 1) {
// Kft ÉS Zrt is van ugyanarra a névre: ezek nem egymás elírásai. A
// formánként külön csoportok mehetnek, a forma nélküli viszont nem
// rendelhető egyértelműen egyikhez sem, ezért kimarad.
foreach ($explicitForms as $form) {
$candidates[$base . '|' . $form] = $byForm[$form];
}
continue;
}
$candidates[$base] = array_merge(...array_values($byForm));
}
// Csak az érdekes: ami több szigorú csoportot fog össze (vagy olyan rekordokat,
// amiket a szigorú szabály külön hagyott).
$strictKeys = [];
foreach ($candidates as $key => $members) {
$strictKeys[$key] = array_unique(array_map(
fn ($m) => NameNormalizer::normalize($m['producer']->name),
$members,
));
}
$result = [];
foreach ($candidates as $key => $members) {
if (count($members) < 2 || count($strictKeys[$key]) < 2) {
continue;
}
$result[$key] = $this->sortByKeeperPriority($members);
}
ksort($result);
return $result;
}
/**
* Név szétbontása alapnévre és cégforma-toldatra, a "csak elírás" eltérések
* kiszűrésével: írásjelek, ékezetek és szóközök elhagyásával.
*
* @return array{0: string, 1: string} [alapnév, cégforma]
*/
private function splitCompanyForm(string $name): array
{
$normalized = NameNormalizer::normalize($name);
$normalized = preg_replace('/[.,\-\'"()]/u', ' ', $normalized) ?? $normalized;
$normalized = strtr($normalized, [
'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ö' => 'O',
'Ő' => 'O', 'Ú' => 'U', 'Ü' => 'U', 'Ű' => 'U',
]);
$form = '';
$pattern = '/\b(KFT|ZRT|BT|NYRT|KKT|RT|GMBH|LTD|INC|SRL|NV|BV|AG|SA|SPA)\b/u';
if (preg_match($pattern, $normalized, $matches)) {
$form = $matches[1];
$normalized = preg_replace($pattern, ' ', $normalized) ?? $normalized;
}
// A szóközök teljes elhagyása fogja meg az egybeírást ("Alfölditej" = "Alföldi Tej")
return [preg_replace('/\s+/u', '', $normalized) ?? '', $form];
}
/**
* @return array<int, array{producer: object, counts: array<string, int>}>
*/
private function activeMembers(): array
{
$counts = $this->referenceCounts();
return DB::table('producers')
->whereNotIn('status', [DbStatusFieldEnum::archive, DbStatusFieldEnum::deleted])
->orderBy('id')
->get()
->map(fn ($producer) => [
'producer' => $producer, 'producer' => $producer,
'counts' => [ 'counts' => [
'products' => $counts['products'][$producer->id] ?? 0, 'products' => $counts['products'][$producer->id] ?? 0,
'order_archives_items' => $counts['order_archives_items'][$producer->id] ?? 0, 'order_archives_items' => $counts['order_archives_items'][$producer->id] ?? 0,
'pricelist_file_lines' => $counts['pricelist_file_lines'][$producer->id] ?? 0, 'pricelist_file_lines' => $counts['pricelist_file_lines'][$producer->id] ?? 0,
], ],
], $members); ])
->all();
}
// 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. * A megtartandó rekord kerül előre: a legtöbb TERMÉKKEL rendelkező, mert így mozdul
usort($decorated, function ($a, $b) { * a legkevesebb sor. Döntetlennél a több rendelési tétel, majd a régebbi rekord.
*
* @param array<int, array> $members
* @return array<int, array>
*/
private function sortByKeeperPriority(array $members): array
{
usort($members, function ($a, $b) {
return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id] return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id]
<=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id]; <=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id];
}); });
$groups[$normalized] = $decorated; return array_values($members);
}
ksort($groups);
return $groups;
} }
/** /**
@ -112,25 +230,29 @@ public function buildPlan(array $decisions): array
{ {
$groups = $this->duplicateGroups(); $groups = $this->duplicateGroups();
$normalizedById = [];
$memberById = []; $memberById = [];
foreach ($this->activeMembers() as $member) {
$memberById[$member['producer']->id] = $member;
}
$normalizedById = [];
foreach ($groups as $normalized => $members) { foreach ($groups as $normalized => $members) {
foreach ($members as $member) { foreach ($members as $member) {
$normalizedById[$member['producer']->id] = $normalized; $normalizedById[$member['producer']->id] = $normalized;
$memberById[$member['producer']->id] = $member;
} }
} }
$sheetGroups = []; $sheetGroups = [];
$finalNames = []; $finalNames = [];
$skipped = [];
$errors = []; $errors = [];
foreach ($decisions as $row) { foreach ($decisions as $row) {
$id = (int) ($row['id'] ?? 0); $id = (int) ($row['id'] ?? 0);
if (! isset($normalizedById[$id])) { if (! isset($memberById[$id])) {
$errors[] = "A(z) {$id} azonosítójú gyártó nem szerepel duplikált csoportban " $errors[] = "A(z) {$id} azonosítójú gyártó nem található (vagy már összevonták, "
. '(időközben megváltozott az adat, vagy más környezetből származik a lap).'; . 'vagy más környezetből származik a lap).';
continue; continue;
} }
@ -165,20 +287,19 @@ public function buildPlan(array $decisions): array
$members = array_values(array_map(fn ($id) => $memberById[$id], array_unique($ids))); $members = array_values(array_map(fn ($id) => $memberById[$id], array_unique($ids)));
// Üres végleges név = a megrendelő NEM kéri az összevonást. Ez a laza
// javaslatok elutasításának módja, ezért nem hiba, hanem kihagyás - de
// jelentjük, hogy egy véletlen kihagyás se maradjon észrevétlen.
if (! isset($finalNames[$key])) { if (! isset($finalNames[$key])) {
$errors[] = 'Hiányzó végleges név a(z) ' . $key . '. csoportnál: ' $skipped[] = [
. implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members)); 'group' => $key,
'names' => array_map(fn ($m) => (string) $m['producer']->name, $members),
];
continue; continue;
} }
// A megtartott REKORD a legtöbb terméket tartalmazó (így mozdul a legkevesebb $members = $this->sortByKeeperPriority($members);
// sor), a NEVE viszont a megrendelő által választott végleges név lesz.
usort($members, 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];
});
$keeper = $members[0]['producer']; $keeper = $members[0]['producer'];
$losers = array_slice($members, 1); $losers = array_slice($members, 1);
@ -200,7 +321,7 @@ public function buildPlan(array $decisions): array
]; ];
} }
return ['merges' => $merges, 'errors' => $errors]; return ['merges' => $merges, 'errors' => $errors, 'skipped' => $skipped];
} }
/** /**
@ -221,6 +342,12 @@ private function validateGrouping(array $sheetGroups, array $normalizedById, arr
foreach ($sheetGroups as $key => $ids) { foreach ($sheetGroups as $key => $ids) {
foreach ($ids as $id) { foreach ($ids as $id) {
// A lapon szerepelhet olyan gyártó is, ami NEM tagja szigorú duplikátum
// csoportnak (a laza javaslatok ilyenek) - ott nincs mit ellenőrizni.
if (! isset($normalizedById[$id])) {
continue;
}
$normalized = $normalizedById[$id]; $normalized = $normalizedById[$id];
$sheetKeysByNormalized[$normalized][$key] = true; $sheetKeysByNormalized[$normalized][$key] = true;
$idsByNormalized[$normalized][$id] = true; $idsByNormalized[$normalized][$id] = true;

View File

@ -65,7 +65,7 @@ function decisionsFor(array $rows): array
expect($this->deduplicator->duplicateGroups())->toBe([]); expect($this->deduplicator->duplicateGroups())->toBe([]);
}); });
test('hiányzó végleges név esetén hibát jelez, és nem tervez összevonást', function () { test('az üres végleges név kihagyja a csoportot ez a javaslat elutasításának módja', function () {
$a = producer('Danone'); $a = producer('Danone');
$b = producer('Danone '); $b = producer('Danone ');
@ -74,8 +74,12 @@ function decisionsFor(array $rows): array
['id' => $b->id, 'final_name' => null], ['id' => $b->id, 'final_name' => null],
])); ]));
// Nem hiba: a bizonytalan javaslatokat a megrendelő így utasítja el. De jelentjük,
// hogy egy véletlen kihagyás se maradjon észrevétlen.
expect($plan['merges'])->toBe([]) expect($plan['merges'])->toBe([])
->and($plan['errors'][0])->toContain('Hiányzó végleges név'); ->and($plan['errors'])->toBe([])
->and($plan['skipped'])->toHaveCount(1)
->and($plan['skipped'][0]['names'])->toContain('Danone ');
}); });
test('hiányos csoport esetén hibát jelez', function () { test('hiányos csoport esetén hibát jelez', function () {
@ -95,7 +99,60 @@ function decisionsFor(array $rows): array
test('ismeretlen azonosítót jelez, ha a lap más adatállapotból származik', function () { 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']])); $plan = $this->deduplicator->buildPlan(decisionsFor([['id' => 999999, 'final_name' => 'Bármi']]));
expect($plan['errors'][0])->toContain('nem szerepel duplikált csoportban'); expect($plan['errors'][0])->toContain('nem található');
});
test('a laza jelöltek megfogják az elírásokat, amiket a szigorú szabály nem', function () {
// Ezek egyike sem kerül össze a szigorú szabállyal: írásjel, kötőjel, egybeírás,
// hiányzó ékezet. A megrendelő szemével viszont nyilvánvalóan ugyanaz a cég.
producer('Békás Kft.');
producer('Békás Kft');
producer('Gast Food');
producer('Gast-Food');
producer('Alföldi Tej');
producer('Alfölditej');
expect($this->deduplicator->duplicateGroups())->toBe([]);
$loose = $this->deduplicator->looseCandidateGroups();
expect($loose)->toHaveCount(3);
$names = array_map(
fn ($members) => array_map(fn ($m) => $m['producer']->name, $members),
array_values($loose),
);
expect(collect($names)->flatten()->all())->toContain('Békás Kft.', 'Gast-Food', 'Alfölditej');
});
test('a laza jelöltek NEM vonnak össze eltérő cégformát', function () {
// A Kft és a Zrt valódi különbség, nem elírás - ezt a rendszer nem döntheti el.
producer('Alföldi Tej Kft');
producer('Alföldi Tej Zrt');
expect($this->deduplicator->looseCandidateGroups())->toBe([]);
});
test('a cégforma nélküli név párba állhat, ha csak egyféle forma van', function () {
$bare = producer('Szegedi Sütödék');
$kft = producer('Szegedi Sütödék Kft.');
$loose = $this->deduplicator->looseCandidateGroups();
expect($loose)->toHaveCount(1);
$ids = array_map(fn ($m) => $m['producer']->id, array_values($loose)[0]);
expect($ids)->toContain($bare->id)->toContain($kft->id);
});
test('az archivált gyártó nem kerül a laza jelöltek közé', function () {
producer('Békás Kft.');
$merged = producer('Békás Kft');
DB::table('producers')->where('id', $merged->id)->update(['status' => DbStatusFieldEnum::archive]);
expect($this->deduplicator->looseCandidateGroups())->toBe([]);
}); });
test('a lap egy csoportba tehet olyan gyártókat is, amiket a normalizálás nem köt össze', function () { test('a lap egy csoportba tehet olyan gyártókat is, amiket a normalizálás nem köt össze', function () {