EV3-357 Árlista feltöltés és feldolgzás újra gondolása phase3 fejlec adatok cseréje
This commit is contained in:
parent
5477b953a8
commit
e49b7515e0
@ -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',
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<int, int>, errors: array<int, array>, warnings: array<int, 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<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
|
||||
*
|
||||
* @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();
|
||||
$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
|
||||
|
||||
@ -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%']
|
||||
);
|
||||
}
|
||||
};
|
||||
103
tests/Feature/PricelistColumnMappingTest.php
Normal file
103
tests/Feature/PricelistColumnMappingTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user