HEX
Server: Apache
System: Linux vpshost11508.publiccloud.com.br 5.15.179-grsec-vpshost-10.lc.el8.x86_64 #1 SMP Mon Apr 7 12:04:45 -03 2025 x86_64
User: wicomm2 (10002)
PHP: 8.3.0
Disabled: apache_child_terminate,dl,escapeshellarg,escapeshellcmd,exec,link,mail,openlog,passthru,pcntl_alarm,pcntl_exec,pcntl_fork,pcntl_get_last_error,pcntl_getpriority,pcntl_setpriority,pcntl_signal,pcntl_signal_dispatch,pcntl_sigprocmask,pcntl_sigtimedwait,pcntl_sigwaitinfo,pcntl_strerror,pcntl_wait,pcntl_waitpid,pcntl_wexitstatus,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_wstopsig,pcntl_wtermsig,php_check_syntax,php_strip_whitespace,popen,proc_close,proc_open,shell_exec,symlink,system
Upload Files
File: /home/storage/5/78/dd/wicomm2/public_html/clientes/flavioscalcados/latest-products.php
<?php
/**
 * add_low_stock_skus_to_subcollection.php
 *
 * - Processa SKUs em janelas lógicas e adiciona à Subcollection quando estoque disponível < threshold.
 * - CLI: executa todas as páginas com pausa de 20s entre elas.
 * - WEB: executa 1 página lógica por request e auto-redireciona após 20s (evita timeout).
 * - Logs coloridos em HTML (página) e JSON em arquivo (opcional).
 *
 * Parâmetros (CLI ou GET/POST) — com aliases:
 *   --account            conta VTEX (ex.: suaconta)
 *   --env                vtexcommercestable | myvtex
 *   --appKey             AppKey
 *   --appToken           AppToken
 *   --subcollection      ID da Subcollection (padrão 137)
 *   --threshold          Estoque mínimo (DEFAULT 5) => se available < threshold, adiciona
 *   --pageSize / pagesize / target / total / opagesize  TOTAL alvo de SKUs a processar (ex.: 17000)
 *   --limit / chunk / perPage / per_page                Lote por página lógica (máx 1000) (ex.: 50)
 *   --dry-run           true|false  (não chama inclusão, apenas loga)
 *   --silent            true|false  (silencia logs na página/STDOUT; mantém no arquivo)
 *   --log-file          Caminho do arquivo de log
 *   --log-max-size      Tamanho para rotação (DEFAULT 5MB)
 *
 * Exemplo WEB:
 *   ?account=...&env=vtexcommercestable&appKey=...&appToken=...&subcollection=358&threshold=4&pagesize=17000&limit=50&dry-run=0
 *
 * Exemplo CLI:
 *   php script.php --account=... --env=vtexcommercestable --appKey=... --appToken=... --subcollection=358 --threshold=4 --pageSize=17000 --limit=50 --dry-run=0
 */

date_default_timezone_set('America/Sao_Paulo');

/*=========================================================================
=  Streaming/flush e tratamento de erros
=========================================================================*/
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
@ini_set('implicit_flush', '1');
@ini_set('max_execution_time', '0');
if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); }
while (ob_get_level() > 0) { @ob_end_flush(); }
ob_implicit_flush(true);

// (opcional durante setup)
// @ini_set('display_errors', '1'); @ini_set('display_startup_errors', '1'); @error_reporting(E_ALL);

set_error_handler(function($severity, $message, $file, $line) {
    if (!(error_reporting() & $severity)) return;
    throw new ErrorException($message, 0, $severity, $file, $line);
});

/*=========================================================================
=  Leitura de parâmetros (CLI + Web)
=========================================================================*/
function readArgs(): array {
    if (PHP_SAPI === 'cli') {
        $a = getopt('', [
            'account::','env::','appKey::','appToken::',
            'subcollection::','threshold::',
            'pageSize::','pagesize::','target::','total::','opagesize::',
            'limit::','chunk::','perPage::','per_page::',
            'dry-run::','silent::','log-file::','log-max-size::','__page::'
        ]);
        return is_array($a) ? $a : [];
    }
    $a = [];
    $keys = [
        'account','env','appKey','appToken','subcollection','threshold',
        'pageSize','pagesize','target','total','opagesize',
        'limit','chunk','perPage','per_page',
        'dry-run','silent','log-file','log-max-size','__page'
    ];
    foreach ($keys as $k) {
        if (isset($_GET[$k]))  $a[$k] = $_GET[$k];
        if (isset($_POST[$k])) $a[$k] = $_POST[$k];
    }
    return $a;
}
$args = readArgs();

