ADD EV3-357 a döntési lap adja a csoportosítást (csoportokon átívelő összevonás)

A megrendelő 2026-08-27-én hat esetben jelezte, hogy két külön névcsoportunk
valójában ugyanaz a cég: a rövid név és a cégformás név (Kőröstej / Kőröstej
Kft, Pick / Pick Szeged Zrt, White Lake / White Lake Kft, ...). A
névnormalizálás ezt nem tudhatja, mert a nevek érdemben különböznek.

- buildPlan a LAP "Csoport" oszlopa szerint csoportosít, nem az adatbázis
  normalizálása szerint. Így a lap szélesebb csoportot is kijelölhet - a
  végrehajtásba nem kell csoportokon átívelő logika.
- validateGrouping: a lap szélesebb csoportot csinálhat, SZŰKEBBET nem. Ha az
  azonos nevű rekordok külön csoportba kerülnének, a végén két azonos nevű
  gyártó maradna, vagyis pont a duplikációt állítanánk elő. Ilyenkor a csoport
  kimarad a végrehajtásból, nem csak figyelmeztetés.
- a megtartott REKORD továbbra is a legtöbb terméket tartalmazó (a legkevesebb
  sor mozdul), a NEVE viszont a választott cégnév

2 új teszt a csoportokon átívelő összevonásra és a szétszórás elutasítására.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-27 17:30:24 +02:00
parent 2ced04c086
commit 7e32a870ae
2 changed files with 146 additions and 22 deletions

View File

