d2d.emegrendeles.hu/app/Console/Commands/CheckApiLog.php

337 lines
12 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\ProfitCenter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Monolog\Formatter\LineFormatter;
class CheckApiLog extends Command
{
protected $signature = 'app:check-api-log {file? : A storage/logs alatti API log fájl neve (pl. api-2026-07-21.log). Ha nincs megadva, a fájlnév szerint legutolsó api-*.log fájlt választja.}';
protected $description = 'A storage/logs/api-*.log fájlban szereplő /api/v1/order/unprocessed válaszok ellenőrzése: profit center hooreycaDataActive állapota és a HooreycaVolumen/HooreycaUnitPrice számított mezők helyessége.';
public function handle(): int
{
$filePath = $this->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;
}
}