/*=========================================================================
=  Helpers para leitura robusta de inteiros e booleans
=========================================================================*/
function argInt($arr, $keys, $default) {
    foreach ($keys as $k) {
        if (isset($arr[$k]) && $arr[$k] !== '') return (int)$arr[$k];
    }
    return (int)$default;
}
function argBool($arr, $key, $default=false) {
    if (!isset($arr[$key])) return (bool)$default;
    return in_array(strtolower((string)$arr[$key]), ['1','true','on','yes'], true);
}

/*=========================================================================
=  Config
=========================================================================*/
$ACCOUNT        = $args['account']        ?? getenv('VTEX_ACCOUNT') ?? 'seuaccount';
$ENV            = $args['env']            ?? getenv('VTEX_ENV')     ?? 'vtexcommercestable';
$APP_KEY        = $args['appKey']         ?? getenv('VTEX_APP_KEY') ?? 'XXXX';
$APP_TOKEN      = $args['appToken']       ?? getenv('VTEX_APP_TOKEN') ?? 'YYYY';
$SUBCOLLECTION  = (int)($args['subcollection'] ?? 137);
$THRESHOLD      = (int)($args['threshold']    ?? 5);

/**
 * NOVO COMPORTAMENTO:
 * - TOTAL_TARGET = TOTAL alvo de SKUs a processar (meta) — aceita vários aliases.
 * - CHUNK        = tamanho do lote por página lógica (cap 1000) — aceita vários aliases.
 */
$TOTAL_TARGET = argInt($args, ['pageSize','pagesize','target','total','opagesize'], 1000);
$CHUNK        = argInt($args, ['limit','chunk','perPage','per_page'], 1000);
$CHUNK        = max(1, min($CHUNK, 1000)); // cap 1000

$DRY_RUN      = argBool($args, 'dry-run', false);
$SILENT       = argBool($args, 'silent',  false);
$LOG_FILE     = $args['log-file']      ?? '';
$LOG_MAX_SIZE = (int)($args['log-max-size'] ?? (5 * 1024 * 1024)); // 5MB

$HOST         = "{$ACCOUNT}.{$ENV}.com.br";
$LOG_PREFIX   = "[low-stock->subcollection{$SUBCOLLECTION}] ";
$PAGE_WAIT_SECONDS = 20; // pausa entre páginas (CLI e WEB)

/*=========================================================================
=  Logger (JSON + HTML na página)
=========================================================================*/
function log_json($level, $msg, $context = []) {
    global $LOG_FILE, $LOG_MAX_SIZE;
    $entry = [
        'ts'      => date('c'),
        'level'   => strtoupper($level),
        'message' => $msg,
        'context' => $context,
    ];
    $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL;

    if ($LOG_FILE) {
        if (@file_exists($LOG_FILE) && @filesize($LOG_FILE) > $LOG_MAX_SIZE) {
            for ($i = 3; $i >= 1; $i--) {
                $src = $LOG_FILE . '.' . $i;
                $dst = $LOG_FILE . '.' . ($i + 1);
                if (@file_exists($src)) @rename($src, $dst);
            }
            @rename($LOG_FILE, $LOG_FILE . '.1');
        }
        @file_put_contents($LOG_FILE, $line, FILE_APPEND);
    }
    return $line;
}

function out($msg, $level = 'INFO', $context = []) {
    global $SILENT;
    $levelColor = [
        'INFO'  => '#00cc66',
        'WARN'  => '#ffcc00',
        'ERROR' => '#ff3333'
    ];
    $color = $levelColor[strtoupper($level)] ?? '#cccccc';
    $ts = date('H:i:s');

    $contextStr = '';
    if (!empty($context)) {
        $contextStr = '<pre style="margin:2px 0 8px 0; background:#f9f9f9; padding:4px; border-radius:4px; font-size:12px;">'
                    . htmlspecialchars(json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))
                    . '</pre>';
    }
    $html = "<div style='font-family:monospace; color:{$color}; margin:4px 0;'>[{$ts}] <strong>{$level}</strong> — {$msg}{$contextStr}</div>";

    if (!$SILENT) {
        echo $html;
        echo str_repeat(' ', 1024);
        @flush();
    }
    log_json($level, $msg, $context);
}

