File: /home/storage/5/78/dd/wicomm2/public_html/clientes/flavioscalcados/latest-products-cron.php
<?php
/**
* add_low_stock_skus_to_subcollection_cron.php
*
* CLI-only para execução via CRON.
* - Processa SKUs em janelas lógicas (TOTAL_TARGET e CHUNK).
* - Pausa configurável entre páginas (wait-seconds, padrão 20s).
* - Logs em STDOUT (texto) e opcionalmente em arquivo (JSON).
* - Inclusão na Subcollection com body { "SkuId": <id> }.
*
* Parâmetros (somente CLI):
* --account, --env (vtexcommercestable|myvtex), --appKey, --appToken
* --subcollection (int), --threshold (int)
* --pageSize / pagesize / target / total / opagesize (TOTAL alvo)
* --limit / chunk / perPage / per_page (lote por página, máx 1000)
* --wait-seconds / wait / pause (pausa entre páginas)
* --dry-run (true/false), --silent (true/false)
* --log-file (path), --log-max-size (bytes, default 5MB)
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Este script é CLI-only (use via cron/terminal).\n");
exit(2);
}
date_default_timezone_set('America/Sao_Paulo');
/* ========= Leitura de argumentos ========= */
$args = getopt('', [
'account::','env::','appKey::','appToken::',
'subcollection::','threshold::',
'pageSize::','pagesize::','target::','total::','opagesize::',
'limit::','chunk::','perPage::','per_page::',
'wait-seconds::','wait::','pause::',
'dry-run::','silent::','log-file::','log-max-size::'
]);
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') ?? '';
$APP_TOKEN = $args['appToken'] ?? getenv('VTEX_APP_TOKEN') ?? '';
$SUBCOLLECTION = (int)($args['subcollection'] ?? 137);
$THRESHOLD = (int)($args['threshold'] ?? 5);
$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
$WAIT_SECONDS = argInt($args, ['wait-seconds','wait','pause'], 20);
$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));
$HOST = "{$ACCOUNT}.{$ENV}.com.br";
$LOG_PREFIX = "[low-stock->subcollection{$SUBCOLLECTION}]";
/* ========= Logging ========= */
function log_json_file($level, $msg, $context=[]) {
global $LOG_FILE, $LOG_MAX_SIZE;
if (!$LOG_FILE) return;
$entry = ['ts'=>date('c'),'level'=>strtoupper($level),'message'=>$msg,'context'=>$context];
$line = json_encode($entry, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES).PHP_EOL;
if (@file_exists($LOG_FILE) && @filesize($LOG_FILE) > $LOG_MAX_SIZE) {
@rename($LOG_FILE, $LOG_FILE.'.1'); // rotação simples
}
@file_put_contents($LOG_FILE, $line, FILE_APPEND);
}
function out($msg, $level='INFO', $context=[]) {
global $SILENT;
$prefix = sprintf("[%s] %s", date('H:i:s'), $level);
if (!$SILENT) echo "{$prefix} - {$msg}\n";
log_json_file($level, $msg, $context);
}
/* ========= HTTP base com retry ========= */
function vtexHeaders($appKey, $appToken, $extra=[]) {
return array_merge([
'X-VTEX-API-AppKey' => $appKey,
'X-VTEX-API-AppToken' => $appToken,
'Accept' => 'application/json',
], $extra);
}
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");
}
/* ========= VTEX helpers ========= */
// Busca janela de SKUs cruzando páginas reais (pagesize real = 1000)
function fetchSkuWindow($host, $appKey, $appToken, $startIndex, $length) {
$result = [];
$maxPerPage = 1000;
$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;
$take = min($remaining, max(0, count($data) - $offset));
if ($take <= 0) { $cursor = $apiPage * $maxPerPage; continue; }
$slice = array_slice($data, $offset, $take);
$result = array_merge($result, $slice);
$remaining -= $take;
$cursor += $take;
}
return $result;
}
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;
}
function addSkuToSubcollection($host, $appKey, $appToken, $subcollectionId, $skuId) {
// rota principal: body JSON {SkuId}
$url1 = "https://{$host}/api/catalog/pvt/subcollection/{$subcollectionId}/stockkeepingunit";
$headers = vtexHeaders($appKey, $appToken, ['Content-Type'=>'application/json']);
$body = ['SkuId' => (int)$skuId];
out("POST add SKU -> subcollection", 'INFO', ['sku'=>$skuId,'subcollection'=>$subcollectionId,'endpoint'=>$url1,'body'=>$body]);
list($status1, $resp1) = vtexRequest('POST', $url1, $headers, json_encode($body));
if (($status1 >= 200 && $status1 < 300) || in_array($status1, [409,422], true)) return true;
// fallback por path
if (in_array($status1, [404,405], true)) {
$url2 = "https://{$host}/api/catalog/pvt/subcollection/{$subcollectionId}/sku/{$skuId}";
out("POST fallback path", 'WARN', ['sku'=>$skuId,'endpoint'=>$url2]);
list($status2, $resp2) = vtexRequest('POST', $url2, vtexHeaders($appKey, $appToken));
if (($status2 >= 200 && $status2 < 300) || in_array($status2, [409,422], true)) return true;
throw new RuntimeException("Fallback falhou ({$status2}) body={$resp2}");
}
throw new RuntimeException("Add SKU body JSON falhou ({$status1}) body={$resp1}");
}
/* ========= Processar 1 página lógica ========= */
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];
}
$length = min($chunk, $totalTarget - $startIndex);
out("Janela lógica {$logicalPage}: start={$startIndex}, len={$length}");
$ids = fetchSkuWindow($host, $appKey, $appToken, $startIndex, $length);
if (empty($ids)) { out("Janela vazia", 'WARN'); return ['checked'=>0,'added'=>0]; }
$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]);
} else {
try {
addSkuToSubcollection($host, $appKey, $appToken, $subcollectionId, $skuId);
$added++;
out("SKU {$skuId} adicionado (disp={$available})", 'INFO');
} catch (Throwable $e) {
out("Erro add SKU {$skuId}: ".$e->getMessage(), 'ERROR', ['sku'=>$skuId]);
}
}
}
usleep(25000); // reduzir 429/503
}
return ['checked'=>$checked,'added'=>$added];
}
/* ========= Execução principal ========= */
try {
$totalPages = (int)ceil(max(1, $TOTAL_TARGET) / $CHUNK);
out("Start {$LOG_PREFIX}", 'INFO', [
'account'=>$ACCOUNT,'env'=>$ENV,'subcollection'=>$SUBCOLLECTION,'threshold'=>$THRESHOLD,
'total_target'=>$TOTAL_TARGET,'chunk_limit'=>$CHUNK,'total_pages'=>$totalPages,'dry_run'=>$DRY_RUN
]);
$sumChecked=0; $sumAdded=0;
for ($page=1; $page <= $totalPages; $page++) {
out("== Página {$page}/{$totalPages} ==");
$res = processLogicalPage($HOST, $APP_KEY, $APP_TOKEN, $SUBCOLLECTION, $THRESHOLD, $page, $CHUNK, $TOTAL_TARGET, $DRY_RUN);
$sumChecked += $res['checked'];
$sumAdded += $res['added'];
out("Página {$page} concluída: verificados+={$res['checked']}, adicionados+={$res['added']}", 'INFO');
if ($page < $totalPages) {
out("Aguardando {$WAIT_SECONDS}s antes da próxima página...", 'INFO');
sleep($WAIT_SECONDS);
}
}
out("Concluído. Verificados={$sumChecked}, adicionados={$sumAdded}.", 'INFO');
exit(0);
} catch (Throwable $e) {
out("FATAL: ".$e->getMessage(), 'ERROR');
exit(1);
}