@ -99,72 +99,99 @@ private function referenceCounts(): array
/** /**
* Összevonási terv a döntési lap sorai alapján. * Összevonási terv a döntési lap sorai alapján.
* *
* A csoportosítás forrása a LAP "Csoport" oszlopa, nem az adatbázis normalizálása.
* Ez azért fontos, mert a megrendelő olyan gyártókat is egybe akarhat vonni, amiket a
* névnormalizálás nem tud összekötni: a "Kőröstej" és a "Kőröstej Kft" ugyanaz a cég,
* de a nevük érdemben különbözik. Ha a lap ezeket egy csoportba teszi, itt is egy
* összevonás lesz belőlük - így nem kell a végrehajtásba csoportokon átívelő logika.
*
* @param array<int, array{group: int|string, id: int, final_name: ?string}> $decisions * @param array<int, array{group: int|string, id: int, final_name: ?string}> $decisions
* @return array{merges: array<int, array>, errors: array<int, string>} * @return array{merges: array<int, array>, errors: array<int, string>}
*/ */
public function buildPlan(array $decisions): array public function buildPlan(array $decisions): array
{ {
$groups = $this->duplicateGroups(); $groups = $this->duplicateGroups();
$byId = [];
$normalizedById = [];
$memberById = [];
foreach ($groups as $normalized => $members) { foreach ($groups as $normalized => $members) {
foreach ($members as $member) { foreach ($members as $member) {
$byId[$member['producer']->id] = $normalized; $normalizedById[$member['producer']->id] = $normalized;
$memberById[$member['producer']->id] = $member;
} }
} }
$rowsByGroup = []; $sheetGroups = [];
$finalNames = []; $finalNames = [];
$errors = []; $errors = [];
foreach ($decisions as $row) { foreach ($decisions as $row) {
$id = (int) ($row['id'] ?? 0); $id = (int) ($row['id'] ?? 0);
if (! isset($byId[$id])) { if (! isset($normalizedById[$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 szerepel duplikált csoportban "
. '(időközben megváltozott az adat, vagy más környezetből származik a lap).'; . '(időközben megváltozott az adat, vagy más környezetből származik a lap).';
continue; continue;
} }
$normalized = $byId[$id]; $key = trim((string) ($row['group'] ?? ''));
$rowsByGroup[$normalized][] = $id;
if ($key === '') {
$errors[] = "A(z) {$id} azonosítójú sorból hiányzik a csoport száma.";
continue;
}
$sheetGroups[$key][] = $id;
$name = trim((string) ($row['final_name'] ?? '')); $name = trim((string) ($row['final_name'] ?? ''));
if ($name !== '') { if ($name !== '') {
$finalNames[$normalized] ??= $name; $finalNames[$key] ??= $name;
} }
} }
$validation = $this->validateGrouping($sheetGroups, $normalizedById, $groups);
$errors = array_merge($errors, $validation['errors']);
$merges = []; $merges = [];
foreach ($rowsByGroup as $normalized => $ids) { foreach ($sheetGroups as $key => $ids) {
$members = $groups[$normalized]; // A hibás csoportot NEM hajtjuk végre: egy hiányzó tag azt jelentené, hogy
$memberIds = array_map(fn ($m) => $m['producer']->id, $members); // az összevonás után is maradna azonos nevű rekord.
if (isset($validation['invalid'][$key])) {
continue;
}
if (! isset($finalNames[$normalized])) { $members = array_values(array_map(fn ($id) => $memberById[$id], array_unique($ids)));
$errors[] = 'Hiányzó végleges név ehhez a csoporthoz: '
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)); . implode(' / ', array_map(fn ($m) => '"' . $m['producer']->name . '"', $members));
continue; continue;
} }
$missing = array_diff($memberIds, $ids); // A megtartott REKORD a legtöbb terméket tartalmazó (így mozdul a legkevesebb
if ($missing !== []) { // sor), a NEVE viszont a megrendelő által választott végleges név lesz.
$errors[] = 'A döntési lap nem tartalmazza a csoport minden tagját (hiányzó azonosító: ' usort($members, function ($a, $b) {
. implode(', ', $missing) . '). Generáld újra a lapot ebben a környezetben.'; return [$b['counts']['products'], $b['counts']['order_archives_items'], $a['producer']->id]
<=> [$a['counts']['products'], $a['counts']['order_archives_items'], $b['producer']->id];
continue; });
}
$keeper = $members[0]['producer']; $keeper = $members[0]['producer'];
$losers = array_slice($members, 1); $losers = array_slice($members, 1);
if ($losers === []) {
continue; // egytagú csoport: nincs mit összevonni
}
$merges[] = [ $merges[] = [
'normalized' => $normalized, 'group' => $key,
'keeper_id' => $keeper->id, 'keeper_id' => $keeper->id,
'keeper_name' => $keeper->name, 'keeper_name' => $keeper->name,
'final_name' => $finalNames[$normalized], 'final_name' => $finalNames[$key],
'rename' => trim((string) $keeper->name) !== $finalNames[$normalized], 'rename' => trim((string) $keeper->name) !== $finalNames[$key],
'from' => array_map(fn ($m) => [ 'from' => array_map(fn ($m) => [
'id' => $m['producer']->id, 'id' => $m['producer']->id,
'name' => $m['producer']->name, 'name' => $m['producer']->name,
@ -176,6 +203,57 @@ public function buildPlan(array $decisions): array
return ['merges' => $merges, 'errors' => $errors]; return ['merges' => $merges, 'errors' => $errors];
} }
/**
* A lap-csoportosítás ellenőrzése.
*
* A lap SZÉLESEBB csoportot csinálhat, mint a névnormalizálás (ez a cél), de
* SZŰKEBBET nem: ha az azonos nevű rekordok külön lap-csoportba kerülnének, a
* végén két azonos nevű gyártó maradna - vagyis pont a duplikációt állítanánk elő.
*
* @return array{errors: array<int, string>, invalid: array<string, true>}
*/
private function validateGrouping(array $sheetGroups, array $normalizedById, array $groups): array
{
$errors = [];
$invalid = [];
$sheetKeysByNormalized = [];
$idsByNormalized = [];
foreach ($sheetGroups as $key => $ids) {
foreach ($ids as $id) {
$normalized = $normalizedById[$id];
$sheetKeysByNormalized[$normalized][$key] = true;
$idsByNormalized[$normalized][$id] = true;
}
}
foreach ($sheetKeysByNormalized as $normalized => $keys) {
if (count($keys) > 1) {
$errors[] = 'Az azonos nevű gyártók ("' . $normalized . '") több csoportba kerültek a lapon ('
. implode(', ', array_keys($keys)) . '). Ezeket egy csoportba kell tenni, '
. 'különben duplikátum maradna utánuk.';
foreach (array_keys($keys) as $key) {
$invalid[$key] = true;
}
}
$expected = array_map(fn ($m) => $m['producer']->id, $groups[$normalized]);
$missing = array_diff($expected, array_keys($idsByNormalized[$normalized]));
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.';
foreach (array_keys($keys) as $key) {
$invalid[$key] = true;
}
}
}
return ['errors' => $errors, 'invalid' => $invalid];
}
/** /**
* A terv végrehajtása. Előbb visszaállítási pontot ír, csak utána módosít. * A terv végrehajtása. Előbb visszaállítási pontot ír, csak utána módosít.
* *

View File

@ -98,6 +98,52 @@ function decisionsFor(array $rows): array
expect($plan['errors'][0])->toContain('nem szerepel duplikált csoportban'); expect($plan['errors'][0])->toContain('nem szerepel duplikált csoportban');
}); });
test('a lap egy csoportba tehet olyan gyártókat is, amiket a normalizálás nem köt össze', function () {
// A megrendelő döntése: a "Kőröstej" és a "Kőröstej Kft" ugyanaz a cég. A
// névnormalizálás ezt nem tudhatja, a lap Csoport oszlopa viszont kifejezi.
$short = producer('Kőröstej');
$shortDup = producer('Kőröstej ');
$long = producer('Kőröstej Kft');
$longDup = producer('KŐRÖSTEJ KFT');
productFor($short, 'A1');
productFor($short, 'A2');
productFor($long, 'B1');
$plan = app(ProducerDeduplicator::class)->buildPlan([
['group' => 7, 'id' => $short->id, 'final_name' => 'Kőröstej Kft'],
['group' => 7, 'id' => $shortDup->id, 'final_name' => null],
['group' => 7, 'id' => $long->id, 'final_name' => null],
['group' => 7, 'id' => $longDup->id, 'final_name' => null],
]);
expect($plan['errors'])->toBe([])
->and($plan['merges'])->toHaveCount(1);
$merge = $plan['merges'][0];
// A megtartott REKORD a legtöbb terméket tartalmazó, a NEVE viszont a választott
// cégnév - így a legkevesebb sor mozdul, mégis a kívánt név marad.
expect($merge['keeper_id'])->toBe($short->id)
->and($merge['final_name'])->toBe('Kőröstej Kft')
->and($merge['rename'])->toBeTrue()
->and($merge['from'])->toHaveCount(3);
});
test('az azonos nevű rekordok szétszórása külön csoportokba hibát ad', function () {
// Ez duplikátumot hagyna maga után, ezért nem hajtható végre.
$a = producer('Danone');
$b = producer('Danone ');
$plan = app(ProducerDeduplicator::class)->buildPlan([
['group' => 1, 'id' => $a->id, 'final_name' => 'Danone'],
['group' => 2, 'id' => $b->id, 'final_name' => 'Danone'],
]);
expect($plan['merges'])->toBe([])
->and($plan['errors'][0])->toContain('több csoportba kerültek');
});
test('az összevonás átírja a hivatkozásokat és archiválja a beolvasztott rekordot', function () { test('az összevonás átírja a hivatkozásokat és archiválja a beolvasztott rekordot', function () {
$keeper = producer('Danone'); $keeper = producer('Danone');
$loser = producer('Danone '); $loser = producer('Danone ');