diff --git a/app/Enums/PreProcessErrorCode.php b/app/Enums/PreProcessErrorCode.php index 300d027..4af580e 100644 --- a/app/Enums/PreProcessErrorCode.php +++ b/app/Enums/PreProcessErrorCode.php @@ -43,9 +43,8 @@ public function severity(): string self::NO_WORKSHEET, self::COLUMN_COUNT_MISMATCH, self::HEADER_MISMATCH, + self::COLUMN_MISSING, self::NO_DATA_ROWS => 'blocker', - - self::COLUMN_MISSING => 'error', self::COLUMN_EXTRA, self::FILE_TOO_LARGE => 'warning', }; diff --git a/app/Services/PriceListService.php b/app/Services/PriceListService.php index 4bef5b4..5baef5f 100644 --- a/app/Services/PriceListService.php +++ b/app/Services/PriceListService.php @@ -84,7 +84,7 @@ public function getExcelFieldPointer(): array } public const EXPECTED_HEADERS = [ - 0 => 'Szállítói cikkszám', + 0 => 'Szálltói cikkszám', 1 => 'Fő termékcsoport', 2 => 'Alcsoport 1', 3 => 'Alcsoport 2', @@ -92,7 +92,7 @@ public function getExcelFieldPointer(): array 5 => 'Kiszerelés', 6 => 'Súly/Űrtartalom (nettó)', 7 => 'Mértékegység', - 8 => 'Gyártó', + 8 => 'Gyártó/Forgalmazó', 9 => 'Legkisebb eladási egység', 10 => 'Egységszorzó', 11 => 'Mennyiségi egység', diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index fe7efd3..53a0d0b 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -174,6 +174,19 @@ public function updateStepStatus( private const MIN_FILE_SIZE_BYTES = 1024; // 1 KB 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 * @@ -238,7 +251,7 @@ public function preProcess(PricelistFile $pricelistFile): bool // 4. Statisztika gyűjtés (70-90%) $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) { $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, errors: array, warnings: array} + */ + private function buildColumnMap(Worksheet $worksheet): array { $errors = []; $warnings = []; + $map = []; - $expectedColumnCount = count($this->priceListService->getExcelFieldPointer()); $highestColumn = $worksheet->getHighestDataColumn(); $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; - $checkCount = min($columnCount, count($expectedHeaders)); - for ($col = 0; $col < $checkCount; $col++) { - $cellValue = trim((string) $worksheet->getCellByColumnAndRow($col + 1, $headerRow)->getValue()); - $expectedValue = $expectedHeaders[$col] ?? null; - - if ($expectedValue !== null && $cellValue !== $expectedValue) { - // Az oszlopnevek eltérése blocker hiba - $errors[] = $this->buildError( - PreProcessErrorCode::HEADER_MISMATCH, - "A(z) " . ($col + 1) . ". oszlop fejléce '{$cellValue}' az elvárt '{$expectedValue}' helyett (a {$headerRow}. sorban)." + // A 3. sor fejléceinek beolvasása: normalizált fejléc => oszlopszám (1-alapú) + $headerByName = []; + for ($col = 1; $col <= $columnCount; $col++) { + $raw = trim((string) $worksheet->getCellByColumnAndRow($col, self::HEADER_ROW)->getValue()); + if ($raw === '') { + continue; + } + $normalized = $this->normalizeHeader($raw); + // 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, errors: array, warnings: array} + */ + private function validateColumnStructure(Worksheet $worksheet): array + { + return $this->buildColumnMap($worksheet); } /** * Statisztikai adatok gyűjtése a munkalapról + * + * @param array $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(); $highestColumn = $worksheet->getHighestDataColumn(); $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; $emptyRows = 0; - // A 4. sort fejlécnek tekintjük, az 5. sortól indul az adat - for ($row = 5; $row <= $highestRow; $row++) { - $supplierProductNumber = $worksheet->getCellByColumnAndRow(1, $row)->getValue(); + // A 3. sor a fejléc, a 4. sort figyelmen kívül hagyjuk, az adat az 5. sortól indul + for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) { + $supplierProductNumber = $worksheet->getCellByColumnAndRow($skuColumn, $row)->getValue(); if (!empty(trim((string) $supplierProductNumber))) { $dataRows++; } else { @@ -385,7 +447,7 @@ private function collectStatistics(Worksheet $worksheet): array } 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, 'empty_rows' => $emptyRows, 'column_count' => $columnCount, @@ -484,21 +546,31 @@ public function validate(PricelistFile $pricelistFile): bool $highestRow = $worksheet->getHighestDataRow(); $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; $dataToInsert = []; $processedCount = 0; - $totalDataRows = $highestRow >= 5 ? ($highestRow - 4) : 0; + $totalDataRows = $highestRow >= self::DATA_START_ROW ? ($highestRow - (self::DATA_START_ROW - 1)) : 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 = []; $hasData = false; 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(); // Ha képlet, próbáljuk kiszámolni a megjelenített értéket diff --git a/database/migrations/2026_07_20_171642_restore_missing_id_auto_increment.php b/database/migrations/2026_07_20_171642_restore_missing_id_auto_increment.php new file mode 100644 index 0000000..3b1712c --- /dev/null +++ b/database/migrations/2026_07_20_171642_restore_missing_id_auto_increment.php @@ -0,0 +1,54 @@ +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 + */ + 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%'] + ); + } +}; diff --git a/tests/Feature/PricelistColumnMappingTest.php b/tests/Feature/PricelistColumnMappingTest.php new file mode 100644 index 0000000..3862597 --- /dev/null +++ b/tests/Feature/PricelistColumnMappingTest.php @@ -0,0 +1,103 @@ +, errors: array, warnings: 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 $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); +});