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');
}
}