d2d.emegrendeles.hu/app/Services/PricelistFileProcessService.php

1217 lines
49 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PreProcessErrorCode;
use App\Enums\PricelistWorkflowStep;
use App\Models\PricelistFile;
use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFileLine;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistUnitEnum;
use App\Enums\PricelistSellerUnitEnum;
use App\Enums\PricelistVatEnum;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class PricelistFileProcessService
{
protected const PRODUCT_FIELD_MAP = [
'name' => [
'header' => PriceListService::EXPECTED_HEADERS[4], // Termék megnevezése
'type' => 'string'
],
'unitValue' => [
'header' => PriceListService::EXPECTED_HEADERS[6], // Súly/Űrtartalom (nettó)
'type' => 'float'
],
'productUnit' => [
'header' => PriceListService::EXPECTED_HEADERS[7], // Mértékegység
'type' => 'string'
],
'sellerUnit' => [
'header' => PriceListService::EXPECTED_HEADERS[9], // Legkisebb eladási egység
'type' => 'string'
],
'unitMultiplier' => [
'header' => PriceListService::EXPECTED_HEADERS[10], // Egységszorzó
'type' => 'float'
],
'amountUnit' => [
'header' => PriceListService::EXPECTED_HEADERS[11], // Mennyiségi egység
'type' => 'string'
],
'vat' => [
'header' => PriceListService::EXPECTED_HEADERS[12], // ÁFA %
'type' => 'float'
],
'hooreycaId' => [
'header' => PriceListService::EXPECTED_HEADERS[16], // Fix Hooreyca ID
'type' => 'string'
],
'HooreycaUnit' => [
'header' => PriceListService::EXPECTED_HEADERS[17], // Hooreyca mértékegység
'type' => 'string'
],
'HooreycaMultiplier' => [
'header' => PriceListService::EXPECTED_HEADERS[18], // Hooreyca mennyiségi szorzó
'type' => 'float'
],
];
public function __construct(
protected PriceListService $priceListService
) {}
/**
* Alaphelyzetbe állítja a workflow-t és az állapotot az újraindításhoz
*/
public function resetWorkflow(PricelistFile $pricelistFile): void
{
$pricelistFile->update([
'workflow_steps' => PricelistWorkflowStep::defaultSteps(),
'status' => PricelistFileStatusEnum::todo,
'processing_current_step' => null,
'processing_current_step_percentage' => null,
'file_meta' => null,
]);
}
/**
* Elindítja az árlista feldolgozás kezdeti láncát (Pre-processing -> Validation)
*/
public function dispatchInitialChain(PricelistFile $pricelistFile): void
{
$this->resetWorkflow($pricelistFile);
Bus::chain([
PricelistWorkflowStep::Preprocessing->getJob($pricelistFile),
PricelistWorkflowStep::Validation->getJob($pricelistFile),
])->dispatch();
}
/**
* Elindítja a végrehajtási jobot (Approval után)
*/
public function dispatchExecutionJob(PricelistFile $pricelistFile): void
{
$job = PricelistWorkflowStep::Execution->getJob($pricelistFile);
if ($job) {
dispatch($job);
}
}
public function updateStepStatus(
PricelistFile $pricelistFile,
PricelistWorkflowStep $stepEnum,
string $status,
?string $message = null,
?int $percentage = 0
): void {
$effectiveMessage = $message ?? $stepEnum->label();
// Frissítjük a modellt az adatbázisból, hogy a legfrissebb workflow_steps-szel dolgozzunk
if ($pricelistFile->exists) {
$pricelistFile->refresh();
}
$steps = $pricelistFile->workflow_steps ?? [];
$found = false;
foreach ($steps as &$step) {
if ($step['name'] === $stepEnum->value) {
$step['status'] = $status;
$step['message'] = $effectiveMessage;
$found = true;
break;
}
}
unset($step);
// Ha valamiért nem találtuk meg a lépést (pl. régi rekord), adjuk hozzá a listához
if (!$found) {
$steps[] = [
'name' => $stepEnum->value,
'label' => $stepEnum->label(),
'status' => $status,
'message' => $effectiveMessage,
];
}
$updateData = [
'workflow_steps' => $steps,
'processing_current_step' => mb_strcut(
($message && $message !== $stepEnum->label())
? $stepEnum->label() . ': ' . $message
: $effectiveMessage,
0,
255
),
'processing_current_step_percentage' => $percentage,
];
if ($status === 'inprogress') {
if ($stepEnum === PricelistWorkflowStep::Approval) {
$updateData['status'] = PricelistFileStatusEnum::waiting_for_approval;
$updateData['processing_current_step'] = null;
$updateData['processing_current_step_percentage'] = null;
} else {
$updateData['status'] = PricelistFileStatusEnum::inprogress;
}
} elseif ($status === 'failed') {
$updateData['status'] = PricelistFileStatusEnum::fail;
}
$pricelistFile->update($updateData);
}
private const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
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;
/**
* Opcionális (visszafelé kompatibilis) oszlopfejlécek indexei.
* Ha egy ilyen oszlop hiányzik a 3. sorból, az nem blokkoló hiba, csak figyelmeztetés,
* és a hozzá tartozó feldolgozás (pl. akciós jelölés) kimarad.
*/
private const OPTIONAL_HEADER_INDEXES = [
22, // Akció (specialOffer)
];
/**
* Előfeldolgozás (Pre-processing) - Strukturális és technikai ellenőrzés
*
* @param PricelistFile $pricelistFile
* @return bool
*/
public function preProcess(PricelistFile $pricelistFile): bool
{
$startTime = microtime(true);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Fájl ellenőrzése...', 10);
$meta = [
'completed_at' => null,
'duration_seconds' => null,
'file_info' => [],
'statistics' => [],
'errors' => [],
'warnings' => [],
'is_processable' => true,
];
try {
// 1. Fájl fizikai ellenőrzése (10-20%)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Fájl fizikai ellenőrzése...', 15);
$fileCheckResult = $this->checkFilePhysical($pricelistFile);
$meta['file_info'] = $fileCheckResult['info'];
$meta['errors'] = array_merge($meta['errors'], $fileCheckResult['errors']);
$meta['warnings'] = array_merge($meta['warnings'], $fileCheckResult['warnings']);
if ($this->hasBlockerError($meta['errors'])) {
$meta['is_processable'] = false;
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
return false;
}
// 2. Excel megnyitás és munkalap ellenőrzés (20-40%)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Munkalap ellenőrzése...', 30);
$fullPath = Storage::disk('public')->path($pricelistFile->filename);
try {
$spreadsheet = IOFactory::load($fullPath);
$worksheet = $spreadsheet->getActiveSheet();
$meta['file_info']['sheet_name'] = $worksheet->getTitle();
} catch (\Exception $e) {
$meta['errors'][] = $this->buildError(PreProcessErrorCode::FILE_CORRUPTED, $e->getMessage());
$meta['is_processable'] = false;
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
return false;
}
// 3. Oszlopstruktúra validálás (40-70%)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Oszlopstruktúra ellenőrzése...', 50);
$structureResult = $this->validateColumnStructure($worksheet);
$meta['errors'] = array_merge($meta['errors'], $structureResult['errors']);
$meta['warnings'] = array_merge($meta['warnings'], $structureResult['warnings']);
if ($this->hasBlockerError($meta['errors'])) {
$meta['is_processable'] = false;
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
return false;
}
// 4. Statisztika gyűjtés (70-90%)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Statisztikai adatok gyűjtése...', 75);
$meta['statistics'] = $this->collectStatistics($worksheet, $structureResult['map']);
if ($meta['statistics']['data_rows'] === 0) {
$meta['errors'][] = $this->buildError(PreProcessErrorCode::NO_DATA_ROWS);
$meta['is_processable'] = false;
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
return false;
}
// 5. Sikeres befejezés (100%)
$meta['completed_at'] = now()->toDateTimeString();
$meta['duration_seconds'] = round(microtime(true) - $startTime, 2);
$pricelistFile->update(['file_meta' => ['preprocess' => $meta]]);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'completed', 'Előfeldolgozás sikeres.', 100);
return true;
} catch (\Exception $e) {
Log::error('Pricelist pre-process error: ' . $e->getMessage());
$meta['errors'][] = [
'code' => 'UNEXPECTED_ERROR',
'message' => $e->getMessage(),
'severity' => 'blocker',
];
$meta['is_processable'] = false;
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
throw $e;
}
}
/**
* Fájl fizikai ellenőrzése (létezés, kiterjesztés, méret)
*/
private function checkFilePhysical(PricelistFile $pricelistFile): array
{
$errors = [];
$warnings = [];
$info = [
'original_filename' => $pricelistFile->filename,
'file_size_bytes' => null,
'file_size_human' => null,
'mime_type' => null,
];
$disk = Storage::disk('public');
if (!$disk->exists($pricelistFile->filename)) {
$errors[] = $this->buildError(PreProcessErrorCode::FILE_NOT_FOUND);
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
}
$fullPath = $disk->path($pricelistFile->filename);
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS)) {
$errors[] = $this->buildError(
PreProcessErrorCode::INVALID_EXTENSION,
"Kiterjesztés: '{$extension}', elfogadott: " . implode(', ', self::ALLOWED_EXTENSIONS)
);
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
}
$fileSize = $disk->size($pricelistFile->filename);
$info['file_size_bytes'] = $fileSize;
$info['file_size_human'] = $this->humanFileSize($fileSize);
$info['mime_type'] = $disk->mimeType($pricelistFile->filename);
if ($fileSize < self::MIN_FILE_SIZE_BYTES) {
$errors[] = $this->buildError(PreProcessErrorCode::FILE_EMPTY, "Fájlméret: {$info['file_size_human']}");
}
if ($fileSize > self::MAX_FILE_SIZE_BYTES) {
$warnings[] = $this->buildWarning(PreProcessErrorCode::FILE_TOO_LARGE, "Fájlméret: {$info['file_size_human']}");
}
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
}
/**
* 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 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 = [];
$highestColumn = $worksheet->getHighestDataColumn();
$columnCount = Coordinate::columnIndexFromString($highestColumn);
$expectedHeaders = PriceListService::EXPECTED_HEADERS;
// 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."
);
}
}
$usedColumns = [];
foreach ($expectedHeaders as $index => $expectedLabel) {
$normalizedExpected = $this->normalizeHeader($expectedLabel);
if (isset($headerByName[$normalizedExpected])) {
$map[$index] = $headerByName[$normalizedExpected];
$usedColumns[$headerByName[$normalizedExpected]] = true;
} elseif (in_array($index, self::OPTIONAL_HEADER_INDEXES, true)) {
// Opcionális oszlop hiánya nem blokkoló, csak figyelmeztetés (visszafelé kompatibilitás)
$warnings[] = $this->buildWarning(
PreProcessErrorCode::COLUMN_MISSING,
"Hiányzó opcionális oszlopfejléc: '{$expectedLabel}' (a " . self::HEADER_ROW . ". sorban nem található); a feldolgozás e nélkül folytatódik."
);
} 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 $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 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 {
$emptyRows++;
}
}
return [
'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,
'expected_column_count' => count($this->priceListService->getExcelFieldPointer()),
];
}
/**
* Hiba rekord összeállítása
*/
private function buildError(PreProcessErrorCode $code, ?string $customMessage = null): array
{
return [
'code' => $code->value,
'message' => $customMessage ?? $code->label(),
'severity' => $code->severity(),
];
}
/**
* Figyelmeztetés rekord összeállítása
*/
private function buildWarning(PreProcessErrorCode $code, ?string $customMessage = null, array $extra = []): array
{
return array_merge([
'code' => $code->value,
'message' => $customMessage ?? $code->label(),
'severity' => $code->severity(),
], $extra);
}
/**
* Blocker típusú hiba keresése a hibalistában
*/
private function hasBlockerError(array $errors): bool
{
foreach ($errors as $error) {
if (($error['severity'] ?? '') === 'blocker') {
return true;
}
}
return false;
}
/**
* Meta adatok mentése és a workflow lépés hibásra állítása
*/
private function saveMetaAndFail(PricelistFile $pricelistFile, array $meta, float $startTime): void
{
$meta['completed_at'] = now()->toDateTimeString();
$meta['duration_seconds'] = round(microtime(true) - $startTime, 2);
$pricelistFile->update(['file_meta' => ['preprocess' => $meta]]);
$firstBlocker = collect($meta['errors'])->firstWhere('severity', 'blocker');
$failMessage = $firstBlocker['message'] ?? 'Előfeldolgozás sikertelen.';
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'failed', $failMessage);
}
/**
* Fájlméret olvasható formátumban
*/
private function humanFileSize(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
$size = (float) $bytes;
while ($size >= 1024 && $i < count($units) - 1) {
$size /= 1024;
$i++;
}
return round($size, 1) . ' ' . $units[$i];
}
/**
* Validálás (Validation) - Üzleti szabályok ellenőrzése és sorok rögzítése
*
* @param PricelistFile $pricelistFile
* @return bool
*/
public function validate(PricelistFile $pricelistFile): bool
{
$startTime = microtime(true);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Sorok beolvasása és mentése...', 0);
try {
// Korábbi sorok törlése az újraindíthatóság érdekében
$pricelistFile->lines()->delete();
$fullPath = Storage::disk('public')->path($pricelistFile->filename);
$spreadsheet = IOFactory::load($fullPath);
$worksheet = $spreadsheet->getActiveSheet();
$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 >= self::DATA_START_ROW ? ($highestRow - (self::DATA_START_ROW - 1)) : 0;
if ($totalDataRows === 0) {
throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort a ' . self::DATA_START_ROW . '. sortól.');
}
for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) {
$rowData = [];
$hasData = false;
foreach ($expectedHeaders as $index => $label) {
// 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
if (str_starts_with((string)$value, "=")) {
try {
// Megpróbáljuk kiszámítani a képletet
$value = $cell->getCalculatedValue();
} catch (\Exception $e) {
try {
// Ha a számítás nem sikerül (pl. hiányzó külső hivatkozás),
// próbáljuk a fájlban utoljára tárolt értéket (cache) lekérni
$value = $cell->getOldCalculatedValue();
} catch (\Exception $e2) {
// Ha nincs korábbi érték sem, marad a képlet szövege fallback-ként
\Illuminate\Support\Facades\Log::warning("Képlet számítási hiba a(z) {$row}. sorban: " . $e->getMessage());
}
}
}
$rowData[$label] = $value;
if (!empty(trim((string)$value))) {
$hasData = true;
}
}
// Csak akkor mentjük, ha a sor nem teljesen üres
if ($hasData) {
$dataToInsert[] = [
'pricelist_file_id' => $pricelistFile->id,
'row_number' => $row,
'status' => PricelistFileLineStatusEnum::ok->value,
'payload' => json_encode($rowData),
'created_at' => now(),
'updated_at' => now(),
// A BaseAuditable trait miatt ezeket is érdemes lehet kitölteni,
// de a tömeges beszúrásnál (insert) nem futnak le a boot események.
'created_by' => auth()->id() ?? 1,
];
}
$processedCount++;
if (count($dataToInsert) >= $batchSize) {
PricelistFileLine::insert($dataToInsert);
$dataToInsert = [];
$percentage = round(($processedCount / $totalDataRows) * 100);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', "Sorok mentése ({$processedCount}/{$totalDataRows})...", $percentage);
}
}
if (!empty($dataToInsert)) {
PricelistFileLine::insert($dataToInsert);
}
// Az "Akció" (specialOffer) opcionális oszlop: csak akkor kezeljük, ha ténylegesen szerepel a fájlban
$hasSpecialOfferColumn = isset($columnMap[22]);
$this->runBusinessValidation($pricelistFile, $hasSpecialOfferColumn);
$duration = round(microtime(true) - $startTime, 2);
// Ellenőrizzük, vannak-e hibás sorok
$hasErrors = $pricelistFile->lines()->where('status', PricelistFileLineStatusEnum::error)->exists();
if ($hasErrors) {
$this->updateStepStatus(
$pricelistFile,
PricelistWorkflowStep::Validation,
'failed',
"befejeződött, de hibás sorokat találtunk. Kérjük, javítsa a hibákat a továbblépéshez!",
100
);
return false;
}
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'completed', "Sikeres validálás ({$processedCount} sor, {$duration} mp).", 100);
// A validálás végén átadjuk a lépést a jóváhagyásnak (Approval)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'inprogress');
return true;
} catch (\Exception $e) {
Log::error('Pricelist validation error: ' . $e->getMessage());
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'failed', $e->getMessage());
throw $e;
}
}
/**
* Szöveg normalizálása az összehasonlításhoz (mb_strtoupper, trim, speciális karakterek kezelése)
*/
protected function normalizeForComparison(string $text): string
{
// 1. Kis/Nagybetű érzéketlenség UTF-8 támogatással
$text = mb_strtoupper(trim($text), 'UTF-8');
// 2. Szeparátorok egységesítése (perjel és alulvonás cseréje)
// A hibaüzenetben látható alulvonás és a felhasználó által említett perjel miatt
$text = str_replace(['/', '_'], ' ', $text);
// 3. Felesleges szóközök eltávolítása (ha a csere után több szóköz maradt)
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
/**
* Termékcsoport lookup tábla felépítése az adatbázisból (hierarchia útvonala -> id)
*/
protected function getProductGroupLookupMap(): array
{
$allGroups = (new \App\Models\ProductGroup)->allWithPath(3);
$groupLookup = [];
foreach ($allGroups as $group) {
$parentsNames = $group['parentsNames'] ?? [];
// Technikai gyökér elhagyása (0. szint, pl. "Főcsoportok")
if (isset($group['depth']) && $group['depth'] > 0 && !empty($parentsNames)) {
array_shift($parentsNames);
}
$pathParts = array_merge($parentsNames, [$group['name']]);
$normalizedParts = array_map(fn($v) => $this->normalizeForComparison((string)$v), $pathParts);
$pathKey = implode('|', $normalizedParts);
$groupLookup[$pathKey] = $group['id'];
}
return $groupLookup;
}
/**
* Gyártó lookup tábla felépítése (név -> id)
*/
protected function getProducerLookupMap(): array
{
return \App\Models\Producer::all()
->keyBy(fn($p) => $this->normalizeForComparison($p->name))
->map->id
->toArray();
}
/**
* Gyártó ID -> név mapping
*/
protected function getProducerIdMap(): array
{
return \App\Models\Producer::pluck('name', 'id')->toArray();
}
/**
* Termékcsoport ID -> útvonal mapping
*/
protected function getProductGroupIdMap(): array
{
$allGroups = (new \App\Models\ProductGroup)->allWithPath();
$idMap = [];
foreach ($allGroups as $group) {
$parentsNames = $group['parentsNames'] ?? [];
if (isset($group['depth']) && $group['depth'] > 0 && !empty($parentsNames)) {
array_shift($parentsNames);
}
$pathParts = array_merge($parentsNames, [$group['name']]);
$idMap[$group['id']] = implode(' > ', $pathParts);
}
return $idMap;
}
/**
* Termék lookup tábla felépítése a beszállítóhoz (cikkszám -> id)
*/
protected function getProductLookupMap(int $supplierId): array
{
return \App\Models\Product::where('supplier_id', $supplierId)
->whereNotNull('supplierProductNumber')
->get()
->keyBy('supplierProductNumber')
->toArray();
}
/**
* Termékcsoport útvonal összeállítása a payload-ból
*/
protected function getRowPathFromPayload(array $payload): string
{
$pathComponents = [
$payload[PriceListService::EXPECTED_HEADERS[1]] ?? null,
$payload[PriceListService::EXPECTED_HEADERS[2]] ?? null,
$payload[PriceListService::EXPECTED_HEADERS[3]] ?? null
];
return collect($pathComponents)
->filter(fn($v) => !empty(trim((string)$v)))
->map(fn($v) => $this->normalizeForComparison((string)$v))
->implode('|');
}
/**
* Termékcsoport validáció egy sorra
*/
protected function validateProductGroup(string $rowPath, array $groupLookup): ?string
{
if (empty($rowPath)) {
return "Hiányzó termékcsoport adatok.";
}
if (!isset($groupLookup[$rowPath])) {
return "Ismeretlen termékcsoport útvonal: $rowPath";
}
return null;
}
/**
* Mértékegység validáció egy sorra
*/
protected function validateUnit(array $payload): ?string
{
$unitValue = $payload[PriceListService::EXPECTED_HEADERS[7]] ?? null;
if (empty(trim((string)$unitValue))) {
return "Hiányzó mértékegység.";
}
$normalizedUnit = mb_strtolower(trim((string)$unitValue));
if (!PricelistUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen mértékegység: $unitValue. Elfogadott: " . implode(', ', PricelistUnitEnum::values());
}
return null;
}
/**
* Legkisebb eladási egység validáció egy sorra
*/
protected function validateSellerUnit(array $payload): ?string
{
$sellerUnitValue = $payload[PriceListService::EXPECTED_HEADERS[9]] ?? null;
if (empty(trim((string)$sellerUnitValue))) {
return "Hiányzó legkisebb eladási egység.";
}
$normalizedUnit = mb_strtolower(trim((string)$sellerUnitValue));
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen legkisebb eladási egység: $sellerUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
}
return null;
}
/**
* Mennyiségi egység validáció egy sorra
*/
protected function validateAmountUnit(array $payload): ?string
{
$amountUnitValue = $payload[PriceListService::EXPECTED_HEADERS[11]] ?? null;
if (empty(trim((string)$amountUnitValue))) {
return "Hiányzó mennyiségi egység.";
}
$normalizedUnit = mb_strtolower(trim((string)$amountUnitValue));
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen mennyiségi egység: $amountUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
}
return null;
}
/**
* ÁFA % validáció egy sorra
*/
protected function validateVat(array $payload): ?string
{
$vatValue = $payload[PriceListService::EXPECTED_HEADERS[12]] ?? null;
if ($vatValue === null || (is_string($vatValue) && trim($vatValue) === '')) {
return "Hiányzó ÁFA %.";
}
// Kinyerjük a numerikus értéket, kezelve a százalékjelet, szóközöket és a tizedesvesszőt
$numericValue = (float) str_replace(['%', ' ', ','], ['', '', '.'], (string)$vatValue);
// Ha az érték 0 és 1 közé esik (pl. 0.05, 0.27), akkor valószínűleg Excel százalék formátum
// Ebben az esetben megszorozzuk 100-zal, hogy megkapjuk az egész értéket (pl. 5, 27)
if ($numericValue > 0 && $numericValue <= 1) {
$numericValue *= 100;
}
$finalVat = (int) round($numericValue);
if (!PricelistVatEnum::tryFrom($finalVat)) {
return "Érvénytelen ÁFA %: $vatValue. Elfogadott: " . implode(', ', PricelistVatEnum::labels());
}
return null;
}
/**
* Gyártó validáció egy sorra
*/
protected function validateProducer(array $payload, array $producerLookup): ?string
{
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
if (empty(trim((string)$producerName))) {
return "Hiányzó gyártó megnevezés.";
}
$normalizedName = $this->normalizeForComparison((string)$producerName);
if (!isset($producerLookup[$normalizedName])) {
return "NEW_PRODUCER";
}
return null;
}
/**
* Üzleti validáció futtatása a mentett sorokon (Batch feldolgozás)
*
* @param PricelistFile $pricelistFile
*/
protected function runBusinessValidation(PricelistFile $pricelistFile, bool $hasSpecialOfferColumn = false): void
{
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti validálás...', 100);
$groupLookup = $this->getProductGroupLookupMap();
$producerLookup = $this->getProducerLookupMap();
$productLookup = $this->getProductLookupMap($pricelistFile->supplier_id);
$producerIdToName = $this->getProducerIdMap();
$groupIdToPath = $this->getProductGroupIdMap();
$pricelistFile->lines()->chunk(1000, function ($lines) use ($groupLookup, $producerLookup, $productLookup, $producerIdToName, $groupIdToPath, $hasSpecialOfferColumn) {
$updates = [];
foreach ($lines as $line) {
$payload = $line->payload;
$validationMessages = [];
// 1. Kötelező mezők ellenőrzése (0-2, 4-12 és 14)
// Megjegyzés: az "Alcsoport 2" (3. index) NEM kötelező, mivel vannak
// olyan termékek, amelyek csak az "Alcsoport 1" szinthez vannak rendelve.
$mandatoryIndexes = [0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14];
foreach ($mandatoryIndexes as $mIndex) {
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
$mValue = $payload[$mLabel] ?? null;
if ($mValue === null || (is_string($mValue) && trim($mValue) === '')) {
// Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák
$mKey = match($mIndex) {
7 => 'product_unit',
8 => 'producer',
9 => 'seller_unit',
11 => 'amount_unit',
12 => 'vat',
default => 'mandatory_' . $mIndex
};
$validationMessages[$mKey] = "Hiányzó kötelező mező: $mLabel";
}
}
// 2. Hooreyca specifikus mezők függősége (16, 17, 18, 19)
$hooreycaIndexes = [16, 17, 18, 19];
$hasAnyHooreyca = false;
$hooreycaData = [];
foreach ($hooreycaIndexes as $hIndex) {
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
$hValue = $payload[$hLabel] ?? null;
if ($hValue !== null && !(is_string($hValue) && trim($hValue) === '')) {
$hasAnyHooreyca = true;
$hooreycaData[$hIndex] = $hValue;
} else {
$hooreycaData[$hIndex] = null;
}
}
if ($hasAnyHooreyca) {
foreach ($hooreycaIndexes as $hIndex) {
if ($hooreycaData[$hIndex] === null) {
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
$validationMessages['hooreyca_' . $hIndex] = "Hiányzó kötelező mező (Hooreyca adatok jelenléte miatt): $hLabel";
}
}
}
// Termék ID kinyerése (cikkszám alapján)
$sku = (string)($payload[PriceListService::EXPECTED_HEADERS[0]] ?? '');
$existingProduct = $productLookup[$sku] ?? null;
$productId = $existingProduct['id'] ?? null;
// Termékcsoport útvonal és ID kinyerése
$rowPath = $this->getRowPathFromPayload($payload);
$groupId = $groupLookup[$rowPath] ?? null;
// Gyártó ID kinyerése
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
$producerId = $producerLookup[$this->normalizeForComparison((string)$producerName)] ?? null;
// Különálló validációk meghívása
if ($groupError = $this->validateProductGroup($rowPath, $groupLookup)) {
$validationMessages['product_group'] = $groupError;
}
$hasNewProducer = false;
if ($producerError = $this->validateProducer($payload, $producerLookup)) {
if ($producerError === 'NEW_PRODUCER') {
$validationMessages['producer'] = "Új gyártó: " . ($payload[PriceListService::EXPECTED_HEADERS[8]] ?? '');
$hasNewProducer = true;
} else {
$validationMessages['producer'] = $producerError;
}
}
if ($unitError = $this->validateUnit($payload)) {
$validationMessages['product_unit'] = $unitError;
}
if ($sellerUnitError = $this->validateSellerUnit($payload)) {
$validationMessages['seller_unit'] = $sellerUnitError;
}
if ($amountUnitError = $this->validateAmountUnit($payload)) {
$validationMessages['amount_unit'] = $amountUnitError;
}
if ($vatError = $this->validateVat($payload)) {
$validationMessages['vat'] = $vatError;
}
// Státusz meghatározása
$hasError = false;
foreach ($validationMessages as $key => $msg) {
if (str_contains($msg, 'Hiányzó') || str_contains($msg, 'Ismeretlen') || str_contains($msg, 'Érvénytelen')) {
$hasError = true;
break;
}
}
$status = PricelistFileLineStatusEnum::ok;
$diff = [];
if ($hasError) {
$status = PricelistFileLineStatusEnum::error;
} elseif ($productId === null) {
// Ha nincs meglévő termék -> Új termék
$status = PricelistFileLineStatusEnum::new_product;
} else {
// Meglévő termék esetén nézzük a módosulásokat
$isUpdated = false;
// 1. Gyártó módosulás
$oldProducerId = (int)($existingProduct['producer_id'] ?? 0);
if ($hasNewProducer) {
$isUpdated = true;
$diff['producer_id'] = [
'old' => $oldProducerId,
'new' => $producerId,
'old_label' => $producerIdToName[$oldProducerId] ?? ($oldProducerId > 0 ? "Ismeretlen gyártó ($oldProducerId)" : 'N/A'),
'new_label' => $producerName,
'label' => PriceListService::EXPECTED_HEADERS[8]
];
} elseif ($producerId !== null && $oldProducerId !== (int)$producerId) {
$isUpdated = true;
$diff['producer_id'] = [
'old' => $oldProducerId,
'new' => (int)$producerId,
'old_label' => $producerIdToName[$oldProducerId] ?? ($oldProducerId > 0 ? "Ismeretlen gyártó ($oldProducerId)" : 'N/A'),
'new_label' => $producerIdToName[(int)$producerId] ?? ($producerId > 0 ? "Ismeretlen gyártó ($producerId)" : 'N/A'),
'label' => PriceListService::EXPECTED_HEADERS[8]
];
}
// 2. Termékcsoport módosulás
$oldGroupId = (int)($existingProduct['product_group_id'] ?? 0);
if ($groupId !== null && $oldGroupId !== (int)$groupId) {
$isUpdated = true;
$oldPath = $groupIdToPath[$oldGroupId] ?? null;
$newPath = $groupIdToPath[(int)$groupId] ?? null;
$diff['product_group_id'] = [
'old' => $oldGroupId,
'new' => (int)$groupId,
'old_label' => $oldPath ?? ($oldGroupId > 0 ? "Ismeretlen csoport ($oldGroupId)" : 'N/A'),
'new_label' => $newPath ?? ($groupId > 0 ? "Ismeretlen csoport ($groupId)" : 'N/A'),
'label' => PriceListService::EXPECTED_HEADERS[1]
];
}
// 3. Általános mezők összehasonlítása
foreach (self::PRODUCT_FIELD_MAP as $modelField => $config) {
$excelValue = $payload[$config['header']] ?? null;
$currentValue = $existingProduct[$modelField] ?? null;
// Típuskonverzió és normalizálás az összehasonlításhoz
if ($config['type'] === 'float') {
$excelValue = is_string($excelValue)
? (float)str_replace([' ', ','], ['', '.'], $excelValue)
: (float)$excelValue;
// ÁFA speciális kezelése: 0.05 -> 5
if ($modelField === 'vat') {
if ($excelValue > 0 && $excelValue <= 1) {
$excelValue *= 100;
}
$excelValue = (float) round($excelValue);
}
$currentValue = (float)$currentValue;
}
if ($excelValue != $currentValue) {
$diff[$modelField] = [
'old' => $currentValue,
'new' => $excelValue,
'label' => $config['header']
];
$isUpdated = true;
}
}
if ($isUpdated) {
$status = PricelistFileLineStatusEnum::updated;
}
// 4. Ár módosulás (mindig bekerül a diff-be ha van változás, de nem feltétlenül váltja ki az 'updated' státuszt, ha az árat külön kezeljük)
// Megjegyzés: Az áráltozás önmagában is lehet 'updated', de sokszor az árlista import lényege az árváltozás.
$oldPriceExcel = $payload[PriceListService::EXPECTED_HEADERS[13]] ?? null;
$newPriceExcel = $payload[PriceListService::EXPECTED_HEADERS[14]] ?? null;
if ($oldPriceExcel !== null && $newPriceExcel !== null) {
// Szóközök és vesszők egységes kezelése string típus esetén
$oldPrice = (float)str_replace([' ', ','], ['', '.'], (string)$oldPriceExcel);
$newPrice = (float)str_replace([' ', ','], ['', '.'], (string)$newPriceExcel);
if ($oldPrice != $newPrice) {
$diff['price'] = [
'old' => $oldPrice,
'new' => $newPrice,
'label' => PriceListService::EXPECTED_HEADERS[14]
];
}
}
// 5. Akciós (specialOffer) módosulás - csak ha az "Akció" oszlop szerepel a fájlban.
// booleanCustom logika: üres érték => false, bármilyen más érték => true.
if ($hasSpecialOfferColumn) {
$specialOfferRaw = $payload[PriceListService::EXPECTED_HEADERS[22]] ?? null;
$newSpecialOffer = strlen(trim((string)$specialOfferRaw)) > 0;
$oldSpecialOffer = (bool)($existingProduct['specialOffer'] ?? false);
if ($newSpecialOffer !== $oldSpecialOffer) {
$isUpdated = true;
$diff['specialOffer'] = [
'old' => (int)$oldSpecialOffer,
'new' => (int)$newSpecialOffer,
'old_label' => $oldSpecialOffer ? 'Akciós' : 'Nem akciós',
'new_label' => $newSpecialOffer ? 'Akciós' : 'Nem akciós',
'label' => PriceListService::EXPECTED_HEADERS[22],
];
}
}
}
$updates[] = [
'id' => $line->id,
'pricelist_file_id' => $line->pricelist_file_id,
'row_number' => $line->row_number,
'status' => $status->value,
'product_id' => $productId,
'product_group_id' => $groupId,
'producer_id' => $producerId,
'payload' => json_encode($line->payload),
'validation_messages' => json_encode($validationMessages),
'diff' => json_encode($diff),
'created_at' => $line->created_at->toDateTimeString(),
'updated_at' => now()->toDateTimeString(),
];
}
// Kötegelt frissítés (upsert) a teljesítmény optimalizálásához
if (!empty($updates)) {
PricelistFileLine::upsert(
$updates,
['id'], // Egyedi azonosító
['status', 'validation_messages', 'diff', 'product_id', 'product_group_id', 'producer_id', 'updated_at'] // Frissítendő mezők
);
}
});
}
/**
* Végrehajtás (Execution) - Módosítások tényleges alkalmazása tranzakcióban
*
* @param PricelistFile $pricelistFile
* @return bool
*/
public function execute(PricelistFile $pricelistFile): bool
{
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Árlista frissítése...', 0);
DB::beginTransaction();
try {
// TODO: Tényleges importálás végrehajtása
// $this->priceListService->importPriceList(...);
sleep(1); // Szimuláció
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100);
DB::commit();
$pricelistFile->update([
'status' => PricelistFileStatusEnum::done,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Pricelist execution error: ' . $e->getMessage());
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage());
return false;
}
}
}