diff --git a/app/Console/Commands/CheckApiLog.php b/app/Console/Commands/CheckApiLog.php new file mode 100644 index 0000000..ecd8742 --- /dev/null +++ b/app/Console/Commands/CheckApiLog.php @@ -0,0 +1,336 @@ +resolveLogFile($this->argument('file')); + + if (! $filePath) { + $this->error($this->argument('file') + ? 'A megadott log fájl nem található: storage/logs/'.$this->argument('file') + : 'Nem található api-*.log fájl a storage/logs könyvtárban.'); + + return self::FAILURE; + } + + $this->info('Elemzett fájl: '.$filePath); + + [$totalQueries, $totalOrders] = $this->preprocess($filePath); + + if ($totalQueries === 0) { + $this->warn('Nem található "unprocessed" API válasz (pagination mezővel) ebben a fájlban.'); + + return self::SUCCESS; + } + + $this->info("Talált lekérdezések (API hívások): {$totalQueries}, összesen {$totalOrders} megrendeléssel."); + + $errorLogPath = $this->errorLogPathFor($filePath); + $errorLogger = Log::build([ + 'driver' => 'single', + 'path' => $errorLogPath, + ]); + + $formatter = new LineFormatter(null, null, true, true); + $formatter->setJsonPrettyPrint(true); + foreach ($errorLogger->getLogger()->getHandlers() as $handler) { + $handler->setFormatter($formatter); + } + + $bar = $this->output->createProgressBar($totalOrders); + $bar->start(); + + $queryIndex = 0; + $queriesWithErrors = 0; + $ordersWithErrors = 0; + $itemErrorCount = 0; + $profitCenterErrorCount = 0; + + $handle = fopen($filePath, 'r'); + while (($line = fgets($handle)) !== false) { + $responseBody = $this->extractUnprocessedResponse($line); + if ($responseBody === null) { + continue; + } + $queryIndex++; + $queryHasError = false; + + foreach ($responseBody['data'] ?? [] as $order) { + $orderErrors = $this->checkProfitCenter($order); + if ($orderErrors) { + $profitCenterErrorCount++; + } + + foreach ($order['items'] ?? [] as $item) { + $itemErrors = $this->checkItemCalculations($item); + if ($itemErrors) { + $itemErrorCount++; + foreach ($itemErrors as $err) { + $orderErrors[] = 'item id='.($item['id'] ?? '?').': '.$err; + } + } + } + + if ($orderErrors) { + $ordersWithErrors++; + $queryHasError = true; + $errorLogger->error('Hibás megrendelés találat', [ + 'sourceQueryIndex' => $queryIndex, + 'orderId' => $order['id'] ?? null, + 'errors' => $orderErrors, + 'order' => $order, + ]); + } + + $bar->advance(); + } + + if ($queryHasError) { + $queriesWithErrors++; + } + } + fclose($handle); + + $bar->finish(); + $this->newLine(2); + + $this->table(['Metrika', 'Érték'], [ + ['Vizsgált lekérdezések (API hívások) száma', $queryIndex], + ['Hibát tartalmazó lekérdezések száma', $queriesWithErrors], + ['Vizsgált megrendelések száma', $totalOrders], + ['Hibás megrendelések száma', $ordersWithErrors], + ['Hibás tétel(item) számítás találatok száma', $itemErrorCount], + ['Profit center hiba találatok száma', $profitCenterErrorCount], + ]); + + if ($ordersWithErrors > 0) { + $this->warn('Hibás rendelések naplózva ide: '.$errorLogPath); + } else { + $this->info('Nem található hiba.'); + } + + return self::SUCCESS; + } + + private function resolveLogFile(?string $file): ?string + { + $logsDir = storage_path('logs'); + + if ($file) { + $path = $logsDir.DIRECTORY_SEPARATOR.$file; + + return is_file($path) ? $path : null; + } + + $dated = []; + foreach (glob($logsDir.DIRECTORY_SEPARATOR.'api-*.log') ?: [] as $path) { + if (preg_match('/api-(\d{4}-\d{2}-\d{2})\.log$/', basename($path), $m)) { + $dated[$m[1]] = $path; + } + } + + if (! $dated) { + return null; + } + + krsort($dated); + + return reset($dated); + } + + private function errorLogPathFor(string $filePath): string + { + $suffix = preg_match('/api-(\d{4}-\d{2}-\d{2})\.log$/', basename($filePath), $m) + ? $m[1] + : now()->format('Y-m-d'); + + return storage_path("logs/api-check-errors-{$suffix}.log"); + } + + private function preprocess(string $filePath): array + { + $totalQueries = 0; + $totalOrders = 0; + + $handle = fopen($filePath, 'r'); + while (($line = fgets($handle)) !== false) { + $responseBody = $this->extractUnprocessedResponse($line); + if ($responseBody === null) { + continue; + } + $totalQueries++; + $totalOrders += count($responseBody['data'] ?? []); + } + fclose($handle); + + return [$totalQueries, $totalOrders]; + } + + /** + * Csak a /api/v1/order/unprocessed válaszokat ismeri fel (a "pagination" kulcs csak ott van jelen, + * a setProcessed/status válaszokban nincs). + */ + private function extractUnprocessedResponse(string $line): ?array + { + if (! str_contains($line, 'API response')) { + return null; + } + + $context = $this->extractBalancedJson($line, 'API response'); + if ($context === null || ! isset($context['responseBody']) || ! is_array($context['responseBody'])) { + return null; + } + + $body = $context['responseBody']; + if (! isset($body['pagination'], $body['data']) || ! is_array($body['data'])) { + return null; + } + + return $body; + } + + /** + * A Monolog sorvégi %context%/%extra% JSON-jából kiszedi az első kapcsos zárójel-pártól + * a hozzá tartozó záró zárójelig terjedő, kiegyensúlyozott JSON-t (idézőjelen belüli + * kapcsos zárójeleket figyelmen kívül hagyva), hogy a sor végén álló extra tartalom + * (pl. üres %extra% tömb) ne zavarja a dekódolást. + */ + private function extractBalancedJson(string $line, string $marker): ?array + { + $pos = strpos($line, $marker); + if ($pos === false) { + return null; + } + + $jsonStart = strpos($line, '{', $pos); + if ($jsonStart === false) { + return null; + } + + $depth = 0; + $inString = false; + $escape = false; + $end = null; + $len = strlen($line); + + for ($i = $jsonStart; $i < $len; $i++) { + $ch = $line[$i]; + if ($escape) { + $escape = false; + continue; + } + if ($ch === '\\') { + $escape = true; + continue; + } + if ($ch === '"') { + $inString = ! $inString; + continue; + } + if ($inString) { + continue; + } + if ($ch === '{') { + $depth++; + } elseif ($ch === '}') { + $depth--; + if ($depth === 0) { + $end = $i; + break; + } + } + } + + if ($end === null) { + return null; + } + + $decoded = json_decode(substr($line, $jsonStart, $end - $jsonStart + 1), true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + /** + * A profit center hooreycaDataActive állapotát a JELENLEGI DB-állapot alapján ellenőrzi + * (a log maga nem tartalmazza a nyers flag értékét), és jelzi, ha a válasz tartalma + * (profitCenterHooreycaId/profitCenterName jelenléte) nem egyezik a DB jelenlegi állapotával. + */ + private function checkProfitCenter(array $order): array + { + $errors = []; + $profitCenterId = $order['profit_center_id'] ?? null; + $profitCenter = $profitCenterId ? ProfitCenter::withTrashed()->find($profitCenterId) : null; + $dbActive = $profitCenter?->hooreycaDataActive; + $hasResponseData = ! empty($order['profitCenterHooreycaId']) || ! empty($order['profitCenterName']); + + if (! $profitCenter) { + $errors[] = "profit_center_id={$profitCenterId} nem található a DB-ben (törölve vagy soha nem létezett)"; + } elseif ($profitCenter->trashed()) { + $errors[] = "profit_center_id={$profitCenterId} soft-deleted a DB-ben, mégis szerepelt a válaszban"; + } elseif (! $dbActive) { + $errors[] = "profit_center_id={$profitCenterId} hooreycaDataActive JELENLEG nem aktív a DB-ben, mégis szerepelt a válaszban"; + } + + if ($profitCenter && ! $profitCenter->trashed() && $dbActive && ! $hasResponseData) { + $errors[] = "profit_center_id={$profitCenterId} a DB-ben aktív, de a válaszból hiányzik a profitCenterHooreycaId/profitCenterName"; + } + + return $errors; + } + + /** + * A HooreycaVolumen/HooreycaUnitPrice mezőket a jelenleg érvényes (OrderController:: + * convertItemCollectionToHooreycaAPIItems-ben lévő) képlettel újraszámolja a naplózott + * nyers mezőkből, és összeveti a naplózott számított értékkel. + */ + private function checkItemCalculations(array $item): array + { + $errors = []; + $quantity = $item['quantity'] ?? null; + $unitMultiplier = $item['unitMultiplier'] ?? null; + $hooreycaMultiplier = $item['HooreycaMultiplier'] ?? null; + $price = $item['price'] ?? null; + $loggedVolumen = $item['HooreycaVolumen'] ?? null; + $loggedUnitPrice = $item['HooreycaUnitPrice'] ?? null; + + if ($hooreycaMultiplier === null) { + $expectedVolumen = null; + $expectedUnitPrice = null; + } else { + $expectedVolumen = $quantity * $hooreycaMultiplier; + $expectedUnitPrice = $expectedVolumen != 0 + ? ($quantity * $unitMultiplier * $price) / $expectedVolumen + : null; + } + + if (! $this->numbersMatch($expectedVolumen, $loggedVolumen)) { + $errors[] = sprintf('HooreycaVolumen eltér: várt=%s, naplózott=%s', json_encode($expectedVolumen), json_encode($loggedVolumen)); + } + if (! $this->numbersMatch($expectedUnitPrice, $loggedUnitPrice)) { + $errors[] = sprintf('HooreycaUnitPrice eltér: várt=%s, naplózott=%s', json_encode($expectedUnitPrice), json_encode($loggedUnitPrice)); + } + + return $errors; + } + + private function numbersMatch($expected, $actual, float $epsilon = 0.0001): bool + { + if ($expected === null || $actual === null) { + return $expected === $actual; + } + + return abs((float) $expected - (float) $actual) < $epsilon; + } +}