[ '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' ], ]; /** * A végrehajtás mezőleképezése: Excel oszlop indexe -> `products` tábla mezője. * * Bővebb, mint a PRODUCT_FIELD_MAP: az csak a diff-számításhoz kell, tehát azokat a * mezőket tartalmazza, amelyek változását ki akarjuk mutatni. Importáláskor viszont * olyan mezőket is írunk, amelyeket nem hasonlítunk össze (cikkszám, kiszerelés, * vevői megnevezés, megjegyzés, KREL, akció) - a legacy importtal azonos körben. */ protected const EXECUTION_FIELD_MAP = [ 'supplierProductNumber' => ['index' => 0, 'type' => 'string'], 'name' => ['index' => 4, 'type' => 'string'], 'packing' => ['index' => 5, 'type' => 'int'], 'unitValue' => ['index' => 6, 'type' => 'float'], 'productUnit' => ['index' => 7, 'type' => 'string'], 'sellerUnit' => ['index' => 9, 'type' => 'string'], 'unitMultiplier' => ['index' => 10, 'type' => 'float'], 'amountUnit' => ['index' => 11, 'type' => 'string'], 'vat' => ['index' => 12, 'type' => 'float'], 'hooreycaId' => ['index' => 16, 'type' => 'string'], 'HooreycaUnit' => ['index' => 17, 'type' => 'string'], 'HooreycaMultiplier' => ['index' => 18, 'type' => 'float'], 'buyerProductName' => ['index' => 19, 'type' => 'string'], 'note' => ['index' => 20, 'type' => 'string'], 'krel' => ['index' => 21, 'type' => 'bool'], 'specialOffer' => ['index' => 22, 'type' => 'bool'], ]; /** * Chunk méretek a végrehajtáshoz. A termékírás soronként több query-t jelent, * ezért kisebb köteg; az árak kötegelt insertje elbír nagyobbat. */ private const EXECUTION_CHUNK_SIZE = 500; private const PRICE_CHUNK_SIZE = 1000; /** * A visszaállításból kihagyott sorok részleteiből ennyit tárolunk a file_meta-ban. * A teljes darabszám a `skipped_count`-ban akkor is megmarad, ha ez levág. */ private const REVERT_SKIPPED_DETAIL_LIMIT = 100; 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); } } /** * Jóváhagyás: lezárja a manuális Jóváhagyás lépést és elindítja a végrehajtást. * * @return bool false, ha a fájl közben már nem jóváhagyható állapotba került */ public function approve(PricelistFile $pricelistFile): bool { // Újraellenőrzés a friss rekordon: a felület 5 mp-enként pollozik, tehát a // gomb megjelenítése és a kattintás között változhatott az állapot. $pricelistFile->refresh(); if (! $pricelistFile->canBeApproved()) { return false; } // Egy beszállítóhoz egyszerre csak egy nyitott végrehajtás tartozhat: két // párhuzamos import ugyanarra a termékhalmazra nem determinisztikus eredményt // adna, egy befejezetlen (execution_failed) végrehajtásra pedig félig frissített // állapotra rétegződne rá az új. if (app(PricelistGuard::class)->isBlocked($pricelistFile->supplier_id, $pricelistFile->id)) { return false; } $meta = $pricelistFile->file_meta ?? []; $meta['approval'] = [ 'decision' => 'approved', 'user_id' => auth()->id(), 'at' => now()->toDateTimeString(), ]; $pricelistFile->update(['file_meta' => $meta]); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'completed', 'Jóváhagyva.', 100); // A státuszt MÉG A DISPATCH ELŐTT billentjük át (az Execution lépés // 'inprogress'-re állítása a fájlt is inprogress-re teszi). Ha ezt a jobra // bíznánk, a queue-latency alatt a fájl waiting_for_approval maradna, a // Jóváhagyás gomb továbbra is látszana, és egy második kattintás párhuzamos // végrehajtást indítana ugyanarra a termékhalmazra. $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Végrehajtás előkészítése...', 0); $this->dispatchExecutionJob($pricelistFile); return true; } /** * Elutasítás: a fájl lezárva (`closed`) státuszba kerül, végrehajtás nem indul. * * @return bool false, ha a fájl közben már nem elutasítható állapotba került */ public function reject(PricelistFile $pricelistFile, ?string $reason = null): bool { $pricelistFile->refresh(); if (! $pricelistFile->canBeApproved()) { return false; } $this->updateStepStatus( $pricelistFile, PricelistWorkflowStep::Approval, 'rejected', $reason ? 'Elutasítva: ' . $reason : 'Elutasítva.', ); $meta = $pricelistFile->file_meta ?? []; $meta['approval'] = [ 'decision' => 'rejected', 'reason' => $reason, 'user_id' => auth()->id(), 'at' => now()->toDateTimeString(), ]; // A 'rejected' lépésstátusz szándékosan nem 'failed': az updateStepStatus a // 'failed'-et fail fájlstátuszra fordítaná, az elutasítás viszont nem hiba, // hanem szabályos lezárás. $pricelistFile->update([ 'status' => PricelistFileStatusEnum::closed, 'file_meta' => $meta, 'processing_current_step' => null, 'processing_current_step_percentage' => null, ]); return true; } 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') { // A végrehajtás hibája külön státuszt kap: a `fail` a validálásig tartó // szakaszt jelenti, ahonnan a fájl újratöltése biztonságosan újraindítja a // láncot - a végrehajtásnál viszont már történhettek termék- és árírások, // ott csak a Folytatás vagy a Visszavonás megengedett. $updateData['status'] = $stepEnum === PricelistWorkflowStep::Execution ? PricelistFileStatusEnum::execution_failed : 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, errors: array, warnings: 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, 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 $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] = $this->normalizeCellValue($value); if (!empty(trim((string)$rowData[$label]))) { $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 { // A szabály a NameNormalizerben él, mert a legacy import és a gyártó-összevonó // karbantartás is UGYANEZT az egyeztetést kell hogy használja - ha ezek // elcsúsznak egymástól, az újra duplikált törzsadatot eredményez. return NameNormalizer::normalize($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 { // Az összevont (archive) és a törölt gyártók kimaradnak: enélkül a // duplikáció-takarítás hatástalan lenne, mert a beolvasztott rekord // továbbra is visszakerülne a feloldásba. return \App\Models\Producer::query() ->whereNotIn('status', [DbStatusFieldEnum::archive, DbStatusFieldEnum::deleted]) ->get() ->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 ($this->isBlankPayloadValue($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 (! $this->isBlankPayloadValue($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->reportExecutionProgress($pricelistFile, 0, 'Végrehajtás indítása...'); try { // Szándékosan NINCS egyetlen, mindent átfogó tranzakció: több ezer sornál a // termék-insert + pivot-insert + visibility update percekig tartana lockokat, // és egy timeout minden visszajelzés nélkül dobna el mindent. Helyette // fázisonként/chunkonként tranzakciózunk, az "atomicitást" pedig az adja, hogy // az árlista E6-ig `draft` marad - a felhasználói felület és a statisztika // csak az `active` árlistákat nézi, tehát félbeszakadás esetén sem kerül ki // félkész ár. A megszakadt futás a kurzoroktól folytatható (idempotens). $priceList = $this->prepareExecution($pricelistFile); $this->createMissingProducers($pricelistFile); $this->createNewProducts($pricelistFile); $this->updateExistingProducts($pricelistFile); $this->attachPrices($pricelistFile, $priceList); $this->finalizeExecution($pricelistFile, $priceList); return true; } catch (\Throwable $e) { Log::error('Pricelist execution error: ' . $e->getMessage(), [ 'pricelist_file_id' => $pricelistFile->id, 'exception' => $e, ]); // A lépés 'failed'-re állítása az updateStepStatus-on keresztül a fájlt // execution_failed státuszba teszi (lásd ott a leképezést). $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage()); return false; } } protected function reportExecutionProgress(PricelistFile $pricelistFile, int $percentage, string $message): void { $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', $message, $percentage); } /** * E1 - Előkészítés: az árlista fejrekord létrehozása (vagy újraindításkor a * korábbi továbbhasználata). */ protected function prepareExecution(PricelistFile $pricelistFile): PriceList { $this->reportExecutionProgress($pricelistFile, 2, 'Árlista rekord előkészítése...'); // Újraindításkor NEM hozunk létre új árlistát: a fájlra mentett price_list_id az // egész folytatás horgonya, enélkül minden újrafuttatás duplikálna. if ($pricelistFile->price_list_id && $priceList = PriceList::find($pricelistFile->price_list_id)) { return $priceList; } $priceList = PriceList::create([ 'supplier_id' => $pricelistFile->supplier_id, 'available' => $pricelistFile->available_date, 'note' => $pricelistFile->note, 'canSee' => 1, 'status' => DbStatusFieldEnum::draft, ]); $pricelistFile->update(['price_list_id' => $priceList->id]); return $priceList; } /** * E2 - Új gyártók létrehozása. A validálás az ismeretlen gyártójú sorokon * producer_id nélkül hagyta a rekordot ("új gyártó"), itt pótoljuk őket. * Kis halmaz, ezért egyetlen tranzakcióban fut. */ protected function createMissingProducers(PricelistFile $pricelistFile): void { $this->reportExecutionProgress($pricelistFile, 5, 'Új gyártók létrehozása...'); $lines = $pricelistFile->lines() ->whereNull('producer_id') ->where('status', '!=', PricelistFileLineStatusEnum::error->value) ->get(); if ($lines->isEmpty()) { return; } DB::transaction(function () use ($lines) { $createdByName = []; foreach ($lines as $line) { $producerName = trim((string) ($line->payload[PriceListService::EXPECTED_HEADERS[8]] ?? '')); if ($producerName === '') { continue; } // A fájlon belül ugyanaz a gyártó többféle írásmóddal is előfordulhat, // ezért normalizált kulccsal gyűjtjük, mint a validálás. $key = $this->normalizeForComparison($producerName); $createdByName[$key] ??= \App\Models\Producer::firstOrCreate( ['name' => $producerName], ['canSee' => 1, 'status' => DbStatusFieldEnum::active], )->id; $line->update(['producer_id' => $createdByName[$key]]); } }); } /** * E3 - Új termékek létrehozása. Idempotencia: a már létrehozott sorokon beáll a * product_id, a folytatás csak a product_id nélkülieket veszi. */ protected function createNewProducts(PricelistFile $pricelistFile): void { $this->reportExecutionProgress($pricelistFile, 10, 'Új termékek létrehozása...'); $query = $pricelistFile->lines() ->where('status', PricelistFileLineStatusEnum::new_product->value) ->whereNull('product_id'); $total = (clone $query)->count(); if ($total === 0) { return; } $groupTypes = $this->getProductGroupTypeMap(); $processed = 0; $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) { DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) { foreach ($lines as $line) { $product = \App\Models\Product::create(array_merge( $this->buildProductData($line, $pricelistFile, $groupTypes), ['canSee' => 1, 'status' => DbStatusFieldEnum::active], )); $line->update(['product_id' => $product->id]); } }); $processed += $lines->count(); $this->reportExecutionProgress( $pricelistFile, 10 + (int) floor(30 * $processed / $total), "Új termékek létrehozása ({$processed}/{$total})...", ); }); } /** * E4 - Meglévő termékek frissítése. Idempotencia és visszavonhatóság: a frissítés * ELŐTTI tényleges DB-állapot ugyanabban a tranzakcióban a sor applied_snapshot * mezőjébe kerül, és ennek megléte jelzi, hogy a sor már feldolgozott. */ protected function updateExistingProducts(PricelistFile $pricelistFile): void { $this->reportExecutionProgress($pricelistFile, 40, 'Termékadatok frissítése...'); $query = $pricelistFile->lines() ->where('status', PricelistFileLineStatusEnum::updated->value) ->whereNotNull('product_id') ->whereNull('applied_snapshot'); $total = (clone $query)->count(); if ($total === 0) { return; } $groupTypes = $this->getProductGroupTypeMap(); $processed = 0; $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) { DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) { foreach ($lines as $line) { $product = \App\Models\Product::find($line->product_id); if (! $product) { // A terméket a validálás óta törölték. A sort kivesszük a // további feldolgozásból (így árat sem kap), és megjelöljük, // hogy a felhasználó lássa, mi maradt ki. $line->addValidationError('product', 'A termék időközben megszűnt, a sor kimaradt a végrehajtásból.'); $line->product_id = null; $line->save(); continue; } $productData = $this->buildProductData($line, $pricelistFile, $groupTypes); // A snapshot a TÉNYLEGES, írás előtti állapot - szándékosan nem a // validáláskor számolt diff['old'], mert az a jóváhagyás pillanatában // készült, és egy időközbeni kézi módosítást írna felül a visszavonás. $snapshot = [ 'updated_at' => $product->updated_at?->toDateTimeString(), 'fields' => [], ]; foreach (array_keys($productData) as $field) { $snapshot['fields'][$field] = $product->getAttribute($field); } $product->update($productData); // A MI írásunk utáni updated_at: a visszavonás ehhez hasonlítja a // termék akkori állapotát, és eltérés esetén kihagyja a sort, mert az // azt jelenti, hogy azóta valaki kézzel módosította. (A snapshot // 'updated_at' mezője az írás ELŐTTI érték, arra ez a check nem jó.) $snapshot['applied_updated_at'] = $product->refresh()->updated_at?->toDateTimeString(); $line->update(['applied_snapshot' => $snapshot]); } }); $processed += $lines->count(); $this->reportExecutionProgress( $pricelistFile, 40 + (int) floor(30 * $processed / $total), "Termékadatok frissítése ({$processed}/{$total})...", ); }); } /** * E5 - Árak csatolása az árlistához. A price_list_prices táblának nincs `id` * oszlopa, ezért kötegelt query builder inserttel dolgozunk; a duplikáció ellen a * már meglévő (price_list_id, product_id) párok kiszűrése véd. */ protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void { $this->reportExecutionProgress($pricelistFile, 70, 'Árak rögzítése...'); // A `warning` sorok is importálódnak: csak az `error` státusz zár ki (ilyen sor // egyébként sem lehet, mert azzal a fájl nem hagyható jóvá). $query = $pricelistFile->lines() ->where('status', '!=', PricelistFileLineStatusEnum::error->value) ->whereNotNull('product_id') ->whereNull('executed_at'); $total = (clone $query)->count(); if ($total === 0) { return; } $processed = 0; $query->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use ($pricelistFile, $priceList, $total, &$processed) { DB::transaction(function () use ($lines, $priceList) { $productIds = $lines->pluck('product_id')->all(); // Újraindítás után előfordulhat, hogy egy sor ára már bekerült. $alreadyPriced = array_flip( DB::table('price_list_prices') ->where('price_list_id', $priceList->id) ->whereIn('product_id', $productIds) ->pluck('product_id') ->all(), ); $now = now(); $rows = []; foreach ($lines as $line) { if (isset($alreadyPriced[$line->product_id])) { continue; } $rows[] = [ 'price_list_id' => $priceList->id, 'product_id' => $line->product_id, 'price' => $this->castExecutionFloat( $line->payload[PriceListService::EXPECTED_HEADERS[14]] ?? 0, ), 'created_at' => $now, 'updated_at' => $now, ]; // Ugyanaz a cikkszám kétszer is szerepelhet a fájlban - a pivotnak // nincs egyedi indexe, ezért itt kell kiszűrni. $alreadyPriced[$line->product_id] = true; } if ($rows !== []) { DB::table('price_list_prices')->insert($rows); } PricelistFileLine::whereIn('id', $lines->pluck('id')->all()) ->update(['executed_at' => $now]); }); $processed += $lines->count(); $this->reportExecutionProgress( $pricelistFile, 70 + (int) floor(20 * $processed / $total), "Árak rögzítése ({$processed}/{$total})...", ); }); } /** * E6 - Lezárás: az árlista élesítése és az érintett termékek láthatóvá tétele. * Csak itt válik az árlista `active`-vá, azaz a felhasználók számára láthatóvá. */ protected function finalizeExecution(PricelistFile $pricelistFile, PriceList $priceList): void { $this->reportExecutionProgress($pricelistFile, 90, 'Árlista lezárása...'); $priceList->update(['status' => DbStatusFieldEnum::active]); $affected = 0; $pricelistFile->lines() ->whereNotNull('product_id') ->whereNotNull('executed_at') ->select(['id', 'product_id']) ->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use (&$affected) { $productIds = $lines->pluck('product_id')->unique()->all(); \App\Models\Product::whereIn('id', $productIds)->update(['canSee' => true]); $affected += count($productIds); }); $meta = $pricelistFile->file_meta ?? []; $meta['execution'] = [ 'price_list_id' => $priceList->id, 'affected_products' => $affected, 'line_status_counts' => $pricelistFile->lineStatusCounts(), 'finished_at' => now()->toDateTimeString(), ]; $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100); $pricelistFile->update([ 'status' => PricelistFileStatusEnum::done, 'file_meta' => $meta, ]); } /** * Termék-mezők összeállítása egy sorból (létrehozáshoz és frissítéshez egyaránt). */ protected function buildProductData(PricelistFileLine $line, PricelistFile $pricelistFile, array $groupTypes): array { $payload = $line->payload ?? []; $data = []; foreach (self::EXECUTION_FIELD_MAP as $field => $config) { $header = PriceListService::EXPECTED_HEADERS[$config['index']]; // Az opcionális oszlopok (pl. "Akció") hiányozhatnak a fájlból. Ilyenkor a // mezőt NEM írjuk felül: a termék meglévő értéke marad érvényben. if (! array_key_exists($header, $payload)) { continue; } $data[$field] = $this->castExecutionValue($payload[$header], $config['type'], $field); } $data['supplier_id'] = $pricelistFile->supplier_id; $data['producer_id'] = $line->producer_id; $data['product_group_id'] = $line->product_group_id; if (isset($groupTypes[$line->product_group_id])) { $data['type'] = $groupTypes[$line->product_group_id]; } return $data; } protected function castExecutionValue(mixed $value, string $type, string $field): mixed { return match ($type) { 'float' => $this->castExecutionFloat($value, $field), 'int' => (int) $this->castExecutionFloat($value, $field), // booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true 'bool' => strlen(trim((string) $value)) > 0, default => $this->isBlankPayloadValue($value) ? null : trim((string) $this->normalizeCellValue($value)), }; } protected function castExecutionFloat(mixed $value, string $field = ''): float { $number = is_string($value) ? (float) str_replace([' ', ','], ['', '.'], $value) : (float) $value; // ÁFA: az Excelben 0,27 formában is szerepelhet - a validálás is így számol if ($field === 'vat') { if ($number > 0 && $number <= 1) { $number *= 100; } $number = (float) round($number); } return $number; } /** * Egy Excel cella értékének normalizálása skalárrá, MÉG a payloadba kerülés előtt. * * A `Cell::getValue()` nem csak skalárt adhat vissza. A gyakorlatban a `RichText` * okozott adatvesztést: ha egy cellán belül több formázású szövegrész van (pl. a * terméknév egy része félkövér), az érték egy RichText objektum, amit a `json_encode` * ÜRESRE serializál - a szövegrészek protected propertyben vannak -, így a payloadba * `{}` kerül, ami visszaolvasva üres tömb. A cella szövege ilyenkor nyomtalanul * elveszik, a felület pedig eltörik, mert tömböt kap string helyett. */ protected function normalizeCellValue(mixed $value): string|int|float|bool|null { if ($value === null || is_scalar($value)) { return $value; } if ($value instanceof \PhpOffice\PhpSpreadsheet\RichText\RichText) { return $value->getPlainText(); } if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d H:i:s'); } if (is_array($value)) { $flat = array_filter( \Illuminate\Support\Arr::flatten($value), fn ($item) => $item !== null && $item !== '', ); return $flat === [] ? null : trim(implode(' ', array_map( fn ($item) => is_scalar($item) ? (string) $item : '', $flat, ))); } if (is_object($value) && method_exists($value, '__toString')) { return (string) $value; } return null; } /** * Üresnek számít-e egy payload érték. A tömböt külön kell kezelni: a fenti RichText * hiba miatt a régebbi rekordokban üres tömbök is előfordulnak, azokat pedig a * korábbi `is_string()` alapú vizsgálat KITÖLTÖTTNEK látta - egy elveszett terméknév * így némán átment a kötelező mező ellenőrzésén. */ protected function isBlankPayloadValue(mixed $value): bool { if ($value === null) { return true; } if (is_array($value) || is_object($value)) { return $this->normalizeCellValue($value) === null || trim((string) $this->normalizeCellValue($value)) === ''; } return trim((string) $value) === ''; } /** * Termékcsoport ID => típus ('F' / 'N' / 'X'). A termék `type` mezője a * termékcsoportjától öröklődik, ahogy a legacy importban is. */ protected function getProductGroupTypeMap(): array { return \App\Models\ProductGroup::pluck('type', 'id')->toArray(); } /** * Elindítja a visszaállítási jobot. */ public function dispatchRevertJob(PricelistFile $pricelistFile): void { dispatch(new \App\Jobs\PricelistRevertJob($pricelistFile)); } /** * Kompenzáló visszaállítás: a végrehajtás által írt adatok visszavonása. * * Nem "rollback" a szó adatbázis-értelmében - a tranzakciók már lezárultak -, hanem * a snapshotokból és a saját artefaktumokból vezetett fordított műveletsor. Ugyanúgy * chunkolt és idempotens, mint a végrehajtás, tehát megszakadás után folytatható. */ public function revert(PricelistFile $pricelistFile): bool { $this->startRevertAttempt($pricelistFile); try { $priceList = $pricelistFile->price_list_id ? PriceList::find($pricelistFile->price_list_id) : null; $this->revertPrices($pricelistFile, $priceList); $skipped = $this->revertUpdatedProducts($pricelistFile); $this->revertCreatedProducts($pricelistFile); $this->finalizeRevert($pricelistFile, $priceList, $skipped); return true; } catch (\Throwable $e) { Log::error('Pricelist revert error: ' . $e->getMessage(), [ 'pricelist_file_id' => $pricelistFile->id, 'exception' => $e, ]); $this->failRevert($pricelistFile, $e->getMessage()); return false; } } protected function startRevertAttempt(PricelistFile $pricelistFile): void { $pricelistFile->refresh(); $meta = $pricelistFile->file_meta ?? []; $revert = $meta['revert'] ?? []; $meta['revert'] = array_merge($revert, [ 'attempts' => (int) ($revert['attempts'] ?? 0) + 1, 'last_status' => 'inprogress', 'last_error' => null, 'last_attempt_at' => now()->toDateTimeString(), 'user_id' => auth()->id(), ]); // A fájl a visszaállítás idejére inprogress: ez rejti el a döntési gombokat, // tehát egy második kattintás nem indíthat párhuzamos visszaállítást. $pricelistFile->update([ 'status' => PricelistFileStatusEnum::inprogress, 'file_meta' => $meta, 'processing_current_step' => 'Visszaállítás indítása...', 'processing_current_step_percentage' => 0, ]); } protected function reportRevertProgress(PricelistFile $pricelistFile, int $percentage, string $message): void { $pricelistFile->update([ 'processing_current_step' => mb_strcut($message, 0, 255), 'processing_current_step_percentage' => $percentage, ]); } /** * R1 - Árak eltávolítása. Az egész árlista a miénk, ezért egyetlen törléssel megy. */ protected function revertPrices(PricelistFile $pricelistFile, ?PriceList $priceList): void { $this->reportRevertProgress($pricelistFile, 10, 'Árak eltávolítása...'); if (! $priceList) { return; } DB::table('price_list_prices')->where('price_list_id', $priceList->id)->delete(); } /** * R2 - Frissített termékek visszaállítása a snapshotból. * * @return array a kihagyott (időközben kézzel módosított) sorok leírása */ protected function revertUpdatedProducts(PricelistFile $pricelistFile): array { $this->reportRevertProgress($pricelistFile, 30, 'Termékadatok visszaállítása...'); $query = $pricelistFile->lines()->whereNotNull('applied_snapshot'); $total = (clone $query)->count(); if ($total === 0) { return []; } $skipped = []; $processed = 0; $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed, &$skipped) { DB::transaction(function () use ($lines, &$skipped) { foreach ($lines as $line) { $snapshot = $line->applied_snapshot ?? []; $product = \App\Models\Product::find($line->product_id); if (! $product) { // Nincs mit visszaállítani; a jelölőt elfogyasztjuk. $line->update(['applied_snapshot' => null]); continue; } $appliedAt = $snapshot['applied_updated_at'] ?? null; $currentAt = $product->updated_at?->toDateTimeString(); if ($appliedAt !== null && $appliedAt !== $currentAt) { // A terméket a végrehajtásunk óta valaki módosította. Vakon // visszaírni azt jelentené, hogy elvesszük a kézi módosítást, // ezért kihagyjuk és jelentjük. $skipped[] = [ 'row_number' => $line->row_number, 'product_id' => $product->id, 'fields' => $snapshot['fields'] ?? [], ]; $line->addValidationError('revert', 'A termék a végrehajtás óta módosult, ezért a visszaállításból kimaradt.'); $line->applied_snapshot = null; $line->save(); continue; } $product->update($snapshot['fields'] ?? []); $line->update(['applied_snapshot' => null]); } }); $processed += $lines->count(); $this->reportRevertProgress( $pricelistFile, 30 + (int) floor(30 * $processed / $total), "Termékadatok visszaállítása ({$processed}/{$total})...", ); }); return $skipped; } /** * R3 - A végrehajtás által létrehozott termékek kivonása a forgalomból. * * Szándékosan NEM fizikai törlés: a termékre időközben hivatkozhat megrendelés vagy * más rekord. A soft delete egyben a jelölő is: egy folytatott visszaállítás a már * kezelt termékeket nem találja meg újra. */ protected function revertCreatedProducts(PricelistFile $pricelistFile): void { $this->reportRevertProgress($pricelistFile, 60, 'Létrehozott termékek visszavonása...'); $query = $pricelistFile->lines() ->where('status', PricelistFileLineStatusEnum::new_product->value) ->whereNotNull('product_id'); $total = (clone $query)->count(); if ($total === 0) { return; } $processed = 0; $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed) { DB::transaction(function () use ($lines) { foreach ($lines as $line) { $product = \App\Models\Product::find($line->product_id); if (! $product) { continue; // már visszavonva (soft delete) - a folytatás átugorja } $product->update([ 'status' => DbStatusFieldEnum::deleted, 'canSee' => false, ]); $product->delete(); } }); $processed += $lines->count(); $this->reportRevertProgress( $pricelistFile, 60 + (int) floor(30 * $processed / $total), "Létrehozott termékek visszavonása ({$processed}/{$total})...", ); }); } /** * R4 - Lezárás: az árlista törlése és a fájl végleges lezárása. */ protected function finalizeRevert(PricelistFile $pricelistFile, ?PriceList $priceList, array $skipped): void { $this->reportRevertProgress($pricelistFile, 95, 'Visszaállítás lezárása...'); $priceList?->update(['status' => DbStatusFieldEnum::deleted]); $pricelistFile->refresh(); $meta = $pricelistFile->file_meta ?? []; $meta['revert'] = array_merge($meta['revert'] ?? [], [ 'last_status' => 'completed', 'last_error' => null, 'finished_at' => now()->toDateTimeString(), 'skipped_count' => count($skipped), // A részleteket korlátozottan tároljuk, hogy a file_meta ne hízzon el; a // teljes darabszám a skipped_count-ban akkor is megmarad. 'skipped' => array_slice($skipped, 0, self::REVERT_SKIPPED_DETAIL_LIMIT), 'skipped_detail_truncated' => count($skipped) > self::REVERT_SKIPPED_DETAIL_LIMIT, ]); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'reverted', 'A végrehajtás visszavonva.'); $pricelistFile->update([ 'status' => PricelistFileStatusEnum::closed, 'file_meta' => $meta, 'processing_current_step' => null, 'processing_current_step_percentage' => null, ]); } /** * A visszaállítás hibára futott. A fájl visszakerül execution_failed-be, és mostantól * a kényszerlezárás is elérhető - ez a "legalább egy terminális hibába futott * kísérlet" feltétel teljesülése. */ public function failRevert(PricelistFile $pricelistFile, string $message): void { $pricelistFile->refresh(); $meta = $pricelistFile->file_meta ?? []; $meta['revert'] = array_merge($meta['revert'] ?? [], [ 'last_status' => 'failed', 'last_error' => $message, 'finished_at' => now()->toDateTimeString(), ]); $pricelistFile->update([ 'status' => PricelistFileStatusEnum::execution_failed, 'file_meta' => $meta, 'processing_current_step' => mb_strcut('Visszaállítási hiba: ' . $message, 0, 255), 'processing_current_step_percentage' => null, ]); } /** * Felügyelt kényszerlezárás: a fájlt lezárja anélkül, hogy bármit visszaállítana. * * Vészkijárat arra az esetre, amikor a visszaállítás sem tud lefutni - enélkül egy * ilyen fájl véglegesen blokkolná a beszállítót az árlistázásból. Nem javítja meg az * adatokat, csak a döntést és a felelősséget teszi explicitté. * * @return bool false, ha a fájl nem kényszerlezárható (pl. még nem volt sikertelen * visszaállítási kísérlet) */ public function forceClose(PricelistFile $pricelistFile, string $reason): bool { $pricelistFile->refresh(); if (! $pricelistFile->canBeForceClosed()) { return false; } $meta = $pricelistFile->file_meta ?? []; $meta['force_close'] = [ 'reason' => $reason, 'revert_error' => $meta['revert']['last_error'] ?? null, 'user_id' => auth()->id(), 'at' => now()->toDateTimeString(), ]; $pricelistFile->update([ 'status' => PricelistFileStatusEnum::closed, 'file_meta' => $meta, 'processing_current_step' => null, 'processing_current_step_percentage' => null, ]); return true; } }