[ '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']; /** * 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); 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]; } /** * Oszlopstruktúra és fejléc validálása */ private function validateColumnStructure(Worksheet $worksheet): array { $errors = []; $warnings = []; $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)." ); } } return ['errors' => $errors, 'warnings' => $warnings]; } /** * Statisztikai adatok gyűjtése a munkalapról */ private function collectStatistics(Worksheet $worksheet): array { $highestRow = $worksheet->getHighestDataRow(); $highestColumn = $worksheet->getHighestDataColumn(); $columnCount = Coordinate::columnIndexFromString($highestColumn); $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(); if (!empty(trim((string) $supplierProductNumber))) { $dataRows++; } else { $emptyRows++; } } return [ 'total_rows' => max(0, $highestRow - 4), // 4 sor fejléc/leírás 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; $batchSize = 50; $dataToInsert = []; $processedCount = 0; $totalDataRows = $highestRow >= 5 ? ($highestRow - 4) : 0; if ($totalDataRows === 0) { throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort az 5. sortól.'); } for ($row = 5; $row <= $highestRow; $row++) { $rowData = []; $hasData = false; foreach ($expectedHeaders as $index => $label) { $cell = $worksheet->getCellByColumnAndRow($index + 1, $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); } $this->runBusinessValidation($pricelistFile); $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): 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) { $updates = []; foreach ($lines as $line) { $payload = $line->payload; $validationMessages = []; // 1. Kötelező mezők ellenőrzése (0-12 és 14) $mandatoryIndexes = [0, 1, 2, 3, 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] ]; } } } $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; } } }