/*=========================================================================
=  HTTP base com retry/backoff
=========================================================================*/
function vtexRequest($method, $url, $headers = [], $body = null, $retry = 3, $sleepBaseMs = 400) {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER         => true,
        CURLOPT_TIMEOUT        => 60,
    ]);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, is_string($body) ? $body : json_encode($body));
        if (!isset($headers['Content-Type'])) {
            $headers['Content-Type'] = 'application/json';
        }
    }
    $h = [];
    foreach ($headers as $k => $v) { $h[] = $k . ': ' . $v; }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $h);

    $attempt = 0;
    do {
        $attempt++;
        $resp = curl_exec($ch);
        if ($resp === false) {
            $err = curl_error($ch);
            if ($attempt <= $retry) {
                out("cURL error, retrying: {$err}", 'WARN', ['attempt' => $attempt, 'url' => $url]);
                usleep($sleepBaseMs * 1000 * $attempt);
                continue;
            }
            throw new RuntimeException("cURL error: {$err}");
        }

        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $status     = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $bodyStr    = substr($resp, $headerSize);

        if (in_array($status, [429, 503], true) && $attempt <= $retry) {
            out("HTTP {$status} — retrying", 'WARN', ['attempt' => $attempt, 'url' => $url]);
            $waitMs = $sleepBaseMs * $attempt + rand(0, 250);
            usleep($waitMs * 1000);
            continue;
        }
        return [$status, $bodyStr];
    } while ($attempt <= $retry);

    throw new RuntimeException("Unexpected HTTP flow");
}

function vtexHeaders($appKey, $appToken, $extra = []) {
    return array_merge([
        'X-VTEX-API-AppKey'   => $appKey,
        'X-VTEX-API-AppToken' => $appToken,
        'Accept'              => 'application/json',
    ], $extra);
}

/*=========================================================================
=  VTEX Helpers
=========================================================================*/
// Busca uma janela de SKUs (startIndex absoluto e length), cruzando páginas reais VTEX (pagesize=1000)
function fetchSkuWindow($host, $appKey, $appToken, $startIndex, $length) {
    $result = [];
    $maxPerPage = 1000; // limite real VTEX
    $remaining = $length;
    $cursor = $startIndex;

    while ($remaining > 0) {
        $apiPage  = (int)floor($cursor / $maxPerPage) + 1; // 1-based
        $offset   = $cursor % $maxPerPage;
        $url      = "https://{$host}/api/catalog_system/pvt/sku/stockkeepingunitids?page={$apiPage}&pagesize={$maxPerPage}";
        list($status, $body) = vtexRequest('GET', $url, vtexHeaders($appKey, $appToken));
        if ($status !== 200) {
            throw new RuntimeException("List SKU IDs falhou (HTTP {$status}) body={$body}");
        }
        $data = json_decode($body, true);
        if (!is_array($data) || empty($data)) {
            break; // sem mais SKUs
        }

        $take = min($remaining, max(0, count($data) - $offset));
        if ($take <= 0) {
            // avançar para a próxima página real
            $cursor = $apiPage * $maxPerPage;
            continue;
        }

        $slice = array_slice($data, $offset, $take);
        $result = array_merge($result, $slice);

        $remaining -= $take;
        $cursor    += $take;
    }

    return $result;
}

// Estoque disponível por SKU
function getAvailableStockForSku($host, $appKey, $appToken, $skuId) {
    $url = "https://{$host}/api/logistics/pvt/inventory/skus/{$skuId}";
    list($status, $body) = vtexRequest('GET', $url, vtexHeaders($appKey, $appToken));
    if ($status === 404) return 0;
    if ($status !== 200) {
        throw new RuntimeException("Inventory lookup failed for SKU {$skuId} (HTTP {$status}) body={$body}");
    }
    $data = json_decode($body, true);

    $warehouses = [];
    if (isset($data['balance']))                        $warehouses = $data['balance'];
    elseif (isset($data['balances']))                   $warehouses = $data['balances'];
    elseif (isset($data['warehouses']))                 $warehouses = $data['warehouses'];
    elseif (isset($data['inventoryByWarehouse']))       $warehouses = $data['inventoryByWarehouse'];
    elseif (isset($data['items']))                      $warehouses = $data['items'];

    $available = 0;
    foreach ($warehouses as $w) {
        $total    = $w['totalQuantity']    ?? ($w['quantity'] ?? 0);
        $reserved = $w['reservedQuantity'] ?? 0;
        $available += max($total - $reserved, 0);
    }
    return $available;
}

