diff --git a/app/Console/Commands/ProducersDedupe.php b/app/Console/Commands/ProducersDedupe.php index 225bcf5..9c43bbf 100644 --- a/app/Console/Commands/ProducersDedupe.php +++ b/app/Console/Commands/ProducersDedupe.php @@ -41,12 +41,21 @@ public function handle(ProducerDeduplicator $deduplicator): int return self::FAILURE; } - ['merges' => $merges, 'errors' => $errors] = $deduplicator->buildPlan($decisions); + $plan = $deduplicator->buildPlan($decisions); + $merges = $plan['merges']; + $errors = $plan['errors']; foreach ($errors as $error) { $this->warn(' ! ' . $error); } + // Az üres végleges név a bizonytalan javaslatok elutasításának módja - nem hiba, + // de kiírjuk, hogy egy véletlen kihagyás se maradjon észrevétlen. + foreach ($plan['skipped'] ?? [] as $skip) { + $this->line(' - kimarad a(z) ' . $skip['group'] . '. csoport (nincs végleges név): ' + . implode(' / ', array_map(fn ($n) => '"' . $n . '"', $skip['names']))); + } + if ($merges === []) { $this->error('Nincs végrehajtható összevonás.'); diff --git a/app/Console/Commands/ProducersDedupeReport.php b/app/Console/Commands/ProducersDedupeReport.php index 7d203d7..05e8fa9 100644 --- a/app/Console/Commands/ProducersDedupeReport.php +++ b/app/Console/Commands/ProducersDedupeReport.php @@ -4,6 +4,7 @@ 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; @@ -13,7 +14,9 @@ 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)'; @@ -28,11 +31,16 @@ class ProducersDedupeReport extends Command 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 { - $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.'); return self::SUCCESS; @@ -46,11 +54,11 @@ public function handle(ProducerDeduplicator $deduplicator): int $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Gyártó duplikációk'); - $this->writeIntro($sheet); - $headerRow = 8; + $this->writeIntro($sheet, $loose !== []); + $headerRow = 9; $this->writeHeader($sheet, $headerRow); - $lastRow = $this->writeGroups($sheet, $headerRow, $groups); - $this->finishLayout($sheet, $headerRow, $lastRow, count($groups), $groups); + $lastRow = $this->writeGroups($sheet, $headerRow, $certain, $loose); + $this->finishLayout($sheet, $headerRow, $lastRow, $certain, $loose); $directory = dirname($path); if (! is_dir($directory)) { @@ -59,50 +67,69 @@ public function handle(ProducerDeduplicator $deduplicator): int (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); + $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): void + 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() + $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'); + . '"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. 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'); + . '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(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 { - $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']; foreach ($headers as $i => $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)->getFill()->setFillType(Fill::FILL_SOLID) ->getStartColor()->setARGB('FF44546A'); @@ -111,54 +138,74 @@ private function writeHeader($sheet, int $headerRow): void $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; $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++; + 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); } - - $sheet->getStyle('A' . $groupFirstRow . ':I' . ($row - 1))->getBorders()->getTop() - ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFBFBFBF'); } 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 */ @@ -182,30 +229,37 @@ private function describeDifference(string $name, string $proposed): array 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; - $sheet->getStyle('E' . $first . ':F' . $lastRow)->getNumberFormat()->setFormatCode('# ##0'); - $sheet->getStyle('A' . $first . ':B' . $lastRow)->getAlignment() + $sheet->getStyle('F' . $first . ':G' . $lastRow)->getNumberFormat()->setFormatCode('# ##0'); + $sheet->getStyle('A' . $first . ':C' . $lastRow)->getAlignment() ->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'); - $widths = ['A' => 9, 'B' => 11, 'C' => 34, 'D' => 22, 'E' => 11, - 'F' => 17, 'G' => 13, 'H' => 34, 'I' => 26]; + $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 . ':I' . $lastRow); + $sheet->setAutoFilter('A' . $headerRow . ':J' . $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); + $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); } } diff --git a/app/Services/ProducerDeduplicator.php b/app/Services/ProducerDeduplicator.php index 19ad302..1c8395f 100644 --- a/app/Services/ProducerDeduplicator.php +++ b/app/Services/ProducerDeduplicator.php @@ -40,11 +40,9 @@ class ProducerDeduplicator */ public function duplicateGroups(): array { - $counts = $this->referenceCounts(); - $byNormalized = []; - foreach (DB::table('producers')->orderBy('id')->get() as $producer) { - $byNormalized[NameNormalizer::normalize($producer->name)][] = $producer; + foreach ($this->activeMembers() as $member) { + $byNormalized[NameNormalizer::normalize($member['producer']->name)][] = $member; } $groups = []; @@ -53,28 +51,148 @@ public function duplicateGroups(): array 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}>> + */ + 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}> + */ + 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, '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); + ]) + ->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. - 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]; - }); + /** + * A megtartandó rekord kerül előre: a legtöbb TERMÉKKEL rendelkező, mert így mozdul + * a legkevesebb sor. Döntetlennél a több rendelési tétel, majd a régebbi rekord. + * + * @param array $members + * @return array + */ + private function sortByKeeperPriority(array $members): array + { + 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]; + }); - $groups[$normalized] = $decorated; - } - - ksort($groups); - - return $groups; + return array_values($members); } /** @@ -112,25 +230,29 @@ public function buildPlan(array $decisions): array { $groups = $this->duplicateGroups(); - $normalizedById = []; $memberById = []; + foreach ($this->activeMembers() as $member) { + $memberById[$member['producer']->id] = $member; + } + + $normalizedById = []; foreach ($groups as $normalized => $members) { foreach ($members as $member) { $normalizedById[$member['producer']->id] = $normalized; - $memberById[$member['producer']->id] = $member; } } $sheetGroups = []; $finalNames = []; + $skipped = []; $errors = []; foreach ($decisions as $row) { $id = (int) ($row['id'] ?? 0); - if (! isset($normalizedById[$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).'; + if (! isset($memberById[$id])) { + $errors[] = "A(z) {$id} azonosítójú gyártó nem található (vagy már összevonták, " + . 'vagy más környezetből származik a lap).'; continue; } @@ -165,20 +287,19 @@ public function buildPlan(array $decisions): array $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])) { - $errors[] = 'Hiányzó végleges név a(z) ' . $key . '. csoportnál: ' - . implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members)); + $skipped[] = [ + 'group' => $key, + 'names' => array_map(fn ($m) => (string) $m['producer']->name, $members), + ]; continue; } - // A megtartott REKORD a legtöbb terméket tartalmazó (így mozdul a legkevesebb - // 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]; - }); - + $members = $this->sortByKeeperPriority($members); $keeper = $members[0]['producer']; $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 ($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]; $sheetKeysByNormalized[$normalized][$key] = true; $idsByNormalized[$normalized][$id] = true; diff --git a/tests/Feature/ProducerDedupeTest.php b/tests/Feature/ProducerDedupeTest.php index a3ab2b7..7aacc72 100644 --- a/tests/Feature/ProducerDedupeTest.php +++ b/tests/Feature/ProducerDedupeTest.php @@ -65,7 +65,7 @@ function decisionsFor(array $rows): array 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'); $b = producer('Danone '); @@ -74,8 +74,12 @@ function decisionsFor(array $rows): array ['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([]) - ->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 () { @@ -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 () { $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 () {