Autosave: 20260718-194402

This commit is contained in:
Flatlogic Bot 2026-07-18 19:42:46 +00:00
parent 3e16c3adac
commit 730f9cb10b
15 changed files with 1041 additions and 112 deletions

View File

@ -274,6 +274,16 @@ h1, .h1 {
border-spacing: 0;
table-layout: fixed; /* Ayuda a controlar mejor los anchos de columna */
}
.cc-pedidos-listos-container .table,
.cc-pedidos-completados-container .table {
min-width: 1400px;
}
.cc-pedidos-listos-container,
.cc-pedidos-completados-container {
overflow-x: auto !important;
overflow-y: scroll !important;
scrollbar-gutter: stable both-edges;
}
.excel-container th {
position: sticky;
top: 0;

View File

@ -130,9 +130,9 @@ include 'layout_header.php';
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<div class="card-body p-0">
<div class="excel-container cc-pedidos-rotulados-container cc-pedidos-completados-container">
<table id="pedidos-table" class="table table-striped">
<thead>
<tr>
<th>ID</th>
@ -174,12 +174,11 @@ include 'layout_header.php';
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="numero_operacion">
<?php echo htmlspecialchars($pedido['numero_operacion'] ?? 'N/A'); ?>
</td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
<?php
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?> clave-cell" data-id="<?php echo $pedido['id']; ?>" data-field="clave" id="clave-<?php echo $pedido['id']; ?>">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<td class="editable-select" data-id="<?php echo $pedido['id']; ?>" data-field="banco">
<?php echo !empty($pedido['banco']) ? htmlspecialchars($pedido['banco']) : 'N/A'; ?>
@ -272,16 +271,10 @@ function updatePedidoField(pedidoId, field, value) {
if (data.success) {
console.log(`${field} actualizado con éxito.`);
// Si el usuario es Asesor, verificar si ahora puede ver la clave
const userRole = "<?php echo $user_role; ?>";
if (userRole === 'Asesor') {
const pedido = data.pedido;
const claveCell = document.getElementById(`clave-${pedidoId}`);
if (pedido.numero_operacion && pedido.banco) {
claveCell.innerHTML = pedido.clave || 'N/A';
} else {
claveCell.innerHTML = '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>';
}
const pedido = data.pedido || {};
const claveCell = document.getElementById(`clave-${pedidoId}`);
if (claveCell) {
claveCell.innerHTML = pedido.clave || 'N/A';
}
} else if (data.error) {
alert('Error: ' + data.error);
@ -461,15 +454,10 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.success) {
// Si es numero_operacion y es Asesor, verificar visibilidad de clave
if (field === 'numero_operacion' && userRole === 'Asesor') {
const pedido = data.pedido;
const claveCell = document.getElementById(`clave-${pedidoId}`);
if (pedido.numero_operacion && pedido.banco) {
claveCell.innerHTML = pedido.clave || 'N/A';
} else {
claveCell.innerHTML = '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>';
}
const pedido = data.pedido || {};
const claveCell = document.getElementById(`clave-${pedidoId}`);
if (claveCell) {
claveCell.innerHTML = pedido.clave || 'N/A';
}
} else if (data.error) {
console.error('Error al guardar:', data.error);

View File

@ -179,7 +179,7 @@ include 'layout_header.php';
<th>Monto Debe</th>
<th> De Orden</th>
<th>Codigo De Orden</th>
<th>CLAVE</th>
<?php if ($user_role !== 'Asesor'): ?><th>CLAVE</th><?php endif; ?>
<th>DESCARGO</th>
<th>PENDIENTES</th>
<th>Estado</th>
@ -203,13 +203,11 @@ include 'layout_header.php';
<td><?php echo htmlspecialchars($pedido['monto_debe']); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_rastreo"><?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_tracking"><?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?></td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?>" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php if ($user_role !== 'Asesor'): ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<?php endif; ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="descargo">
<?php echo htmlspecialchars($pedido['descargo'] ?? 'N/A'); ?>
</td>
@ -255,7 +253,7 @@ $(document).ready(function() {
"language": {
"url": "//cdn.datatables.net/plug-ins/1.10.25/i18n/Spanish.json"
},
"order": [[ <?php echo ($user_role !== 'Asesor') ? 16 : 15; ?>, "desc" ]],
"order": [[ <?php echo ($user_role !== 'Asesor') ? 16 : 14; ?>, "desc" ]],
"paging": false,
"lengthChange": false,
"info": false

View File

@ -175,7 +175,7 @@ include 'layout_header.php';
<th>Monto Debe</th>
<th> De Orden</th>
<th>Codigo De Orden</th>
<th>CLAVE</th>
<?php if ($user_role !== 'Asesor'): ?><th>CLAVE</th><?php endif; ?>
<th>DESCARGO</th>
<th>PENDIENTES</th>
<th>Estado</th>
@ -199,13 +199,11 @@ include 'layout_header.php';
<td><?php echo htmlspecialchars($pedido['monto_debe']); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_rastreo"><?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_tracking"><?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?></td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?>" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php if ($user_role !== 'Asesor'): ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<?php endif; ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="descargo">
<?php echo htmlspecialchars($pedido['descargo'] ?? 'N/A'); ?>
</td>

View File

@ -97,6 +97,17 @@ function drive_test_get_cell(array $row, array $indexes, array $aliases, string
return $default;
}
function drive_test_compact_text(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$normalized = preg_replace('/\s+/u', ' ', $value);
return trim((string) ($normalized ?? $value));
}
function drive_test_extract_codigo_number(?string $codigo): ?int
{
$digits = preg_replace('/\D+/', '', trim((string) $codigo));
@ -182,14 +193,14 @@ function drive_test_fetch_orders(int $limit = 10, int $startRow = 5552, string $
$precio = drive_test_get_cell($row, $headerIndexes, ['PRECIO']);
$pais = drive_test_get_cell($row, $headerIndexes, ['PAIS']);
$coordenadas = drive_test_get_cell($row, $headerIndexes, ['COORDENADAS']);
$ciudad = drive_test_get_cell($row, $headerIndexes, ['CIUDAD']);
$metodo = drive_test_get_cell($row, $headerIndexes, ['METODO']);
$sede = drive_test_get_cell($row, $headerIndexes, ['SEDE / ID', 'SEDE/ID']);
$dni = drive_test_get_cell($row, $headerIndexes, ['N° DNI', 'N° DNI ', 'NRO DNI', 'DNI']);
$observaciones = drive_test_get_cell($row, $headerIndexes, ['OBSERVACIONES', 'OBSERVACIONES ']);
$direccion = drive_test_get_cell($row, $headerIndexes, ['DIRECION', 'DIRECCION']);
$referencia = drive_test_get_cell($row, $headerIndexes, ['REFERENCIA']);
$distrito = drive_test_get_cell($row, $headerIndexes, ['DISTRITO']);
$ciudad = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['CIUDAD']));
$metodo = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['METODO']));
$sede = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['SEDE / ID', 'SEDE/ID']));
$dni = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['N° DNI', 'N° DNI ', 'NRO DNI', 'DNI']));
$observaciones = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['OBSERVACIONES', 'OBSERVACIONES ']));
$direccion = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['DIRECION', 'DIRECCION']));
$referencia = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['REFERENCIA']));
$distrito = drive_test_compact_text(drive_test_get_cell($row, $headerIndexes, ['DISTRITO']));
$sourceRow = (int) $rowIndex + 2;
$codigoNumber = drive_test_extract_codigo_number($codigo);
@ -364,9 +375,9 @@ function drive_test_ensure_orders_table(PDO $pdo): void
`nombre` VARCHAR(255) DEFAULT NULL,
`direccion_drive` TEXT NULL,
`referencia_drive` TEXT NULL,
`sede_drive` VARCHAR(120) NULL,
`ciudad_drive` VARCHAR(120) NULL,
`distrito_drive` VARCHAR(120) NULL,
`sede_drive` TEXT NULL,
`ciudad_drive` TEXT NULL,
`distrito_drive` TEXT NULL,
`dni_drive` LONGTEXT NULL,
`observaciones_drive` TEXT NULL,
`celular` VARCHAR(40) DEFAULT NULL,
@ -390,20 +401,31 @@ function drive_test_ensure_orders_table(PDO $pdo): void
// Asegura columna para pedidos cargados manualmente (Agregados)
cc_test_ensure_column($pdo, "callcenter_test_orders", "is_agregado", "TINYINT(1) NOT NULL DEFAULT 0");
// Asegurar que `dni_drive` soporte valores grandes desde Google Sheets.
// Asegurar que los campos de ubicación y DNI soporten valores largos desde Google Sheets.
try {
$colStmt = $pdo->prepare("
SELECT DATA_TYPE
SELECT COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'callcenter_test_orders'
AND COLUMN_NAME = 'dni_drive'
LIMIT 1
AND COLUMN_NAME IN ('sede_drive', 'ciudad_drive', 'distrito_drive', 'dni_drive')
");
$colStmt->execute();
$dataType = strtolower((string) $colStmt->fetchColumn());
if ($dataType !== 'longtext') {
$dataTypes = [];
foreach ($colStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$columnName = strtolower((string) ($row['COLUMN_NAME'] ?? ''));
$dataTypes[$columnName] = strtolower((string) ($row['DATA_TYPE'] ?? ''));
}
foreach (['sede_drive', 'ciudad_drive', 'distrito_drive'] as $columnName) {
$dataType = $dataTypes[$columnName] ?? '';
if (!in_array($dataType, ['text', 'mediumtext', 'longtext'], true)) {
$pdo->exec("ALTER TABLE `callcenter_test_orders` MODIFY COLUMN `{$columnName}` TEXT NULL");
}
}
if (($dataTypes['dni_drive'] ?? '') !== 'longtext') {
$pdo->exec("ALTER TABLE `callcenter_test_orders` MODIFY COLUMN `dni_drive` LONGTEXT NULL");
}
} catch (Throwable $exception) {

View File

@ -0,0 +1,289 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const puppeteer = require('puppeteer');
const SHALOM_SECRET = '.Ov3rsku112024l4r43l.';
const SHALOM_ENCRYPTION_KEY_B64 = 'uQn/bQ94PXBEfId70zjN+VE1hSU7kh9VBXTOUd68Ssc=';
const SHALOM_TRACKING_HOME = 'https://shalom.com.pe/rastrea';
const SHALOM_SEARCH_URL = 'https://serviceswebapi.shalomcontrol.com/api/v1/web/rastrea/buscar';
const SHALOM_STATUS_URL = 'https://serviceswebapi.shalomcontrol.com/api/v1/web/rastrea/estados';
function detectChromeExecutable() {
const candidates = [];
const cacheRoot = '/home/ubuntu/.cache/puppeteer/chrome';
try {
if (fs.existsSync(cacheRoot)) {
const versions = fs.readdirSync(cacheRoot).sort().reverse();
for (const version of versions) {
candidates.push(path.join(cacheRoot, version, 'chrome-linux64', 'chrome'));
}
}
} catch (error) {
// Ignoramos y seguimos con rutas fijas.
}
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
candidates.unshift(process.env.PUPPETEER_EXECUTABLE_PATH);
}
candidates.push(
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser'
);
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
}
function uuidv4() {
const bytes = crypto.randomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function buildAuthorizationHeader() {
const token = `web-${uuidv4()}`;
const expiresAt = Math.floor(Date.now() / 1000) + 300;
const payload = `${token}@${expiresAt}`;
const signature = crypto.createHmac('sha256', SHALOM_SECRET).update(payload).digest('hex');
return `Bearer ${payload}@${signature}`;
}
function decryptShalomPayload(payload) {
if (!payload || payload.encrypted !== true || !payload.data) {
return payload;
}
const key = Buffer.from(SHALOM_ENCRYPTION_KEY_B64, 'base64');
if (key.length !== 32) {
throw new Error('No se pudo preparar la clave de descifrado.');
}
const cipherBuffer = Buffer.from(String(payload.data), 'base64');
if (cipherBuffer.length <= 16) {
throw new Error('La respuesta cifrada de Shalom es inválida.');
}
const iv = cipherBuffer.subarray(0, 16);
const ciphertext = cipherBuffer.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const plainText = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
try {
return JSON.parse(plainText);
} catch (error) {
return plainText;
}
}
async function obtainRecaptchaToken(page) {
try {
await page.goto(SHALOM_TRACKING_HOME, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
} catch (error) {
// La página puede reintentar/cargar de nuevo por el limpiador de service workers.
}
await page.waitForFunction(() => window.RECAPTCHA_SITE_KEY && window.grecaptcha, {
timeout: 60000,
});
return page.evaluate(async () => {
await new Promise((resolve) => window.grecaptcha.ready(resolve));
return window.grecaptcha.execute(window.RECAPTCHA_SITE_KEY, { action: 'rastrea_buscar' });
});
}
async function postShalomApi(url, fields) {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null) continue;
form.append(key, String(value));
}
const response = await fetch(url, {
method: 'POST',
body: form,
headers: {
Accept: 'application/json',
Authorization: buildAuthorizationHeader(),
Origin: 'https://shalom.com.pe',
Referer: 'https://shalom.com.pe/',
'X-Requested-With': 'XMLHttpRequest',
},
});
const rawText = await response.text();
let wrappedPayload = null;
try {
wrappedPayload = JSON.parse(rawText);
} catch (error) {
return {
success: false,
error: `Respuesta no JSON de Shalom (${response.status}).`,
details: rawText.trim() ? rawText.slice(0, 1200) : 'Sin contenido útil.',
};
}
let plainPayload = wrappedPayload;
try {
plainPayload = decryptShalomPayload(wrappedPayload);
} catch (error) {
return {
success: false,
error: `No se pudo descifrar la respuesta de Shalom (${response.status}).`,
details: error.message,
};
}
if (response.status >= 400) {
const message = plainPayload && typeof plainPayload === 'object'
? String(plainPayload.message || plainPayload.error || '').trim()
: '';
return {
success: false,
error: message || `Shalom respondió HTTP ${response.status}.`,
details: plainPayload,
};
}
if (!plainPayload || typeof plainPayload !== 'object') {
return {
success: false,
error: 'Shalom devolvió una respuesta vacía o inválida.',
details: plainPayload,
};
}
if (plainPayload.success === false) {
return {
success: false,
error: String(plainPayload.message || plainPayload.error || 'Shalom rechazó la solicitud.'),
details: plainPayload,
};
}
return {
success: true,
data: plainPayload,
};
}
function printJson(payload) {
console.log(JSON.stringify(payload));
}
async function main() {
const orderNumber = String(process.argv[2] || '').trim();
const orderCode = String(process.argv[3] || '').trim();
if (!orderNumber || !orderCode) {
printJson({
success: false,
error: 'Número de orden y código de orden son requeridos.',
});
process.exit(1);
}
let browser = null;
try {
const executablePath = detectChromeExecutable();
if (!executablePath) {
printJson({
success: false,
error: 'No se encontró un navegador Chromium compatible para consultar Shalom.',
});
process.exit(2);
}
browser = await puppeteer.launch({
headless: 'new',
executablePath,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-features=SSLCommonNameMismatchHandling',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await page.setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36');
const recaptchaToken = await obtainRecaptchaToken(page);
const searchResult = await postShalomApi(SHALOM_SEARCH_URL, {
numero: orderNumber,
codigo: orderCode,
ose_id: '',
recaptcha_token: recaptchaToken,
});
if (!searchResult.success) {
printJson(searchResult);
process.exit(3);
}
const searchPayload = searchResult.data;
const oseId = searchPayload && searchPayload.data ? searchPayload.data.ose_id : null;
if (!oseId) {
printJson({
success: false,
error: 'La búsqueda de Shalom no devolvió un N° interno válido.',
details: searchPayload,
});
process.exit(4);
}
const statusResult = await postShalomApi(SHALOM_STATUS_URL, {
ose_id: String(oseId),
});
if (!statusResult.success) {
printJson(statusResult);
process.exit(5);
}
const statusPayload = statusResult.data;
const trackingUrl = `${SHALOM_TRACKING_HOME}/${encodeURIComponent(String(oseId))}/${encodeURIComponent(orderCode)}`;
printJson({
success: true,
source: 'shalom_search_api',
search: searchPayload,
status: statusPayload,
resolved: {
ose_id: oseId,
tracking_url: trackingUrl,
},
});
} catch (error) {
printJson({
success: false,
error: error && error.message ? error.message : 'Error al consultar Shalom.',
details: error && error.stack ? error.stack.split('\n').slice(0, 4).join(' | ') : undefined,
});
process.exit(6);
} finally {
if (browser) {
try {
await browser.close();
} catch (error) {
// Ignoramos errores al cerrar el navegador.
}
}
}
}
main();

View File

@ -176,8 +176,8 @@ include 'layout_header.php';
<div class="card">
<div class="card-body p-0">
<div class="excel-container cc-pedidos-rotulados-container">
<table class="table table-striped">
<div class="excel-container cc-pedidos-rotulados-container cc-pedidos-listos-container">
<table id="pedidos-table" class="table table-striped">
<thead>
<tr>
<th>ID</th>
@ -191,7 +191,7 @@ include 'layout_header.php';
<th>Monto Total</th>
<th>Monto Debe</th>
<th>Nro. Operación</th>
<th>Clave</th>
<?php if ($user_role !== 'Asesor'): ?><th>Clave</th><?php endif; ?>
<th style="background-color: #d4edda; font-weight: bold;">Recojo Cliente (Día y Hora)</th>
<th>Estado</th>
<th>Asesor</th>
@ -203,7 +203,7 @@ include 'layout_header.php';
<tbody>
<?php if (empty($pedidos)): ?>
<tr>
<td colspan="16" class="text-center">No hay pedidos listos para recoger.</td>
<td colspan="<?php echo ($user_role === 'Asesor') ? '17' : '18'; ?>" class="text-center">No hay pedidos listos para recoger.</td>
</tr>
<?php else: ?>
<?php foreach ($pedidos as $pedido): ?>
@ -219,13 +219,11 @@ include 'layout_header.php';
<td><?php echo htmlspecialchars($pedido['monto_total']); ?></td>
<td><?php echo htmlspecialchars($pedido['monto_debe']); ?></td>
<td><?php echo htmlspecialchars($pedido['numero_operacion'] ?? 'N/A'); ?></td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?>" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php if ($user_role !== 'Asesor'): ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<?php endif; ?>
<td class="editable-recojo" data-id="<?php echo $pedido['id']; ?>" style="background-color: #d4edda; font-weight: bold;">
<span class="text" title="Doble clic para editar"><?php echo !empty($pedido['fecha_recojo']) ? htmlspecialchars($pedido['fecha_recojo']) : 'N/A'; ?></span>
<input type="text" class="form-control edit-input" style="display: none;" value="<?php echo htmlspecialchars($pedido['fecha_recojo'] ?? ''); ?>">

View File

@ -37,6 +37,7 @@ $pedido = [
'costo_envio_real' => 0,
'gasto_publicidad_unitario' => 0,
'voucher_adelanto_path' => '',
'voucher_restante_path' => '',
];
$page_title = 'Crear Pedido';
@ -340,6 +341,7 @@ include 'layout_header.php';
<div class="col-md-6 mb-3">
<label for="voucher_restante" class="form-label">Voucher de Pago Restante</label>
<input type="file" class="form-control" id="voucher_restante" name="voucher_restante">
<div class="form-text">Este archivo es obligatorio para pasar el pedido a COMPLETADO . Si ya hay un voucher cargado, no necesitas subirlo otra vez.</div>
<?php
$voucherRestantePath = $pedido['voucher_restante_path'] ?? '';
$voucherRestanteExists = $voucherRestantePath !== '' && file_exists(__DIR__ . '/' . $voucherRestantePath);
@ -367,6 +369,7 @@ include 'layout_header.php';
<option value="Gestion" <?php echo ($pedido['estado'] == 'Gestion') ? 'selected' : ''; ?>>GESTIONES ⚙️</option>
<?php endif; ?>
</select>
<div id="completion-requirements" class="form-text text-danger d-none">Para mover el pedido a COMPLETADO debes registrar número de operación, banco y voucher del pago restante.</div>
</div>
</div>
@ -398,6 +401,7 @@ include 'layout_header.php';
<script>
document.addEventListener('DOMContentLoaded', function() {
const pedidoForm = document.querySelector('form[action="save_pedido.php"]');
const numeroOperacionInput = document.getElementById('numero_operacion');
const bancoInput = document.getElementById('banco');
const operacionFeedback = document.getElementById('operacion-feedback');
@ -407,6 +411,10 @@ document.addEventListener('DOMContentLoaded', function() {
const submitBtn = document.querySelector('button[type="submit"]');
const agenciaSelect = document.getElementById('agencia');
const sedeEnvioInput = document.getElementById('sede_envio');
const estadoInput = document.getElementById('estado');
const voucherRestanteInput = document.getElementById('voucher_restante');
const completionRequirements = document.getElementById('completion-requirements');
const hasExistingVoucherRestante = <?php echo $voucherRestanteExists ? 'true' : 'false'; ?>;
function updateSedeList() {
if (agenciaSelect.value === 'SHALOM') {
@ -416,8 +424,25 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
function isCompletedStateSelected() {
return estadoInput.value === 'COMPLETADO ✅' || estadoInput.value === 'Completado';
}
function updateCompletionRequirements() {
const completionSelected = isCompletedStateSelected();
const needsVoucherUpload = completionSelected && !hasExistingVoucherRestante;
numeroOperacionInput.required = completionSelected;
bancoInput.required = completionSelected;
voucherRestanteInput.required = needsVoucherUpload;
if (completionRequirements) {
completionRequirements.classList.toggle('d-none', !completionSelected);
}
}
agenciaSelect.addEventListener('change', updateSedeList);
updateSedeList(); // Run on load
updateSedeList();
function validateOperacion() {
const value = numeroOperacionInput.value.trim();
@ -428,6 +453,7 @@ document.addEventListener('DOMContentLoaded', function() {
numeroOperacionInput.classList.remove('is-invalid', 'is-valid');
toggleClaveBtn.disabled = true;
claveInput.type = 'password';
submitBtn.disabled = false;
return;
}
@ -456,7 +482,6 @@ document.addEventListener('DOMContentLoaded', function() {
numeroOperacionInput.classList.remove('is-invalid');
numeroOperacionInput.classList.add('is-valid');
// Solo habilitar si también hay banco seleccionado
if (bancoValue !== '') {
toggleClaveBtn.disabled = false;
} else {
@ -468,17 +493,33 @@ document.addEventListener('DOMContentLoaded', function() {
})
.catch(error => {
console.error('Error checking duplicate:', error);
submitBtn.disabled = false;
});
}
numeroOperacionInput.addEventListener('input', validateOperacion);
bancoInput.addEventListener('change', validateOperacion);
// Run validation on load if there's a value
estadoInput.addEventListener('change', function() {
updateCompletionRequirements();
validateOperacion();
});
voucherRestanteInput.addEventListener('change', updateCompletionRequirements);
updateCompletionRequirements();
if (numeroOperacionInput.value.trim() !== '') {
validateOperacion();
}
if (pedidoForm) {
pedidoForm.addEventListener('submit', function(event) {
updateCompletionRequirements();
if (!pedidoForm.checkValidity()) {
event.preventDefault();
pedidoForm.reportValidity();
}
});
}
toggleClaveBtn.addEventListener('click', function() {
if (claveInput.type === 'password') {
claveInput.type = 'text';

View File

@ -224,7 +224,7 @@ include 'layout_header.php';
<?php endif; ?>
<th> De Orden</th>
<th>Codigo De Orden</th>
<th>CLAVE</th>
<?php if ($user_role !== 'Asesor'): ?><th>CLAVE</th><?php endif; ?>
<th>NOTA ADICIONAL</th>
<th>Estado</th>
<?php if ($user_role !== 'Asesor'): ?><th>Asesor</th><?php endif; ?>
@ -250,13 +250,11 @@ include 'layout_header.php';
<?php endif; ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_rastreo"><?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_tracking"><?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?></td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?>" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php if ($user_role !== 'Asesor'): ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<?php endif; ?>
<td class="editable-dblclick" data-id="<?php echo $pedido['id']; ?>" data-field="nota_adicional">
<?php echo htmlspecialchars($pedido['nota_adicional'] ?? 'N/A'); ?>
</td>

View File

@ -1,5 +1,8 @@
<?php
session_start();
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
@ -191,7 +194,7 @@ include 'layout_header.php';
<button type="button" id="verify-statuses-btn" class="btn btn-primary">Actualizar Estados Shalom</button>
</div>
</form>
<p class="text-muted small mb-0 mt-3">El sistema guarda el último estado consultado de Shalom y puedes refrescar la columna con este botón cuando lo necesites.</p>
<p id="shalom-auto-status" class="text-muted small mb-0 mt-3" aria-live="polite">La columna Estado Shalom se actualiza automáticamente al abrir esta pantalla y también puedes refrescarla manualmente con el botón.</p>
</div>
</div>
@ -213,7 +216,7 @@ include 'layout_header.php';
<th>Monto Debe</th>
<th> De Orden</th>
<th>Codigo De Orden</th>
<th>CLAVE</th>
<?php if ($user_role !== 'Asesor'): ?><th>CLAVE</th><?php endif; ?>
<th>Estado</th>
<th>Estado Shalom</th>
<?php if ($user_role !== 'Asesor'): ?><th>Asesor</th><?php endif; ?>
@ -276,7 +279,7 @@ include 'layout_header.php';
?>
<div class="mt-1">
<button type="button" class="btn btn-sm btn-info" title="Consultar Estado" data-bs-toggle="modal" data-bs-target="#trackingModal" data-pedido-id="<?php echo htmlspecialchars((string) $pedido['id']); ?>" data-order-number="<?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?>" data-order-code="<?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?>" data-whatsapp-message="<?php echo htmlspecialchars($whatsappMessageRaw); ?>">🔍</button>
<a href="<?php echo htmlspecialchars($trackingUrl); ?>" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-primary" title="Rastreo Shalom">🚚</a>
<a id="tracking-link-<?php echo htmlspecialchars((string) $pedido['id']); ?>" href="<?php echo htmlspecialchars($trackingUrl); ?>" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-primary" title="Rastreo Shalom">🚚</a>
<button type='button' class='btn btn-sm btn-success copy-summary-icon' title='Copiar Resumen para Cliente' data-whatsapp-message="<?php echo htmlspecialchars($whatsappMessageRaw); ?>">📋</button>
<a href="<?php echo $whatsappUrl; ?>" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-secondary whatsapp-icon" id="whatsapp-icon-<?php echo $pedido['id']; ?>" title="Enviar WhatsApp">💬</a>
</div>
@ -289,13 +292,11 @@ include 'layout_header.php';
<td><?php echo htmlspecialchars($pedido['monto_debe']); ?></td>
<td><?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_tracking"><?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?></td>
<?php
$canSeeClave = ($user_role !== 'Asesor' || (!empty($pedido['numero_operacion']) && !empty($pedido['banco'])));
$isEditableClave = ($user_role !== 'Asesor') ? 'editable' : '';
?>
<td class="<?php echo $isEditableClave; ?>" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo $canSeeClave ? htmlspecialchars($pedido['clave'] ?? 'N/A') : '<i class="fas fa-eye-slash text-muted" title="Suba el número de operación y seleccione el banco para ver la clave"></i> <span class="text-muted" style="font-size: 0.8rem;">Oculto</span>'; ?>
<?php if ($user_role !== 'Asesor'): ?>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="clave">
<?php echo htmlspecialchars($pedido['clave'] ?? 'N/A'); ?>
</td>
<?php endif; ?>
<td><span class="badge" style="<?php echo getStatusStyle($pedido['estado']); ?>"><?php echo ($pedido['estado'] == 'Gestion') ? 'GESTIONES ⚙️' : htmlspecialchars($pedido['estado']); ?></span></td>
<td id="live-status-<?php echo $pedido['id']; ?>">
<?php
@ -365,9 +366,17 @@ $(document).ready(function() {
document.addEventListener('DOMContentLoaded', function() {
const table = document.querySelector('.table');
const SHALOM_API_ENABLED = true;
const verificationNotice = document.getElementById('shalom-auto-status');
let shalomVerificationPromise = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const updateVerificationNotice = (message, tone = 'muted') => {
if (!verificationNotice) return;
verificationNotice.className = `small mb-0 mt-3 text-${tone}`;
verificationNotice.textContent = message;
};
const getStatusMeta = (statusMessage) => {
const upper = (statusMessage || '').toUpperCase();
let badgeClass = 'bg-secondary';
@ -443,8 +452,22 @@ document.addEventListener('DOMContentLoaded', function() {
return errText.length > 22 ? errText.slice(0, 22) + '…' : (errText || 'Error Shalom');
};
const fetchShalomData = async (orderNumber, orderCode) => {
const response = await fetch(`shalom_api.php?orderNumber=${encodeURIComponent(orderNumber)}&orderCode=${encodeURIComponent(orderCode)}`);
const fetchShalomData = async (orderNumber, orderCode, options = {}) => {
const { includeDetails = false } = options;
const query = new URLSearchParams({
orderNumber,
orderCode,
_ts: Date.now().toString(),
});
if (includeDetails) {
query.set('includeDetails', '1');
}
const response = await fetch(`shalom_api.php?${query.toString()}`, {
cache: 'no-store',
credentials: 'same-origin'
});
let rawText = '';
try {
rawText = await response.text();
@ -529,6 +552,12 @@ document.addEventListener('DOMContentLoaded', function() {
setStatusCell(statusCell, badgeClass, statusMessage, '', updatedFromProvider);
}
const trackingLink = document.getElementById(`tracking-link-${pedidoId}`);
const resolvedTrackingUrl = data?.search?.data?.tracking_url || data?.search?.tracking_url || '';
if (trackingLink && resolvedTrackingUrl) {
trackingLink.href = resolvedTrackingUrl;
}
if (whatsappIcon) {
whatsappIcon.classList.remove('btn-secondary', 'btn-success');
whatsappIcon.classList.add(whatsappClass);
@ -569,7 +598,7 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
fetchShalomData(orderNumber, orderCode)
fetchShalomData(orderNumber, orderCode, { includeDetails: true })
.then(async data => {
if (data && data.error) {
const title = data.details ? `${data.error} - ${data.details}` : data.error;
@ -730,10 +759,23 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
async function verificarEstados() {
const rows = document.querySelectorAll('tr[data-order-number][data-order-code]');
async function verificarEstados(options = {}) {
const { automatic = false } = options;
const rows = Array.from(document.querySelectorAll('tr[data-order-number][data-order-code]'));
const verifyButton = document.getElementById('verify-statuses-btn');
if (shalomVerificationPromise) {
if (!automatic) {
updateVerificationNotice('Ya hay una actualización automática de Shalom en curso. Espera un momento.', 'warning');
}
return shalomVerificationPromise;
}
if (!rows.length) {
updateVerificationNotice('No hay pedidos en tránsito para consultar en Shalom.', 'muted');
return;
}
if (!SHALOM_API_ENABLED) {
rows.forEach((row) => {
const pedidoId = row.id.split('-')[1];
@ -741,15 +783,26 @@ document.addEventListener('DOMContentLoaded', function() {
if (!statusCell) return;
setStatusCell(statusCell, 'bg-warning text-dark', 'VERIFICAR');
});
updateVerificationNotice('La consulta automática de Shalom no está disponible ahora mismo.', 'warning');
return;
}
if (verifyButton) {
verifyButton.disabled = true;
verifyButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Actualizando...';
}
shalomVerificationPromise = (async () => {
if (verifyButton) {
verifyButton.disabled = true;
verifyButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Actualizando...';
}
updateVerificationNotice(
automatic
? 'Actualizando estados Shalom automáticamente al abrir la pantalla...'
: 'Actualizando estados Shalom manualmente...',
'info'
);
let processed = 0;
let updated = 0;
try {
for (const row of rows) {
const orderNumber = row.dataset.orderNumber;
const orderCode = row.dataset.orderCode;
@ -758,16 +811,21 @@ document.addEventListener('DOMContentLoaded', function() {
const whatsappIcon = document.getElementById(`whatsapp-icon-${pedidoId}`);
if (!statusCell) {
processed++;
continue;
}
if (orderCode === '26') {
setStatusCell(statusCell, 'bg-info', 'OLVA COURIER');
processed++;
updateVerificationNotice(`Actualizando Estado Shalom: ${processed}/${rows.length} pedidos revisados.`, 'info');
continue;
}
if (!orderNumber || !orderCode || orderNumber === 'N/A' || orderCode === 'N/A') {
setStatusCell(statusCell, 'bg-light text-dark', 'Sin datos');
processed++;
updateVerificationNotice(`Actualizando Estado Shalom: ${processed}/${rows.length} pedidos revisados.`, 'info');
continue;
}
@ -783,6 +841,7 @@ document.addEventListener('DOMContentLoaded', function() {
setStatusCell(statusCell, 'bg-danger', badgeText, title);
} else if (data && data.statuses && data.statuses.message) {
await applyStatusToRow(pedidoId, data.statuses.message, data, whatsappIcon);
updated++;
} else {
setStatusCell(statusCell, 'bg-warning text-dark', 'Inválido');
}
@ -793,9 +852,31 @@ document.addEventListener('DOMContentLoaded', function() {
setStatusCell(statusCell, 'bg-danger', shortErr, fullMessage);
}
await sleep(700);
processed++;
updateVerificationNotice(`Actualizando Estado Shalom: ${processed}/${rows.length} pedidos revisados${updated ? ` · ${updated} actualizados` : ''}.`, 'info');
if (processed < rows.length) {
await sleep(700);
}
}
const finishedAt = new Date().toLocaleTimeString('es-PE', {
hour: '2-digit',
minute: '2-digit'
});
updateVerificationNotice(
automatic
? `Estados Shalom actualizados automáticamente a las ${finishedAt}.`
: `Actualización manual de Estados Shalom completada a las ${finishedAt}.`,
'success'
);
})();
try {
await shalomVerificationPromise;
} finally {
shalomVerificationPromise = null;
if (verifyButton) {
verifyButton.disabled = false;
verifyButton.innerHTML = 'Actualizar Estados Shalom';
@ -805,7 +886,19 @@ document.addEventListener('DOMContentLoaded', function() {
const verifyButton = document.getElementById('verify-statuses-btn');
if (verifyButton) {
verifyButton.addEventListener('click', verificarEstados);
verifyButton.addEventListener('click', function () {
verificarEstados({ automatic: false });
});
}
const autoRefreshRows = document.querySelectorAll('tr[data-order-number][data-order-code]');
if (autoRefreshRows.length) {
updateVerificationNotice('La columna Estado Shalom empezará a actualizarse sola en unos segundos...', 'muted');
window.setTimeout(function () {
verificarEstados({ automatic: true });
}, 900);
} else {
updateVerificationNotice('No hay pedidos en tránsito para consultar en Shalom.', 'muted');
}
document.addEventListener('click', function (e) {

View File

@ -114,8 +114,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($productos_detalle)) {
$notas .= "\n\n" . $notas_adicionales;
}
$completed_states = ['Completado', 'COMPLETADO ✅'];
$is_completed_state = in_array($estado, $completed_states, true);
$voucher_restante_exists = !empty($voucher_restante_path) && file_exists(__DIR__ . '/' . $voucher_restante_path);
if ($estado === 'COMPLETADO ✅' && empty($numero_operacion)) {
if ($is_completed_state && empty($numero_operacion)) {
$error_message = urlencode("El número de operación es obligatorio cuando el estado es 'COMPLETADO ✅'.");
$redirect_url = 'pedido_form.php?error=' . $error_message;
if ($id) {
@ -125,6 +129,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
if ($is_completed_state && empty($banco)) {
$error_message = urlencode("El banco es obligatorio cuando el estado es 'COMPLETADO ✅'.");
$redirect_url = 'pedido_form.php?error=' . $error_message;
if ($id) {
$redirect_url .= '&id=' . $id;
}
header('Location: ' . $redirect_url);
exit;
}
if ($is_completed_state && !$voucher_restante_exists) {
$error_message = urlencode("Debe subir el Voucher de Pago Restante antes de mover el pedido a 'COMPLETADO ✅'.");
$redirect_url = 'pedido_form.php?error=' . $error_message;
if ($id) {
$redirect_url .= '&id=' . $id;
}
header('Location: ' . $redirect_url);
exit;
}
// Check for minimum length of operation number
if (!empty($numero_operacion) && strlen($numero_operacion) < 6) {
$error_message = urlencode("El número de operación debe tener al menos 6 dígitos.");
@ -237,8 +261,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
// Conditionally add fecha_completado
$completed_states = ['Completado', 'COMPLETADO ✅'];
if (in_array($estado, $completed_states)) {
if ($is_completed_state) {
// Only set fecha_completado if it hasn't been set before.
$stmt_check = $pdo->prepare("SELECT fecha_completado FROM pedidos WHERE id = ?");
$stmt_check->execute([$id]);
@ -287,8 +310,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$columns_sql = "dni_cliente, nombre_completo, celular, agencia, sede_envio, codigo_rastreo, codigo_tracking, clave, pendientes, producto, cantidad, monto_total, monto_adelantado, numero_operacion, banco, monto_debe, estado, asesor_id, notas, nota_adicional, observacion, descargo, comision_vendedora, costo_envio_real, gasto_publicidad_unitario, voucher_adelanto_path, voucher_restante_path";
$values_sql = ":dni_cliente, :nombre_completo, :celular, :agencia, :sede_envio, :codigo_rastreo, :codigo_tracking, :clave, :pendientes, :producto, :cantidad, :monto_total, :monto_adelantado, :numero_operacion, :banco, :monto_debe, :estado, :asesor_id, :notas, :nota_adicional, :observacion, :descargo, :comision_vendedora, :costo_envio_real, :gasto_publicidad_unitario, :voucher_adelanto_path, :voucher_restante_path";
$completed_states = ['Completado', 'COMPLETADO ✅'];
if (in_array($estado, $completed_states)) {
if ($is_completed_state) {
$columns_sql .= ", fecha_completado";
$values_sql .= ", :fecha_completado";
$params['fecha_completado'] = date('Y-m-d H:i:s');
@ -300,7 +322,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$new_id = $pdo->lastInsertId();
// --- NEW: Insert into operaciones_provincia if completed ---
if (in_array($estado, $completed_states)) {
if ($is_completed_state) {
$stmt_u = $pdo->prepare("SELECT nombre_asesor FROM users WHERE id = ?");
$stmt_u->execute([$_SESSION['user_id']]);
$nombre_asesor = $stmt_u->fetchColumn();

View File

@ -1,5 +1,8 @@
<?php
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
// Make sure we ALWAYS return valid JSON from this endpoint, even when
// upstream error bodies include invalid UTF-8 bytes.
@ -44,6 +47,65 @@ function parse_shalom_tracking_date(?string $rawText): array {
return [null, $cleanText];
}
function normalize_shalom_status_label(string $statusMessage): string {
$normalized = trim(preg_replace('/\s+/u', ' ', $statusMessage));
if ($normalized === '') {
return '';
}
$upper = mb_strtoupper($normalized, 'UTF-8');
if (str_contains($upper, 'ENTREGA EXITOSA') || str_contains($upper, 'ENTREGAD') || str_contains($upper, 'COMPLET')) {
return 'Entregado';
}
if (str_contains($upper, 'REPARTO')) {
return 'En reparto';
}
if (str_contains($upper, 'DESTINO')) {
return 'En destino';
}
if (str_contains($upper, 'TRANSITO') || str_contains($upper, 'TRÁNSITO')) {
return 'En tránsito';
}
if (str_contains($upper, 'ORIGEN') || str_contains($upper, 'REGISTRADO')) {
return 'En origen';
}
return $normalized;
}
function infer_status_message_from_payload(array $statusPayload): string {
$statusData = isset($statusPayload['data']) && is_array($statusPayload['data'])
? $statusPayload['data']
: [];
$priority = [
['key' => 'entregado', 'label' => 'Entregado'],
['key' => 'reparto', 'label' => 'En reparto'],
['key' => 'destino', 'label' => 'En destino'],
['key' => 'transito', 'label' => 'En tránsito'],
['key' => 'origen', 'label' => 'En origen'],
['key' => 'registrado', 'label' => 'En origen'],
['key' => 'demora', 'label' => 'En demora'],
];
foreach ($priority as $candidate) {
$key = $candidate['key'];
if (!empty($statusData[$key])) {
return $candidate['label'];
}
}
$fallbackMessage = trim((string) ($statusPayload['message'] ?? $statusPayload['error'] ?? ''));
$normalizedFallback = normalize_shalom_status_label($fallbackMessage);
return $normalizedFallback !== '' ? $normalizedFallback : 'No disponible';
}
// Load env from executor/.env (helps when Apache/PHP doesn't export .env vars)
function load_dotenv_if_needed(array $keys): void {
$missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === '');
@ -87,6 +149,248 @@ function resolve_binary(array $candidates, string $fallback): string {
return $fallback;
}
function generate_uuid_v4(): string {
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
function build_shalom_web_authorization(): string {
$token = 'web-' . generate_uuid_v4();
$expiresAt = time() + 300;
$payload = $token . '@' . $expiresAt;
$signature = hash_hmac('sha256', $payload, '.Ov3rsku112024l4r43l.');
return 'Bearer ' . $payload . '@' . $signature;
}
function decrypt_shalom_public_payload(array $decoded): array {
if (empty($decoded['encrypted'])) {
return [
'success' => true,
'data' => $decoded,
];
}
$encodedPayload = trim((string) ($decoded['data'] ?? ''));
if ($encodedPayload === '') {
return [
'success' => false,
'error' => 'Shalom devolvió una respuesta cifrada vacía.',
];
}
$key = base64_decode('uQn/bQ94PXBEfId70zjN+VE1hSU7kh9VBXTOUd68Ssc=', true);
if ($key === false || strlen($key) !== 32) {
return [
'success' => false,
'error' => 'No se pudo preparar la clave de descifrado de Shalom.',
];
}
$binaryPayload = base64_decode($encodedPayload, true);
if ($binaryPayload === false || strlen($binaryPayload) <= 16) {
return [
'success' => false,
'error' => 'La respuesta cifrada de Shalom es inválida.',
];
}
$iv = substr($binaryPayload, 0, 16);
$ciphertext = substr($binaryPayload, 16);
$plainText = openssl_decrypt($ciphertext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
if (!is_string($plainText) || $plainText === '') {
return [
'success' => false,
'error' => 'No se pudo descifrar la respuesta del estado público de Shalom.',
];
}
$decodedPlain = json_decode($plainText, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPlain)) {
return [
'success' => false,
'error' => 'Shalom devolvió un estado público imposible de interpretar.',
'details' => $plainText,
];
}
return [
'success' => true,
'data' => $decodedPlain,
];
}
function call_public_shalom_status_api(string $orderNumber): array {
$authorization = build_shalom_web_authorization();
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://serviceswebapi.shalomcontrol.com/api/v1/web/rastrea/estados',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'ose_id' => $orderNumber,
],
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: ' . $authorization,
'Origin: https://shalom.com.pe',
'Referer: https://shalom.com.pe/',
'X-Requested-With: XMLHttpRequest',
],
CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
return [
'success' => false,
'error' => 'Error consultando el estado público de Shalom.',
'details' => $curlError,
];
}
if ($response === false || $response === null || $response === '') {
return [
'success' => false,
'error' => 'Shalom no devolvió respuesta para el estado público.',
'details' => 'HTTP ' . ($httpCode ?: 'desconocido'),
];
}
$decodedTransport = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedTransport)) {
return [
'success' => false,
'error' => 'La respuesta del estado público de Shalom no es JSON válido.',
'details' => $response,
];
}
$decrypted = decrypt_shalom_public_payload($decodedTransport);
if (empty($decrypted['success'])) {
return $decrypted;
}
$payload = $decrypted['data'];
$success = !empty($payload['success']);
$message = is_array($payload) ? trim((string) ($payload['message'] ?? $payload['error'] ?? '')) : '';
if ($httpCode >= 400 || !$success) {
return [
'success' => false,
'error' => $message !== '' ? 'Estado público Shalom: ' . $message : 'Shalom no pudo devolver el estado real del envío.',
'details' => $payload,
];
}
return [
'success' => true,
'data' => $payload,
];
}
function infer_status_detail_text(string $statusMessage): string {
$upper = mb_strtoupper(trim($statusMessage), 'UTF-8');
if ($upper === '') {
return 'Estado actualizado en Shalom.';
}
if (str_contains($upper, 'ENTREGADO') || str_contains($upper, 'COMPLETADO')) {
return 'El pedido ha sido entregado satisfactoriamente.';
}
if (str_contains($upper, 'REPARTO')) {
return 'El pedido está en reparto final.';
}
if (str_contains($upper, 'DESTINO')) {
return 'El pedido llegó a la ciudad de destino y está listo para el siguiente paso.';
}
if (str_contains($upper, 'TRANSITO') || str_contains($upper, 'TRÁNSITO')) {
return 'Rumbo a su destino.';
}
if (str_contains($upper, 'ORIGEN') || str_contains($upper, 'REGISTRADO')) {
return 'El pedido fue registrado en la agencia de origen.';
}
return 'Estado actualizado en Shalom.';
}
function build_status_api_response(string $orderNumber, string $orderCode, array $statusPayload, ?array $scraped = null): array {
$statusMessage = infer_status_message_from_payload($statusPayload);
$statusData = isset($statusPayload['data']) && is_array($statusPayload['data'])
? $statusPayload['data']
: [];
if ($scraped !== null) {
[$dateIso, $dateText] = parse_shalom_tracking_date($scraped['date_text'] ?? '');
if (!isset($statusData['registrado']) || !is_array($statusData['registrado'])) {
$statusData['registrado'] = [
'fecha' => $dateIso,
'fecha_text' => $dateText,
];
} else {
if ($dateIso !== null && empty($statusData['registrado']['fecha'])) {
$statusData['registrado']['fecha'] = $dateIso;
}
if ($dateText !== null && empty($statusData['registrado']['fecha_text'])) {
$statusData['registrado']['fecha_text'] = $dateText;
}
}
}
$detailText = $scraped['description'] ?? '';
if (trim((string) $detailText) === '') {
$detailText = infer_status_detail_text($statusMessage);
}
return [
'source' => $scraped !== null ? 'shalom_public_status+web' : 'shalom_public_status',
'search' => [
'success' => true,
'data' => [
'origen' => ['nombre' => 'N/A'],
'destino' => ['nombre' => 'N/A', 'direccion' => ''],
'tracking_url' => $scraped['tracking_url'] ?? build_public_tracking_url($orderNumber, $orderCode),
'internal_order_number' => $scraped['internal_order_number'] ?? null,
'external_order_number' => $orderNumber,
'order_code' => $orderCode,
'detail_text' => $detailText,
'requires_login' => !empty($scraped['requires_login']),
],
],
'statuses' => [
'message' => $statusMessage,
'raw_message' => isset($statusPayload['message']) ? trim((string) $statusPayload['message']) : null,
'data' => $statusData,
],
];
}
function run_shalom_web_scraper(string $orderNumber, string $orderCode): array {
if (!function_exists('exec')) {
return [
@ -148,6 +452,67 @@ function run_shalom_web_scraper(string $orderNumber, string $orderCode): array {
return $decoded;
}
function run_shalom_search_api_lookup(string $orderNumber, string $orderCode): array {
if (!function_exists('exec')) {
return [
'success' => false,
'error' => 'La función exec está deshabilitada en el servidor.',
];
}
$scriptPath = __DIR__ . '/includes/shalom_api_lookup.js';
if (!is_readable($scriptPath)) {
return [
'success' => false,
'error' => 'No se encontró el helper local de búsqueda de Shalom.',
];
}
$nodeBinary = resolve_binary(['/usr/bin/node', '/usr/local/bin/node'], 'node');
$timeoutBinary = resolve_binary(['/usr/bin/timeout', '/bin/timeout'], 'timeout');
$commandParts = [
escapeshellarg($timeoutBinary),
'80s',
escapeshellarg($nodeBinary),
escapeshellarg($scriptPath),
escapeshellarg($orderNumber),
escapeshellarg($orderCode),
];
$command = implode(' ', $commandParts) . ' 2>&1';
$output = [];
$exitCode = 0;
exec($command, $output, $exitCode);
$rawOutput = trim(implode("\n", $output));
if ($rawOutput === '') {
return [
'success' => false,
'error' => 'La búsqueda oficial de Shalom no devolvió respuesta.',
'details' => 'Proceso sin salida útil.',
];
}
$decoded = json_decode($rawOutput, true);
if (!is_array($decoded)) {
return [
'success' => false,
'error' => 'Respuesta inválida del helper oficial de Shalom.',
'details' => $rawOutput,
];
}
if ($exitCode !== 0 && empty($decoded['success'])) {
if (empty($decoded['details'])) {
$decoded['details'] = $rawOutput;
}
return $decoded;
}
return $decoded;
}
function build_scraper_response(string $orderNumber, string $orderCode, array $scraped): array {
[$dateIso, $dateText] = parse_shalom_tracking_date($scraped['date_text'] ?? '');
@ -167,7 +532,7 @@ function build_scraper_response(string $orderNumber, string $orderCode, array $s
],
],
'statuses' => [
'message' => $scraped['status'] ?? 'No disponible',
'message' => normalize_shalom_status_label((string) ($scraped['status'] ?? 'No disponible')),
'data' => [
'registrado' => [
'fecha' => $dateIso,
@ -269,6 +634,8 @@ function call_official_shalom_api(string $orderNumber, string $orderCode, ?strin
// Inputs
$orderNumber = trim($_GET['orderNumber'] ?? '');
$orderCode = trim($_GET['orderCode'] ?? '');
$includeDetailsRaw = trim((string) ($_GET['includeDetails'] ?? ''));
$includeDetails = in_array(strtolower($includeDetailsRaw), ['1', 'true', 'yes', 'si', 'sí'], true);
if ($orderNumber === '' || $orderCode === '') {
respond_json(400, ['error' => 'Número de orden y código de orden son requeridos.'], $jsonOptions);
@ -293,8 +660,58 @@ if (!$apiKey) {
$attemptErrors = [];
// Prioridad: rastreo web público primero.
// En esta instancia es la vía más estable para mostrar el estado rápido en la columna.
// Fallback 0: resolver el pedido con la búsqueda oficial de Shalom y luego pedir su estado real.
$searchApiAttempt = run_shalom_search_api_lookup($orderNumber, $orderCode);
if (!empty($searchApiAttempt['success']) && !empty($searchApiAttempt['search']) && !empty($searchApiAttempt['status']) && is_array($searchApiAttempt['search']) && is_array($searchApiAttempt['status'])) {
$resolvedOrderNumber = $searchApiAttempt['search']['data']['ose_id'] ?? null;
$resolvedOrderNumber = is_scalar($resolvedOrderNumber) ? (string) $resolvedOrderNumber : null;
$trackingUrl = $searchApiAttempt['resolved']['tracking_url'] ?? build_public_tracking_url(
$resolvedOrderNumber !== null ? $resolvedOrderNumber : $orderNumber,
$orderCode
);
$apiResponse = build_status_api_response($orderNumber, $orderCode, $searchApiAttempt['status'], [
'internal_order_number' => $resolvedOrderNumber,
'tracking_url' => $trackingUrl,
'description' => '',
'requires_login' => false,
]);
$apiResponse['source'] = 'shalom_search_api';
respond_json(200, $apiResponse, $jsonOptions);
}
if (!empty($searchApiAttempt['error'])) {
$attemptErrors[] = 'Búsqueda API Shalom: ' . $searchApiAttempt['error'];
}
if (!empty($searchApiAttempt['details'])) {
$details = is_string($searchApiAttempt['details']) ? $searchApiAttempt['details'] : json_encode($searchApiAttempt['details'], $jsonOptions);
if ($details) {
$attemptErrors[] = 'Detalle búsqueda API: ' . $details;
}
}
$statusAttempt = call_public_shalom_status_api($orderNumber);
if (!empty($statusAttempt['success']) && !empty($statusAttempt['data']) && is_array($statusAttempt['data'])) {
if ($includeDetails) {
$scraperAttempt = run_shalom_web_scraper($orderNumber, $orderCode);
if (!empty($scraperAttempt['success'])) {
respond_json(200, build_status_api_response($orderNumber, $orderCode, $statusAttempt['data'], $scraperAttempt), $jsonOptions);
}
}
respond_json(200, build_status_api_response($orderNumber, $orderCode, $statusAttempt['data']), $jsonOptions);
}
if (!empty($statusAttempt['error'])) {
$attemptErrors[] = 'Estado público: ' . $statusAttempt['error'];
}
if (!empty($statusAttempt['details'])) {
$details = is_string($statusAttempt['details']) ? $statusAttempt['details'] : json_encode($statusAttempt['details'], $jsonOptions);
if ($details) {
$attemptErrors[] = 'Detalle estado público: ' . $details;
}
}
// Fallback 1: rastreo web visual (más pesado, pero útil si el estado público falla).
$scraperAttempt = run_shalom_web_scraper($orderNumber, $orderCode);
if (!empty($scraperAttempt['success'])) {
respond_json(200, build_scraper_response($orderNumber, $orderCode, $scraperAttempt), $jsonOptions);
@ -309,6 +726,7 @@ if (!empty($scraperAttempt['details'])) {
}
}
// Fallback 2: API oficial antigua.
$officialAttempt = call_official_shalom_api($orderNumber, $orderCode, $apiKey ?: null);
if (!empty($officialAttempt['success']) && !empty($officialAttempt['data']) && is_array($officialAttempt['data'])) {
respond_json(200, $officialAttempt['data'], $jsonOptions);
@ -325,6 +743,6 @@ if (!empty($officialAttempt['details'])) {
respond_json(502, [
'error' => 'No se pudo consultar el estado en Shalom.',
'details' => !empty($attemptErrors) ? implode(' | ', $attemptErrors) : 'No hubo respuesta útil de la API ni del rastreo web.',
'details' => !empty($attemptErrors) ? implode(' | ', $attemptErrors) : 'No hubo respuesta útil del estado público, del rastreo web ni de la API.',
'tracking_url' => build_public_tracking_url($orderNumber, $orderCode),
], $jsonOptions);

View File

@ -8,6 +8,13 @@ if (!isset($_SESSION['user_id'])) {
exit;
}
$user_role = $_SESSION['user_role'] ?? 'Asesor';
if ($user_role === 'Asesor') {
http_response_code(403);
echo json_encode(['error' => 'No autorizado para actualizar la clave.']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['id']) || !isset($data['value'])) {

View File

@ -22,12 +22,51 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo = db();
$valid_states = ['RUTA_CONTRAENTREGA', 'PENDIENTE', 'NO CONTESTO, VOLVER A LLAMAR', 'CANCELADO', 'REPROGRAMADO', 'ENTREGA EXITOSA', 'RETORNADO', 'NO CONTESTO, DEVOLVER LLAMADA', 'COMPLETADO ✅', 'Gestion'];
if (!in_array($estado, $valid_states)) {
if (!in_array($estado, $valid_states, true)) {
echo json_encode(['success' => false, 'message' => 'Estado inválido: ' . $estado]);
exit;
}
$stmt = $pdo->prepare("UPDATE pedidos SET estado = ? WHERE id = ?");
if ($estado === 'COMPLETADO ✅') {
$stmt_pedido = $pdo->prepare('SELECT numero_operacion, banco, voucher_restante_path FROM pedidos WHERE id = ?');
$stmt_pedido->execute([$pedido_id]);
$pedido = $stmt_pedido->fetch(PDO::FETCH_ASSOC);
if (!$pedido) {
echo json_encode(['success' => false, 'message' => 'Pedido no encontrado']);
exit;
}
$numero_operacion = trim((string)($pedido['numero_operacion'] ?? ''));
$banco = trim((string)($pedido['banco'] ?? ''));
$voucher_restante_path = trim((string)($pedido['voucher_restante_path'] ?? ''));
$voucher_restante_exists = $voucher_restante_path !== '' && file_exists(__DIR__ . '/' . $voucher_restante_path);
if ($numero_operacion === '') {
echo json_encode(['success' => false, 'message' => 'Antes de completar el pedido debes registrar el número de operación.']);
exit;
}
if (strlen($numero_operacion) < 6) {
echo json_encode(['success' => false, 'message' => 'El número de operación debe tener al menos 6 dígitos antes de completar el pedido.']);
exit;
}
if ($banco === '') {
echo json_encode(['success' => false, 'message' => 'Antes de completar el pedido debes seleccionar el banco.']);
exit;
}
if (!$voucher_restante_exists) {
echo json_encode(['success' => false, 'message' => 'Antes de completar el pedido debes subir el voucher del pago restante.']);
exit;
}
$stmt = $pdo->prepare('UPDATE pedidos SET estado = ?, fecha_completado = COALESCE(fecha_completado, NOW()) WHERE id = ?');
} else {
$stmt = $pdo->prepare('UPDATE pedidos SET estado = ? WHERE id = ?');
}
$result = $stmt->execute([$estado, $pedido_id]);
if ($result) {

View File

@ -8,6 +8,8 @@ if (!isset($_SESSION['user_id'])) {
exit;
}
$user_role = $_SESSION['user_role'] ?? 'Asesor';
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['id']) || !isset($data['field']) || !isset($data['value'])) {
@ -20,6 +22,12 @@ $pedido_id = $data['id'];
$field = $data['field'];
$value = $data['value'];
if ($field === 'clave' && $user_role === 'Asesor') {
http_response_code(403);
echo json_encode(['error' => 'No autorizado para actualizar la clave.']);
exit;
}
// Whitelist allowed fields for security
$allowed_fields = ['numero_operacion', 'banco', 'clave', 'fecha_recojo', 'observacion', 'descargo', 'notas', 'nota_adicional'];
if (!in_array($field, $allowed_fields)) {