// Inclusão de SKU — rota principal com body {SkuId}, fallback por path
function addSkuToSubcollection($host, $appKey, $appToken, $subcollectionId, $skuId) {
    $url1 = "https://{$host}/api/catalog/pvt/subcollection/{$subcollectionId}/stockkeepingunit";
    $headers = vtexHeaders($appKey, $appToken, ['Content-Type' => 'application/json']);
    $body    = ['SkuId' => (int)$skuId];

    out("→ Enviando SKU {$skuId} para Subcollection {$subcollectionId}", 'INFO', [
        'endpoint' => $url1, 'method' => 'POST', 'body' => $body
    ]);

    list($status1, $resp1) = vtexRequest('POST', $url1, $headers, json_encode($body));
    $shortBody = strlen($resp1) > 500 ? substr($resp1, 0, 500) . '...' : $resp1;
    out("← Resposta SKU {$skuId}", 'INFO', ['status' => $status1, 'body_snippet' => $shortBody]);

    if (($status1 >= 200 && $status1 < 300) || in_array($status1, [409, 422], true)) {
        out("✅ SKU {$skuId} adicionado com sucesso.", 'INFO');
        return true;
    }

    if (in_array($status1, [404, 405], true)) {
        $url2 = "https://{$host}/api/catalog/pvt/subcollection/{$subcollectionId}/sku/{$skuId}";
        out("⚠️ Tentando rota alternativa para SKU {$skuId}", 'WARN', ['endpoint' => $url2]);

        list($status2, $resp2) = vtexRequest('POST', $url2, vtexHeaders($appKey, $appToken));
        $shortBody2 = strlen($resp2) > 500 ? substr($resp2, 0, 500) . '...' : $resp2;
        out("← Resposta rota alternativa", 'INFO', ['status' => $status2, 'body_snippet' => $shortBody2]);

        if (($status2 >= 200 && $status2 < 300) || in_array($status2, [409, 422], true)) {
            out("✅ SKU {$skuId} adicionado (rota alternativa).", 'INFO');
            return true;
        }
        throw new RuntimeException("Add SKU fallback falhou ({$status2}) body={$resp2}");
    }

    throw new RuntimeException("Add SKU body JSON falhou ({$status1}) body={$resp1}");
}

/*=========================================================================
=  Processamento de 1 página lógica (WEB/CLI)
=========================================================================*/
function processLogicalPage($host, $appKey, $appToken, $subcollectionId, $threshold, $logicalPage, $chunk, $totalTarget, $dryRun) {
    $startIndex = ($logicalPage - 1) * $chunk;
    if ($startIndex >= $totalTarget) {
        out("⚠️ Página lógica {$logicalPage} fora do alvo total.", 'WARN');
        return ['checked' => 0, 'added' => 0, 'empty' => true];
    }
    $length = min($chunk, $totalTarget - $startIndex);

    out("🔎 Página lógica {$logicalPage}: janela [start={$startIndex}, len={$length}]", 'INFO');

    $ids = fetchSkuWindow($host, $appKey, $appToken, $startIndex, $length);
    if (empty($ids)) {
        out("⚠️ Janela vazia na página lógica {$logicalPage}.", 'WARN');
        return ['checked' => 0, 'added' => 0, 'empty' => true];
    }

    $checked = 0;
    $added   = 0;

    foreach ($ids as $skuId) {
        $checked++;
        try {
            $available = getAvailableStockForSku($host, $appKey, $appToken, $skuId);
        } catch (Throwable $e) {
            out("Falha estoque SKU {$skuId}: " . $e->getMessage(), 'WARN', ['sku' => $skuId]);
            usleep(20000);
            continue;
        }

        if ($available < $threshold && $available > 0) {
            if ($dryRun) {
                out("[DRY-RUN] Adicionaria SKU {$skuId} (disp={$available})", 'INFO', ['sku' => $skuId, 'available' => $available]);
            } else {
                try {
                    addSkuToSubcollection($host, $appKey, $appToken, $subcollectionId, $skuId);
                    $added++;
                    out("✅ SKU {$skuId} adicionado (disp={$available})", 'INFO', ['sku' => $skuId, 'available' => $available]);
                } catch (Throwable $e) {
                    out("Erro ao adicionar SKU {$skuId}: " . $e->getMessage(), 'ERROR', ['sku' => $skuId]);
                }
            }
        }

        // reduzir 429/503
        usleep(25000);
    }

    return ['checked' => $checked, 'added' => $added, 'empty' => false];
}

