EV3-357 Árlista feltöltés és feldolgzás újra gondolása phase3 fejlec adatok cseréje

This commit is contained in:
E98Developer 2026-07-22 12:37:24 +02:00
parent 5477b953a8
commit e49b7515e0
5 changed files with 271 additions and 43 deletions

View File

@ -43,9 +43,8 @@ public function severity(): string
self::NO_WORKSHEET, self::NO_WORKSHEET,
self::COLUMN_COUNT_MISMATCH, self::COLUMN_COUNT_MISMATCH,
self::HEADER_MISMATCH, self::HEADER_MISMATCH,
self::COLUMN_MISSING,
self::NO_DATA_ROWS => 'blocker', self::NO_DATA_ROWS => 'blocker',
self::COLUMN_MISSING => 'error',
self::COLUMN_EXTRA, self::COLUMN_EXTRA,
self::FILE_TOO_LARGE => 'warning', self::FILE_TOO_LARGE => 'warning',
}; };

View File

@ -84,7 +84,7 @@ public function getExcelFieldPointer(): array
} }
public const EXPECTED_HEADERS = [ public const EXPECTED_HEADERS = [
0 => 'Szállítói cikkszám', 0 => 'Szálltói cikkszám',
1 => 'Fő termékcsoport', 1 => 'Fő termékcsoport',
2 => 'Alcsoport 1', 2 => 'Alcsoport 1',
3 => 'Alcsoport 2', 3 => 'Alcsoport 2',
@ -92,7 +92,7 @@ public function getExcelFieldPointer(): array
5 => 'Kiszerelés', 5 => 'Kiszerelés',
6 => 'Súly/Űrtartalom (nettó)', 6 => 'Súly/Űrtartalom (nettó)',
7 => 'Mértékegység', 7 => 'Mértékegység',
8 => 'Gyártó', 8 => 'Gyártó/Forgalmazó',
9 => 'Legkisebb eladási egység', 9 => 'Legkisebb eladási egység',
10 => 'Egységszorzó', 10 => 'Egységszorzó',
11 => 'Mennyiségi egység', 11 => 'Mennyiségi egység',

View File

@ -174,6 +174,19 @@ public function updateStepStatus(
private const MIN_FILE_SIZE_BYTES = 1024; // 1 KB private const MIN_FILE_SIZE_BYTES = 1024; // 1 KB
private const ALLOWED_EXTENSIONS = ['xlsx', 'xls']; private const ALLOWED_EXTENSIONS = ['xlsx', 'xls'];
/**
* A szabványosított "A lista" szerkezete:
* - 1-2. sor: leíró adat (érvénybelépés dátuma, beszállító) - figyelmen kívül hagyjuk,
* - 3. sor: a fejléc megnevezések (nem változtatható), ez alapján történik az oszlop-leképezés,
* - 4. sor: egyedi/legacy megnevezéseket tartalmazhat - figyelmen kívül hagyjuk,
* - 5. sortól: az adatsorok.
*
* Az oszlopok sorrendje szabadon felcserélhető: az adott mező oszlopát a 3. sor
* fejlécneve alapján azonosítjuk (lásd buildColumnMap()).
*/
private const HEADER_ROW = 3;
private const DATA_START_ROW = 5;
/** /**
* Előfeldolgozás (Pre-processing) - Strukturális és technikai ellenőrzés * Előfeldolgozás (Pre-processing) - Strukturális és technikai ellenőrzés
* *
@ -238,7 +251,7 @@ public function preProcess(PricelistFile $pricelistFile): bool
// 4. Statisztika gyűjtés (70-90%) // 4. Statisztika gyűjtés (70-90%)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Statisztikai adatok gyűjtése...', 75); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Statisztikai adatok gyűjtése...', 75);
$meta['statistics'] = $this->collectStatistics($worksheet); $meta['statistics'] = $this->collectStatistics($worksheet, $structureResult['map']);
if ($meta['statistics']['data_rows'] === 0) { if ($meta['statistics']['data_rows'] === 0) {
$meta['errors'][] = $this->buildError(PreProcessErrorCode::NO_DATA_ROWS); $meta['errors'][] = $this->buildError(PreProcessErrorCode::NO_DATA_ROWS);
@ -318,65 +331,114 @@ private function checkFilePhysical(PricelistFile $pricelistFile): array
} }
/** /**
* Oszlopstruktúra és fejléc validálása * Fejléc szöveg normalizálása az összehasonlításhoz (kis/nagybetű-érzéketlen,
* a "/" és "_" szeparátorok szóközzé alakulnak, a többszörös szóközök összevonódnak).
*/ */
private function validateColumnStructure(Worksheet $worksheet): array private function normalizeHeader(string $value): string
{
$value = trim($value);
$value = str_replace(['/', '_'], ' ', $value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return mb_strtolower($value, 'UTF-8');
}
/**
* A 3. sor fejlécei alapján oszlop-leképezés készítése: melyik elvárt mező (index)
* melyik tényleges munkalap-oszlopban (1-alapú) található. Az oszlopok sorrendje szabad.
*
* @return array{map: array<int, int>, errors: array<int, array>, warnings: array<int, array>}
*/
private function buildColumnMap(Worksheet $worksheet): array
{ {
$errors = []; $errors = [];
$warnings = []; $warnings = [];
$map = [];
$expectedColumnCount = count($this->priceListService->getExcelFieldPointer());
$highestColumn = $worksheet->getHighestDataColumn(); $highestColumn = $worksheet->getHighestDataColumn();
$columnCount = Coordinate::columnIndexFromString($highestColumn); $columnCount = Coordinate::columnIndexFromString($highestColumn);
if ($columnCount < $expectedColumnCount) {
$errors[] = $this->buildError(
PreProcessErrorCode::COLUMN_COUNT_MISMATCH,
"Kevés oszlop ({$columnCount}), elvárt legalább: {$expectedColumnCount}"
);
} elseif ($columnCount > $expectedColumnCount) {
$warnings[] = $this->buildWarning(
PreProcessErrorCode::COLUMN_EXTRA,
"Többlet oszlopok ({$columnCount}), az első {$expectedColumnCount} kerül feldolgozásra."
);
}
// Fejléc ellenőrzés (4. sor a specifikáció szerint)
$headerRow = 4;
$expectedHeaders = PriceListService::EXPECTED_HEADERS; $expectedHeaders = PriceListService::EXPECTED_HEADERS;
$checkCount = min($columnCount, count($expectedHeaders));
for ($col = 0; $col < $checkCount; $col++) { // A 3. sor fejléceinek beolvasása: normalizált fejléc => oszlopszám (1-alapú)
$cellValue = trim((string) $worksheet->getCellByColumnAndRow($col + 1, $headerRow)->getValue()); $headerByName = [];
$expectedValue = $expectedHeaders[$col] ?? null; for ($col = 1; $col <= $columnCount; $col++) {
$raw = trim((string) $worksheet->getCellByColumnAndRow($col, self::HEADER_ROW)->getValue());
if ($expectedValue !== null && $cellValue !== $expectedValue) { if ($raw === '') {
// Az oszlopnevek eltérése blocker hiba continue;
$errors[] = $this->buildError( }
PreProcessErrorCode::HEADER_MISMATCH, $normalized = $this->normalizeHeader($raw);
"A(z) " . ($col + 1) . ". oszlop fejléce '{$cellValue}' az elvárt '{$expectedValue}' helyett (a {$headerRow}. sorban)." // Az első előfordulás nyer, a duplikátumokra figyelmeztetünk
if (!isset($headerByName[$normalized])) {
$headerByName[$normalized] = $col;
} else {
$warnings[] = $this->buildWarning(
PreProcessErrorCode::COLUMN_EXTRA,
"A(z) '{$raw}' fejléc többször szerepel a " . self::HEADER_ROW . ". sorban; az első előfordulás ({$headerByName[$normalized]}. oszlop) kerül felhasználásra."
); );
} }
} }
return ['errors' => $errors, 'warnings' => $warnings]; $usedColumns = [];
foreach ($expectedHeaders as $index => $expectedLabel) {
$normalizedExpected = $this->normalizeHeader($expectedLabel);
if (isset($headerByName[$normalizedExpected])) {
$map[$index] = $headerByName[$normalizedExpected];
$usedColumns[$headerByName[$normalizedExpected]] = true;
} else {
// Hiányzó kötelező fejléc: blokkoló hiba
$errors[] = $this->buildError(
PreProcessErrorCode::COLUMN_MISSING,
"Hiányzó oszlopfejléc: '{$expectedLabel}' (a " . self::HEADER_ROW . ". sorban nem található)."
);
}
}
// A leképezésben nem használt (ismeretlen) oszlopok csak figyelmeztetést adnak
for ($col = 1; $col <= $columnCount; $col++) {
if (!isset($usedColumns[$col])) {
$raw = trim((string) $worksheet->getCellByColumnAndRow($col, self::HEADER_ROW)->getValue());
if ($raw !== '') {
$warnings[] = $this->buildWarning(
PreProcessErrorCode::COLUMN_EXTRA,
"Ismeretlen oszlop a {$col}. pozícióban ('{$raw}'), a feldolgozás során figyelmen kívül hagyjuk."
);
}
}
}
return ['map' => $map, 'errors' => $errors, 'warnings' => $warnings];
}
/**
* Oszlopstruktúra és fejléc validálása a 3. sor fejlécnevei alapján (sorrend-független).
*
* @return array{map: array<int, int>, errors: array<int, array>, warnings: array<int, array>}
*/
private function validateColumnStructure(Worksheet $worksheet): array
{
return $this->buildColumnMap($worksheet);
} }
/** /**
* Statisztikai adatok gyűjtése a munkalapról * Statisztikai adatok gyűjtése a munkalapról
*
* @param array<int, int> $columnMap elvárt mező index => oszlopszám (1-alapú)
*/ */
private function collectStatistics(Worksheet $worksheet): array private function collectStatistics(Worksheet $worksheet, array $columnMap = []): array
{ {
$highestRow = $worksheet->getHighestDataRow(); $highestRow = $worksheet->getHighestDataRow();
$highestColumn = $worksheet->getHighestDataColumn(); $highestColumn = $worksheet->getHighestDataColumn();
$columnCount = Coordinate::columnIndexFromString($highestColumn); $columnCount = Coordinate::columnIndexFromString($highestColumn);
// A szállítói cikkszám (0. mező) oszlopa a leképezés alapján, fallback az 1. oszlop
$skuColumn = $columnMap[0] ?? 1;
$dataRows = 0; $dataRows = 0;
$emptyRows = 0; $emptyRows = 0;
// A 4. sort fejlécnek tekintjük, az 5. sortól indul az adat // A 3. sor a fejléc, a 4. sort figyelmen kívül hagyjuk, az adat az 5. sortól indul
for ($row = 5; $row <= $highestRow; $row++) { for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) {
$supplierProductNumber = $worksheet->getCellByColumnAndRow(1, $row)->getValue(); $supplierProductNumber = $worksheet->getCellByColumnAndRow($skuColumn, $row)->getValue();
if (!empty(trim((string) $supplierProductNumber))) { if (!empty(trim((string) $supplierProductNumber))) {
$dataRows++; $dataRows++;
} else { } else {
@ -385,7 +447,7 @@ private function collectStatistics(Worksheet $worksheet): array
} }
return [ return [
'total_rows' => max(0, $highestRow - 4), // 4 sor fejléc/leírás után 'total_rows' => max(0, $highestRow - (self::DATA_START_ROW - 1)), // leíró sorok + fejléc után
'data_rows' => $dataRows, 'data_rows' => $dataRows,
'empty_rows' => $emptyRows, 'empty_rows' => $emptyRows,
'column_count' => $columnCount, 'column_count' => $columnCount,
@ -484,21 +546,31 @@ public function validate(PricelistFile $pricelistFile): bool
$highestRow = $worksheet->getHighestDataRow(); $highestRow = $worksheet->getHighestDataRow();
$expectedHeaders = PriceListService::EXPECTED_HEADERS; $expectedHeaders = PriceListService::EXPECTED_HEADERS;
// A 3. sor fejlécei alapján a tényleges oszlopok leképezése (sorrend-független)
$columnMap = $this->buildColumnMap($worksheet)['map'];
$batchSize = 50; $batchSize = 50;
$dataToInsert = []; $dataToInsert = [];
$processedCount = 0; $processedCount = 0;
$totalDataRows = $highestRow >= 5 ? ($highestRow - 4) : 0; $totalDataRows = $highestRow >= self::DATA_START_ROW ? ($highestRow - (self::DATA_START_ROW - 1)) : 0;
if ($totalDataRows === 0) { if ($totalDataRows === 0) {
throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort az 5. sortól.'); throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort a ' . self::DATA_START_ROW . '. sortól.');
} }
for ($row = 5; $row <= $highestRow; $row++) { for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) {
$rowData = []; $rowData = [];
$hasData = false; $hasData = false;
foreach ($expectedHeaders as $index => $label) { foreach ($expectedHeaders as $index => $label) {
$cell = $worksheet->getCellByColumnAndRow($index + 1, $row); // Az oszlopok sorrendje szabad: a mező tényleges oszlopát a leképezés adja
$column = $columnMap[$index] ?? null;
if ($column === null) {
$rowData[$label] = null;
continue;
}
$cell = $worksheet->getCellByColumnAndRow($column, $row);
$value = $cell->getValue(); $value = $cell->getValue();
// Ha képlet, próbáljuk kiszámolni a megjelenített értéket // Ha képlet, próbáljuk kiszámolni a megjelenített értéket

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Egy korábbi adatbázis-import során számos tábla `id` elsődleges kulcsáról
* elveszett az AUTO_INCREMENT jelző, ami "Field 'id' doesn't have a default value"
* hibát okozott a beszúrásokkor. Ez a migráció idempotens módon helyreállítja az
* AUTO_INCREMENT-et minden olyan `id` oszlopon, amely elsődleges kulcs, egész típusú,
* és jelenleg nem AUTO_INCREMENT.
*/
public function up(): void
{
foreach ($this->findBrokenIdColumns() as $column) {
DB::statement(sprintf(
'ALTER TABLE `%s` MODIFY `id` %s NOT NULL AUTO_INCREMENT',
$column->TABLE_NAME,
$column->COLUMN_TYPE
));
}
}
/**
* A migráció nem visszafordítható: az AUTO_INCREMENT eltávolítása ismét működésképtelenné
* tenné az érintett táblákat, ezért szándékosan nincs visszaállítás.
*/
public function down(): void
{
//
}
/**
* Azon `id` oszlopok listája, amelyek elsődleges kulcsok, egész típusúak,
* de nincs rajtuk AUTO_INCREMENT.
*
* @return array<int, object{TABLE_NAME: string, COLUMN_TYPE: string}>
*/
private function findBrokenIdColumns(): array
{
return DB::select(
'SELECT TABLE_NAME, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND COLUMN_NAME = ?
AND COLUMN_KEY = ?
AND DATA_TYPE IN (?, ?, ?, ?)
AND EXTRA NOT LIKE ?',
[DB::getDatabaseName(), 'id', 'PRI', 'bigint', 'int', 'mediumint', 'smallint', '%auto_increment%']
);
}
};

View File

@ -0,0 +1,103 @@
<?php
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Tests\TestCase;
uses(TestCase::class);
/**
* A 3. sor fejlécei alapján épített oszlop-leképezés meghívása (a metódus privát).
*
* @return array{map: array<int, int>, errors: array<int, array>, warnings: array<int, array>}
*/
function invokeBuildColumnMap(Worksheet $worksheet): array
{
$service = app(PricelistFileProcessService::class);
$method = new ReflectionMethod($service, 'buildColumnMap');
$method->setAccessible(true);
return $method->invoke($service, $worksheet);
}
/**
* @param array<int, int> $order a mezők (EXPECTED_HEADERS index) sorrendje az oszlopokban
*/
function makeWorksheetWithHeaderOrder(array $order): Worksheet
{
$expected = PriceListService::EXPECTED_HEADERS;
$worksheet = (new Spreadsheet())->getActiveSheet();
$worksheet->setCellValue('A1', 'Árlista érvénybelépési időpontja:');
$worksheet->setCellValue('A2', 'Beszállító megnevezése:');
$col = 1;
foreach ($order as $fieldIndex) {
// 3. sor: fejléc, 4. sor: figyelmen kívül hagyandó, 5. sor: adat
$worksheet->setCellValueByColumnAndRow($col, 3, $expected[$fieldIndex]);
$worksheet->setCellValueByColumnAndRow($col, 4, 'egyedi megnevezés');
$worksheet->setCellValueByColumnAndRow($col, 5, 'VAL_' . $fieldIndex);
$col++;
}
return $worksheet;
}
test('a fejlécek alapján tetszőleges oszlopsorrend helyesen leképeződik', function () {
$expected = PriceListService::EXPECTED_HEADERS;
// Fordított oszlopsorrend
$order = array_reverse(array_keys($expected));
$worksheet = makeWorksheetWithHeaderOrder($order);
$result = invokeBuildColumnMap($worksheet);
expect($result['errors'])->toBe([]);
foreach ($expected as $fieldIndex => $label) {
$mappedColumn = $result['map'][$fieldIndex] ?? null;
expect($mappedColumn)->not->toBeNull();
$value = $worksheet->getCellByColumnAndRow($mappedColumn, 5)->getValue();
expect($value)->toBe('VAL_' . $fieldIndex);
}
});
test('a fejléc-egyeztetés kis/nagybetű- és szeparátor-toleráns', function () {
$expected = PriceListService::EXPECTED_HEADERS;
$worksheet = (new Spreadsheet())->getActiveSheet();
$col = 1;
foreach ($expected as $fieldIndex => $label) {
// Kisbetűsítés + extra szóközök, hogy a normalizálást teszteljük
$worksheet->setCellValueByColumnAndRow($col, 3, ' ' . mb_strtolower($label) . ' ');
$col++;
}
$result = invokeBuildColumnMap($worksheet);
expect($result['errors'])->toBe([]);
expect($result['map'])->toHaveCount(count($expected));
});
test('hiányzó kötelező fejléc blokkoló hibát ad', function () {
$expected = PriceListService::EXPECTED_HEADERS;
$worksheet = (new Spreadsheet())->getActiveSheet();
$col = 1;
foreach ($expected as $fieldIndex => $label) {
if ($fieldIndex === 0) {
// A szállítói cikkszám fejlécét szándékosan kihagyjuk
continue;
}
$worksheet->setCellValueByColumnAndRow($col, 3, $label);
$col++;
}
$result = invokeBuildColumnMap($worksheet);
$hasBlocker = collect($result['errors'])->contains(fn ($error) => ($error['severity'] ?? '') === 'blocker');
expect($hasBlocker)->toBeTrue();
expect($result['map'])->not->toHaveKey(0);
});