/*=========================================================================
=  Execução principal
=========================================================================*/
$totalPages = (int)ceil(max(1, $TOTAL_TARGET) / $CHUNK);

out($LOG_PREFIX . "Start", 'INFO', [
    'account' => $ACCOUNT,
    'env' => $ENV,
    'subcollection' => $SUBCOLLECTION,
    'threshold' => $THRESHOLD,
    'raw_args' => $args,              // ajuda a diagnosticar nomes de parâmetros enviados
    'total_target' => $TOTAL_TARGET,  // deve refletir seu valor (ex.: 17000)
    'chunk_limit' => $CHUNK,          // ex.: 50
    'total_pages' => $totalPages,     // ex.: 340
    'dry_run' => $DRY_RUN
]);

$addedSum = 0;
$checkedSum = 0;

if (PHP_SAPI === 'cli') {
    // CLI: processa todas as páginas lógicas num único run
    for ($page = 1; $page <= $totalPages; $page++) {
        $res = processLogicalPage($HOST, $APP_KEY, $APP_TOKEN, $SUBCOLLECTION, $THRESHOLD, $page, $CHUNK, $TOTAL_TARGET, $DRY_RUN);
        $checkedSum += $res['checked'];
        $addedSum   += $res['added'];

        if ($page < $totalPages) {
            out("⏳ Aguardando {$PAGE_WAIT_SECONDS}s antes da próxima página (CLI)...", 'INFO');
            sleep($PAGE_WAIT_SECONDS);
        }
    }
    out("✅ Concluído (CLI). Verificados={$checkedSum}, adicionados={$addedSum}.", 'INFO');
    exit;

} else {
    // WEB: 1 página lógica por request com auto-redirect após 20s
    $currentPage = isset($args['__page']) ? max(1, (int)$args['__page']) : 1;
    if ($currentPage > $totalPages) {
        out("🎉 Finalizado (WEB). Verificados={$checkedSum}, adicionados={$addedSum}.", 'INFO');
        exit;
    }

    out("🌐 (WEB) Processando página {$currentPage}/{$totalPages}...", 'INFO');
    $res = processLogicalPage($HOST, $APP_KEY, $APP_TOKEN, $SUBCOLLECTION, $THRESHOLD, $currentPage, $CHUNK, $TOTAL_TARGET, $DRY_RUN);
    $checkedSum += $res['checked'];
    $addedSum   += $res['added'];
    out("📄 Página {$currentPage} concluída. Verificados+={$res['checked']}, Adicionados+={$res['added']}", 'INFO');

    if ($currentPage < $totalPages) {
        $params = $_GET; // preserva parâmetros originais
        $params['__page'] = $currentPage + 1;
        $nextUrl = strtok($_SERVER['REQUEST_URI'], '?') . '?' . http_build_query($params);

        out("⏳ Aguardando {$PAGE_WAIT_SECONDS}s para continuar na próxima página (WEB)...", 'INFO');

        echo '<script>
            setTimeout(function(){ window.location.href = ' . json_encode($nextUrl) . '; }, ' . ($PAGE_WAIT_SECONDS * 1000) . ');
            setInterval(()=>{window.scrollTo(0, document.body.scrollHeight)}, 1000);
        </script>';

        echo "<div style='margin-top:8px;font-family:sans-serif;'>
                <a href='".htmlspecialchars($nextUrl, ENT_QUOTES)."'>Ir agora para a página ".($currentPage+1)."</a>
              </div>";
    } else {
        out("🎉 Finalizado (WEB). Verificados={$checkedSum}, adicionados={$addedSum}.", 'INFO');
    }
}