Autosave: 20260723-201741

This commit is contained in:
Flatlogic Bot 2026-07-23 20:16:17 +00:00
parent 2924efbd4b
commit 4a8d769440
4 changed files with 788 additions and 37 deletions

View File

@ -49,6 +49,7 @@ if (isset($_GET['ajax']) && $_GET['ajax'] === 'panel_status') {
$panelOrders = array_values(array_filter($panelOrders, static function (array $order): bool {
return empty($order['eliminado']);
}));
$panelOrders = cc_test_mark_recoverables_duplicate_orders($pdo, $panelOrders, $panelStoreKey);
} else {
$stmtPanel = $pdo->prepare("
SELECT source_key, user_id, estado, assigned_at, updated_at, eliminado_at
@ -60,9 +61,16 @@ if (isset($_GET['ajax']) && $_GET['ajax'] === 'panel_status') {
$panelOrders = $stmtPanel->fetchAll(PDO::FETCH_ASSOC);
}
$panelSignatureParts = [cc_test_panel_orders_signature($panelOrders)];
if ($panelStoreKey === 'otra_tienda') {
$panelAssessors = cc_test_fetch_assessors($pdo);
$panelRepartoSettings = cc_test_fetch_reparto_settings($pdo, $panelStoreKey, $panelAssessors);
$panelSignatureParts[] = cc_test_reparto_settings_signature($panelRepartoSettings);
}
echo json_encode([
'success' => true,
'signature' => cc_test_panel_orders_signature($panelOrders),
'signature' => sha1(implode('|', $panelSignatureParts)),
'total' => count($panelOrders),
]);
} catch (Throwable $exception) {
@ -253,6 +261,7 @@ function cc_test_panel_orders_signature(array $orders): string
'source_key' => (string) ($order['source_key'] ?? ''),
'user_id' => (int) ($order['user_id'] ?? 0),
'estado' => cc_test_normalize_state((string) ($order['estado'] ?? '')),
'pedido_repetido_en_tienda' => !empty($order['pedido_repetido_en_tienda']) ? 1 : 0,
'assigned_at' => trim((string) ($order['assigned_at'] ?? '')),
'updated_at' => trim((string) ($order['updated_at'] ?? ($order['seguimiento_actualizado'] ?? ''))),
'eliminado_at' => trim((string) ($order['eliminado_at'] ?? '')),
@ -391,8 +400,8 @@ if (!isset($allowedViews[$view])) {
}
$viewStatesNote = $isTuaniRecuperablesStore
? 'Estados disponibles: <strong>Carritos recuperables</strong>, <strong>Seguimiento</strong>, <strong>Observados</strong>, <strong>Confirmados</strong>, <strong>Recuperados</strong> y <strong>Todos</strong>. Esta bandeja compartida está disponible para cualquier asesora y, cuando una asesora gestiona un carrito recuperable, el sistema lo asigna automáticamente a quien lo trabaja.'
: 'Estados disponibles: Por llamar, Devolver llamada, Observado, Se envió número de cuenta, Confirmado contraentrega, Confirmado envío, Cancelado y Repetido. <strong>Promo Final</strong> se habilita desde el Día 4 y exige imagen de sustento para mover el pedido.';
? 'Estados disponibles: <strong>Carritos recuperables</strong>, <strong>Seguimiento</strong>, <strong>Observados</strong>, <strong>Confirmados</strong>, <strong>Recuperados</strong> y <strong>Todos</strong>. Esta bandeja compartida está disponible para cualquier asesora y, cuando una asesora gestiona un carrito recuperable, el sistema lo asigna automáticamente a quien lo trabaja. Si el teléfono ya existe en <strong>TUANI principal</strong>, se marca en gris como <strong>Pedido repetido en tienda</strong> solo en esta bandeja.'
: 'Estados disponibles: Por llamar, Devolver llamada, Observado, Se envió número de cuenta, Confirmado contraentrega, Confirmado envío, Cancelado y Repetido. <strong>Promo Final</strong> se habilita desde el Día 4 y exige imagen de sustento para mover el pedido. Además, esta bandeja principal incluye reparto intercalado diario: en modo automático reparte pedidos sin asignar entre asesoras activas; en modo manual el supervisor los mueve pedido por pedido.';
$errorMessage = null;
$noticeMessage = null;
@ -421,11 +430,37 @@ $stats = [
];
$panelSignature = '';
$repartoSettings = [
'store_key' => $storeKey,
'reparto_mode' => 'manual',
'rotation_date' => null,
'rotation_index' => 0,
'assessor_states' => [],
];
$repartoPreview = [
'mode' => 'manual',
'mode_label' => 'Manual',
'sequence' => [],
'ordered_keys' => [],
'states' => [],
'available_count' => 0,
'total_count' => 0,
'rotation_index' => 0,
'rotation_date' => null,
'next_key' => null,
'next_label' => '',
'next_position' => null,
];
$repartoUnassignedCount = 0;
$repartoSourceOrders = [];
try {
cc_test_ensure_tracking_table($pdo);
cc_test_ensure_historial_llamadas_table($pdo);
$assessors = cc_test_fetch_assessors($pdo);
if ($isMainTuaniStore) {
$repartoSettings = cc_test_fetch_reparto_settings($pdo, $storeKey, $assessors);
}
try {
$stmtProductos = $pdo->query("SELECT id, nombre FROM products ORDER BY nombre ASC");
@ -469,47 +504,115 @@ try {
return empty($order['eliminado']);
}));
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'assign_assessor') {
$sourceKey = trim((string) ($_POST['source_key'] ?? ''));
$targetValue = trim((string) ($_POST['target_assessor'] ?? ''));
if ($sourceKey === '' || !preg_match('/^[a-f0-9]{40}$/', $sourceKey)) {
throw new RuntimeException('Pedido de prueba inválido para asignar.');
}
$postedAction = trim((string) ($_POST['action'] ?? ''));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($postedAction === 'assign_assessor') {
$sourceKey = trim((string) ($_POST['source_key'] ?? ''));
$targetValue = trim((string) ($_POST['target_assessor'] ?? ''));
if ($sourceKey === '' || !preg_match('/^[a-f0-9]{40}$/', $sourceKey)) {
throw new RuntimeException('Pedido de prueba inválido para asignar.');
}
$targetKey = trim(mb_strtoupper($targetValue));
$targetKey = preg_replace('/\s+/', ' ', $targetKey) ?? $targetKey;
$selectedAssessor = $targetKey !== '' ? ($assessors[$targetKey] ?? null) : null;
if ($targetKey !== '' && !$selectedAssessor) {
throw new RuntimeException('No se encontró la asesora seleccionada.');
}
$targetKey = trim(mb_strtoupper($targetValue));
$targetKey = preg_replace('/\s+/', ' ', $targetKey) ?? $targetKey;
$selectedAssessor = $targetKey !== '' ? ($assessors[$targetKey] ?? null) : null;
if ($targetKey !== '' && !$selectedAssessor) {
throw new RuntimeException('No se encontró la asesora seleccionada.');
}
$targetUserId = $selectedAssessor ? (int) $selectedAssessor['id'] : null;
$targetUserId = $selectedAssessor ? (int) $selectedAssessor['id'] : null;
$stmtCurrent = $pdo->prepare('SELECT user_id FROM callcenter_test_tracking WHERE source_key = ? LIMIT 1');
$stmtCurrent->execute([$sourceKey]);
$currentUserIdRaw = $stmtCurrent->fetchColumn();
$currentUserId = $currentUserIdRaw !== null ? ((int) $currentUserIdRaw > 0 ? (int) $currentUserIdRaw : null) : null;
$stmtCurrent = $pdo->prepare('SELECT user_id FROM callcenter_test_tracking WHERE source_key = ? LIMIT 1');
$stmtCurrent->execute([$sourceKey]);
$currentAssignedUserIdRaw = $stmtCurrent->fetchColumn();
$currentAssignedUserId = $currentAssignedUserIdRaw !== null ? ((int) $currentAssignedUserIdRaw > 0 ? (int) $currentAssignedUserIdRaw : null) : null;
// Si el pedido ya está asignado, evitamos que se reasigne a otra asesora (solo permitimos dejarlo sin asignar).
if ($currentUserId !== null && $targetUserId !== null && (int) $currentUserId !== (int) $targetUserId) {
$currentLabel = 'otra asesora';
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $currentUserId) {
$currentLabel = (string) ($assessor['label'] ?? $assessorKey);
break;
// Si el pedido ya está asignado, evitamos que se reasigne a otra asesora (solo permitimos dejarlo sin asignar).
if ($currentAssignedUserId !== null && $targetUserId !== null && (int) $currentAssignedUserId !== (int) $targetUserId) {
$currentLabel = 'otra asesora';
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $currentAssignedUserId) {
$currentLabel = (string) ($assessor['label'] ?? $assessorKey);
break;
}
}
throw new RuntimeException('Este pedido ya está asignado a ' . $currentLabel . '. Para cambiarlo primero déjalo en "Sin asignar".');
}
cc_test_upsert_assignee($pdo, $sourceKey, $targetUserId);
$noticeMessage = $selectedAssessor
? 'Pedido asignado a ' . $selectedAssessor['label'] . '.'
: 'Pedido dejado sin asignar.';
if ($isMainTuaniStore && $currentAssignedUserId === null && $targetUserId !== null) {
$repartoPreviewBefore = cc_test_reparto_preview($assessors, $repartoSettings);
$orderedKeys = $repartoPreviewBefore['ordered_keys'] ?? [];
$targetPosition = array_search($targetKey, $orderedKeys, true);
if ($targetPosition !== false && !empty($orderedKeys)) {
$repartoSettings['rotation_date'] = (new DateTimeImmutable('today'))->format('Y-m-d');
$repartoSettings['rotation_index'] = (((int) $targetPosition) + 1) % count($orderedKeys);
cc_test_upsert_reparto_settings($pdo, $storeKey, $repartoSettings);
$repartoSettings = cc_test_fetch_reparto_settings($pdo, $storeKey, $assessors);
}
}
throw new RuntimeException('Este pedido ya está asignado a ' . $currentLabel . '. Para cambiarlo primero déjalo en "Sin asignar".');
$tracking = drive_test_fetch_tracking($pdo, array_column($orders, 'source_key'));
$orders = drive_test_merge_tracking($orders, $tracking);
} elseif ($postedAction === 'save_reparto_config') {
if (!$isMainTuaniStore) {
throw new RuntimeException('Esta configuración solo aplica a TUANI principal.');
}
$postedMode = cc_test_normalize_reparto_mode($_POST['reparto_mode'] ?? 'manual');
$postedStates = $_POST['assessor_state'] ?? [];
if (!is_array($postedStates)) {
$postedStates = [];
}
$repartoSettings['reparto_mode'] = $postedMode;
$repartoSettings['assessor_states'] = cc_test_reparto_normalize_states($postedStates, $assessors);
cc_test_upsert_reparto_settings($pdo, $storeKey, $repartoSettings);
$noticeMessage = 'Configuración de reparto guardada.';
} elseif ($postedAction === 'run_daily_reparto') {
if (!$isMainTuaniStore) {
throw new RuntimeException('El reparto intercalado solo está disponible en TUANI principal.');
}
$repartoResult = cc_test_apply_daily_reparto($pdo, $storeKey, $orders, $assessors, $repartoSettings);
$assignedCount = (int) ($repartoResult['assigned_count'] ?? 0);
$noticeMessage = $assignedCount > 0
? 'Reparto intercalado: ' . $assignedCount . ' pedido(s) asignado(s).'
: (string) ($repartoResult['message'] ?? 'No había pedidos pendientes o no había asesoras disponibles.');
if ($assignedCount > 0) {
$tracking = drive_test_fetch_tracking($pdo, array_column($orders, 'source_key'));
$orders = drive_test_merge_tracking($orders, $tracking);
}
$repartoSettings = $repartoResult['settings'] ?? $repartoSettings;
}
}
cc_test_upsert_assignee($pdo, $sourceKey, $targetUserId);
$noticeMessage = $selectedAssessor
? 'Pedido asignado a ' . $selectedAssessor['label'] . '.'
: 'Pedido dejado sin asignar.';
if ($isMainTuaniStore && ($repartoSettings['reparto_mode'] ?? 'manual') === 'automatico') {
$autoRepartoResult = cc_test_apply_daily_reparto($pdo, $storeKey, $orders, $assessors, $repartoSettings);
$autoAssignedCount = (int) ($autoRepartoResult['assigned_count'] ?? 0);
if ($autoAssignedCount > 0) {
$noticeMessage = trim((string) ($noticeMessage !== null ? $noticeMessage . ' ' : '') . 'Reparto automático: ' . $autoAssignedCount . ' pedido(s) asignado(s).');
$tracking = drive_test_fetch_tracking($pdo, array_column($orders, 'source_key'));
$orders = drive_test_merge_tracking($orders, $tracking);
}
$repartoSettings = $autoRepartoResult['settings'] ?? $repartoSettings;
}
$tracking = drive_test_fetch_tracking($pdo, array_column($orders, 'source_key'));
$orders = drive_test_merge_tracking($orders, $tracking);
if ($isMainTuaniStore) {
$repartoSettings = cc_test_fetch_reparto_settings($pdo, $storeKey, $assessors);
$repartoPreview = cc_test_reparto_preview($assessors, $repartoSettings);
}
$repartoSourceOrders = $orders;
$repartoUnassignedCount = 0;
foreach ($repartoSourceOrders as $repartoOrder) {
if ((int) ($repartoOrder['user_id'] ?? 0) <= 0) {
$repartoUnassignedCount++;
}
}
$sourceKeys = array_column($orders, 'source_key');
@ -599,6 +702,8 @@ try {
}
unset($order);
$orders = cc_test_mark_recoverables_duplicate_orders($pdo, $orders, $storeKey);
$performanceOrders = $orders;
if ($selectedAssessorFilter !== '') {
$performanceOrders = array_values(array_filter($orders, static function (array $order) use ($selectedAssessorFilter): bool {
@ -639,6 +744,9 @@ try {
]);
$panelSignature = cc_test_panel_orders_signature($orders);
if ($isMainTuaniStore) {
$panelSignature .= '|'. cc_test_reparto_settings_signature($repartoSettings);
}
$visibleOrders = array_values(array_filter($orders, static function (array $order) use ($view, $selectedAssessorFilter, $todayStart): bool {
if ($selectedAssessorFilter !== '' && !($order['matches_assessor_filter'] ?? false)) {
@ -1046,6 +1154,107 @@ require_once 'layout_header.php';
</section>
<?php endif; ?>
<?php if ($isMainTuaniStore): ?>
<?php
$repartoModeLabel = (string) ($repartoPreview['mode_label'] ?? 'Manual');
$repartoModeClass = ($repartoPreview['mode'] ?? 'manual') === 'automatico'
? 'bg-success-subtle text-success-emphasis border'
: 'bg-secondary-subtle text-secondary-emphasis border';
$repartoNextLabel = (string) ($repartoPreview['next_label'] ?? '');
$repartoAvailableCount = (int) ($repartoPreview['available_count'] ?? 0);
$repartoTotalCount = (int) ($repartoPreview['total_count'] ?? 0);
$repartoRotationDateLabel = !empty($repartoSettings['rotation_date'])
? cc_test_format_date((string) $repartoSettings['rotation_date'], 'Sin fecha')
: 'Sin ciclo aún';
?>
<section class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3 align-items-start">
<div class="col-12 col-xl-7">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="badge rounded-pill <?php echo htmlspecialchars($repartoModeClass); ?>">Reparto <?php echo htmlspecialchars($repartoModeLabel); ?></span>
<?php if ($repartoNextLabel !== ''): ?>
<span class="badge rounded-pill bg-primary-subtle text-primary-emphasis border">Siguiente: <?php echo htmlspecialchars($repartoNextLabel); ?></span>
<?php else: ?>
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis border">Sin asesoras disponibles</span>
<?php endif; ?>
<span class="badge rounded-pill bg-light text-dark border">Sin asignar: <?php echo (int) $repartoUnassignedCount; ?></span>
</div>
<h2 class="h5 fw-bold mb-2">Reparto intercalado diario</h2>
<p class="text-muted small mb-3">Las asesoras activas reciben los pedidos sin asignar en orden. Si una está pausada o ausente, se salta automáticamente. El supervisor puede cambiar el estado en cualquier momento.</p>
<div class="d-flex flex-wrap gap-2">
<span class="badge rounded-pill bg-light text-dark border">Asesoras totales: <?php echo (int) $repartoTotalCount; ?></span>
<span class="badge rounded-pill bg-light text-dark border">Disponibles: <?php echo (int) $repartoAvailableCount; ?></span>
<span class="badge rounded-pill bg-light text-dark border">Ciclo: <?php echo htmlspecialchars($repartoRotationDateLabel); ?></span>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<?php foreach (($repartoPreview['sequence'] ?? []) as $item): ?>
<span class="badge rounded-pill <?php echo htmlspecialchars((string) ($item['badge_class'] ?? 'bg-light text-dark border')); ?>">
<?php echo htmlspecialchars((string) ($item['label'] ?? '')); ?> · <?php echo htmlspecialchars((string) ($item['state_label'] ?? '')); ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php if ($isAdmin): ?>
<div class="col-12 col-xl-5">
<form method="post" class="border rounded-4 p-3 bg-light h-100">
<input type="hidden" name="action" value="save_reparto_config">
<input type="hidden" name="store" value="<?php echo htmlspecialchars($storeKey); ?>">
<input type="hidden" name="view" value="<?php echo htmlspecialchars($view); ?>">
<?php if ($selectedAssessorFilter !== ''): ?>
<input type="hidden" name="assessor" value="<?php echo htmlspecialchars($selectedAssessorFilter); ?>">
<?php endif; ?>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1" for="repartoMode">Modo de reparto</label>
<select name="reparto_mode" id="repartoMode" class="form-select">
<option value="manual"<?php echo ($repartoPreview['mode'] ?? 'manual') === 'manual' ? ' selected' : ''; ?>>Manual</option>
<option value="automatico"<?php echo ($repartoPreview['mode'] ?? 'manual') === 'automatico' ? ' selected' : ''; ?>>Automático intercalado</option>
</select>
</div>
<div class="row g-2">
<?php foreach (($repartoPreview['sequence'] ?? []) as $item): ?>
<div class="col-12">
<div class="bg-white border rounded-4 p-2">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<div class="fw-semibold"><?php echo htmlspecialchars((string) ($item['label'] ?? '')); ?></div>
<span class="badge rounded-pill <?php echo htmlspecialchars((string) ($item['badge_class'] ?? 'bg-light text-dark border')); ?>"><?php echo htmlspecialchars((string) ($item['state_label'] ?? '')); ?></span>
</div>
<select name="assessor_state[<?php echo htmlspecialchars((string) ($item['key'] ?? '')); ?>]" class="form-select form-select-sm">
<option value="disponible"<?php echo ($item['state'] ?? 'disponible') === 'disponible' ? ' selected' : ''; ?>>Disponible</option>
<option value="pausada"<?php echo ($item['state'] ?? 'disponible') === 'pausada' ? ' selected' : ''; ?>>Pausada</option>
<option value="ausente"<?php echo ($item['state'] ?? 'disponible') === 'ausente' ? ' selected' : ''; ?>>Ausente</option>
</select>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="submit" class="btn btn-primary">Guardar configuración</button>
</div>
</form>
<form method="post" class="mt-3">
<input type="hidden" name="action" value="run_daily_reparto">
<input type="hidden" name="store" value="<?php echo htmlspecialchars($storeKey); ?>">
<input type="hidden" name="view" value="<?php echo htmlspecialchars($view); ?>">
<?php if ($selectedAssessorFilter !== ''): ?>
<input type="hidden" name="assessor" value="<?php echo htmlspecialchars($selectedAssessorFilter); ?>">
<?php endif; ?>
<button type="submit" class="btn btn-outline-primary w-100">Distribuir pedidos pendientes</button>
</form>
</div>
<?php else: ?>
<div class="col-12 col-xl-5">
<div class="border rounded-4 p-3 bg-light h-100">
<div class="small text-uppercase text-muted fw-semibold mb-2">Control de reparto</div>
<div class="text-muted small mb-0">Solo el administrador puede cambiar el modo o pausar asesoras. ves aquí el orden intercalado y el siguiente turno.</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php endif; ?>
<section class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-2">
<div>
@ -1129,9 +1338,13 @@ require_once 'layout_header.php';
}
}
}
$rowBackgroundStyle = cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR'));
if ($isTuaniRecuperablesStore && !empty($order['pedido_repetido_en_tienda'])) {
$rowBackgroundStyle .= '--cc-row-bg:#CCCCCC;';
}
ob_start();
?>
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars(cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR'))); ?>">
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars($rowBackgroundStyle); ?>">
<td>
<?php if ($followupDayInfo !== null): ?>
<div class="small text-uppercase fw-semibold <?php echo htmlspecialchars($followupDayInfo['text_class']); ?> mb-1"><?php echo htmlspecialchars($followupDayInfo['display_label']); ?></div>
@ -1164,6 +1377,9 @@ require_once 'layout_header.php';
<td>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="badge rounded-pill <?php echo htmlspecialchars($badgeClass); ?>"><?php echo htmlspecialchars(cc_test_state_label($order['estado'])); ?></span>
<?php if (!empty($order['pedido_repetido_en_tienda'])): ?>
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis border"><?php echo htmlspecialchars((string) ($order['pedido_repetido_en_tienda_label'] ?? 'Pedido repetido en tienda')); ?></span>
<?php endif; ?>
<span class="badge rounded-pill bg-light text-dark border"><span class="js-call-count-number" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>"><?php echo (int) $order['total_llamadas']; ?></span> llamadas</span>
<?php if (!empty($logisticaPendienteLabel)): ?>
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis border"><?php echo htmlspecialchars($logisticaPendienteLabel); ?></span>

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `callcenter_test_reparto_settings` (
`store_key` VARCHAR(50) NOT NULL,
`reparto_mode` VARCHAR(20) NOT NULL DEFAULT 'manual',
`rotation_date` DATE NULL,
`rotation_index` INT NOT NULL DEFAULT 0,
`assessor_states_json` LONGTEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`store_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -297,7 +297,7 @@ if (!isset($allowedViews[$view])) {
}
$viewStatesNote = $isTuaniRecuperablesStore
? 'Estados disponibles: <strong>Carritos recuperables</strong>, <strong>Seguimiento</strong>, <strong>Observados</strong>, <strong>Confirmados</strong>, <strong>Recuperados</strong> y <strong>Todos</strong>. Esta bandeja compartida está disponible para cualquier asesora y, cuando una asesora gestiona un carrito recuperable, el sistema lo asigna automáticamente a quien lo trabaja.'
? 'Estados disponibles: <strong>Carritos recuperables</strong>, <strong>Seguimiento</strong>, <strong>Observados</strong>, <strong>Confirmados</strong>, <strong>Recuperados</strong> y <strong>Todos</strong>. Esta bandeja compartida está disponible para cualquier asesora y, cuando una asesora gestiona un carrito recuperable, el sistema lo asigna automáticamente a quien lo trabaja. Si el teléfono ya existe en <strong>TUANI principal</strong>, se marca en gris como <strong>Pedido repetido en tienda</strong> solo en esta bandeja.'
: 'Estados disponibles: Por llamar, Devolver llamada, Observado, Se envió número de cuenta, Confirmado contraentrega, Confirmado envío, Cancelado y Repetido. <strong>Promo Final</strong> se habilita desde el Día 4 y exige imagen de sustento para mover el pedido.';
$errorMessage = null;
@ -793,6 +793,8 @@ try {
}
unset($order);
$orders = cc_test_mark_recoverables_duplicate_orders($pdo, $orders, $storeKey);
$performanceDashboard = cc_test_build_performance_dashboard($orders, $assessors, 7);
$performanceDashboardHtml = cc_test_render_performance_dashboard($performanceDashboard, [
'title' => 'KPI diario por asesora',
@ -1466,7 +1468,7 @@ require_once 'layout_header.php';
}
ob_start();
?>
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars(cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR'))); ?>">
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars(cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR')) . (!empty($order['pedido_repetido_en_tienda']) ? '--cc-row-bg:#CCCCCC;' : '')); ?>">
<td class="text-center">
<div class="d-flex justify-content-center">
<button
@ -1617,6 +1619,9 @@ require_once 'layout_header.php';
<td>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="badge rounded-pill <?php echo htmlspecialchars($badgeClass); ?>"><?php echo htmlspecialchars(cc_test_state_label($order['estado'])); ?></span>
<?php if (!empty($order['pedido_repetido_en_tienda'])): ?>
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis border"><?php echo htmlspecialchars((string) ($order['pedido_repetido_en_tienda_label'] ?? 'Pedido repetido en tienda')); ?></span>
<?php endif; ?>
<span class="badge rounded-pill bg-light text-dark border"><span class="js-call-count-number" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>"><?php echo (int) $order['total_llamadas']; ?></span> llamadas</span>
<?php if (!empty($logisticaPendienteLabel)): ?>
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis border"><?php echo htmlspecialchars($logisticaPendienteLabel); ?></span>

View File

@ -250,6 +250,526 @@ function cc_test_is_tuani_main_store_key(string $storeKey): bool
return $storeKey === 'otra_tienda';
}
if (!function_exists('cc_test_normalize_phone_digits')) {
function cc_test_normalize_phone_digits(string $value): string
{
$digits = preg_replace('/\D+/', '', trim($value));
return is_string($digits) ? $digits : '';
}
}
if (!function_exists('cc_test_phone_match_keys')) {
function cc_test_phone_match_keys(string $value): array
{
$digits = cc_test_normalize_phone_digits($value);
if ($digits === '') {
return [];
}
$keys = [$digits];
if (strlen($digits) > 2 && strncmp($digits, '00', 2) === 0) {
$withoutInternationalPrefix = substr($digits, 2);
if ($withoutInternationalPrefix !== '') {
$keys[] = $withoutInternationalPrefix;
if (strlen($withoutInternationalPrefix) > 9 && strncmp($withoutInternationalPrefix, '51', 2) === 0) {
$keys[] = substr($withoutInternationalPrefix, 2);
}
}
}
if (strlen($digits) > 9 && strncmp($digits, '51', 2) === 0) {
$keys[] = substr($digits, 2);
}
$keys = array_values(array_unique(array_filter($keys, static fn ($key) => trim((string) $key) !== '')));
return $keys;
}
}
if (!function_exists('cc_test_fetch_store_phone_index')) {
function cc_test_fetch_store_phone_index(PDO $pdo, string $storeKey): array
{
static $cache = [];
$storeKey = trim(mb_strtolower($storeKey));
if ($storeKey === '') {
return [];
}
if (array_key_exists($storeKey, $cache)) {
return $cache[$storeKey];
}
if (!cc_test_table_exists($pdo, 'callcenter_test_orders')) {
$cache[$storeKey] = [];
return [];
}
try {
$stmt = $pdo->prepare("SELECT DISTINCT celular FROM callcenter_test_orders WHERE store_key = ? AND celular IS NOT NULL AND celular <> ''");
$stmt->execute([$storeKey]);
$index = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $phoneValue) {
foreach (cc_test_phone_match_keys((string) $phoneValue) as $phoneKey) {
$index[$phoneKey] = true;
}
}
} catch (Throwable $exception) {
error_log('cc_test_fetch_store_phone_index: ' . $exception->getMessage());
$index = [];
}
$cache[$storeKey] = $index;
return $index;
}
}
if (!function_exists('cc_test_mark_recoverables_duplicate_orders')) {
function cc_test_mark_recoverables_duplicate_orders(PDO $pdo, array $orders, string $storeKey, string $mainStoreKey = 'otra_tienda'): array
{
$storeKey = trim(mb_strtolower($storeKey));
if ($storeKey !== 'tuani_recuperables') {
return $orders;
}
$mainStoreKey = trim(mb_strtolower($mainStoreKey));
if ($mainStoreKey === '') {
return $orders;
}
$mainPhoneIndex = cc_test_fetch_store_phone_index($pdo, $mainStoreKey);
foreach ($orders as &$order) {
$isDuplicate = false;
foreach (cc_test_phone_match_keys((string) ($order['celular'] ?? '')) as $phoneKey) {
if (isset($mainPhoneIndex[$phoneKey])) {
$isDuplicate = true;
break;
}
}
$order['pedido_repetido_en_tienda'] = $isDuplicate;
$order['pedido_repetido_en_tienda_label'] = $isDuplicate ? 'Pedido repetido en tienda' : '';
}
unset($order);
return $orders;
}
}
if (!function_exists('cc_test_ensure_reparto_settings_table')) {
function cc_test_ensure_reparto_settings_table(PDO $pdo): void
{
static $checked = false;
if ($checked) {
return;
}
$pdo->exec("CREATE TABLE IF NOT EXISTS `callcenter_test_reparto_settings` (
`store_key` VARCHAR(50) NOT NULL,
`reparto_mode` VARCHAR(20) NOT NULL DEFAULT 'manual',
`rotation_date` DATE NULL,
`rotation_index` INT NOT NULL DEFAULT 0,
`assessor_states_json` LONGTEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`store_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
$checked = true;
}
}
function cc_test_normalize_reparto_mode(?string $value): string
{
$value = mb_strtolower(trim((string) $value));
return in_array($value, ['automatico', 'automático', 'auto'], true) ? 'automatico' : 'manual';
}
function cc_test_reparto_mode_label(string $mode): string
{
return cc_test_normalize_reparto_mode($mode) === 'automatico' ? 'Automático' : 'Manual';
}
function cc_test_normalize_reparto_state(?string $value): string
{
$value = mb_strtolower(trim((string) $value));
return match ($value) {
'pausada', 'pausado' => 'pausada',
'ausente', 'ausencia' => 'ausente',
default => 'disponible',
};
}
function cc_test_reparto_state_label(string $state): string
{
return match (cc_test_normalize_reparto_state($state)) {
'pausada' => 'Pausada',
'ausente' => 'Ausente',
default => 'Disponible',
};
}
function cc_test_reparto_state_badge_class(string $state): string
{
return match (cc_test_normalize_reparto_state($state)) {
'pausada' => 'bg-warning-subtle text-warning-emphasis border',
'ausente' => 'bg-secondary-subtle text-secondary-emphasis border',
default => 'bg-success-subtle text-success-emphasis border',
};
}
function cc_test_ordered_assessor_keys(array $assessors): array
{
$ordered = [];
$defaultKeys = cc_test_default_assessor_keys();
foreach ($defaultKeys as $key) {
if (isset($assessors[$key])) {
$ordered[] = $key;
}
}
$remaining = array_values(array_diff(array_keys($assessors), $ordered));
if (!empty($remaining)) {
sort($remaining, SORT_NATURAL | SORT_FLAG_CASE);
$ordered = array_merge($ordered, $remaining);
}
return $ordered;
}
function cc_test_reparto_normalize_states($states, array $assessors): array
{
$states = is_array($states) ? $states : [];
$normalized = [];
foreach (cc_test_ordered_assessor_keys($assessors) as $assessorKey) {
$candidateKey = cc_test_normalize_user_key((string) $assessorKey);
$normalized[$candidateKey] = cc_test_normalize_reparto_state($states[$assessorKey] ?? $states[$candidateKey] ?? 'disponible');
}
return $normalized;
}
function cc_test_reparto_preview(array $assessors, array $settings): array
{
$orderedKeys = cc_test_ordered_assessor_keys($assessors);
$states = cc_test_reparto_normalize_states($settings['assessor_states'] ?? [], $assessors);
$sequence = [];
$availableCount = 0;
foreach ($orderedKeys as $assessorKey) {
$state = $states[$assessorKey] ?? 'disponible';
$isAvailable = $state === 'disponible';
if ($isAvailable) {
$availableCount++;
}
$sequence[] = [
'key' => $assessorKey,
'label' => (string) ($assessors[$assessorKey]['label'] ?? $assessorKey),
'state' => $state,
'state_label' => cc_test_reparto_state_label($state),
'badge_class' => cc_test_reparto_state_badge_class($state),
'available' => $isAvailable,
'color_hex' => $assessors[$assessorKey]['color_hex'] ?? null,
];
}
$totalCount = count($orderedKeys);
$rotationIndex = max(0, (int) ($settings['rotation_index'] ?? 0));
$todayKey = (new DateTimeImmutable('today'))->format('Y-m-d');
$rotationDate = trim((string) ($settings['rotation_date'] ?? ''));
$effectiveIndex = ($totalCount > 0 && $rotationDate === $todayKey) ? ($rotationIndex % $totalCount) : 0;
$nextKey = null;
$nextPosition = null;
if ($totalCount > 0) {
for ($offset = 0; $offset < $totalCount; $offset++) {
$position = ($effectiveIndex + $offset) % $totalCount;
$candidateKey = $orderedKeys[$position];
if (($states[$candidateKey] ?? 'disponible') === 'disponible') {
$nextKey = $candidateKey;
$nextPosition = $position;
break;
}
}
}
return [
'mode' => cc_test_normalize_reparto_mode($settings['reparto_mode'] ?? null),
'mode_label' => cc_test_reparto_mode_label((string) ($settings['reparto_mode'] ?? 'manual')),
'sequence' => $sequence,
'ordered_keys' => $orderedKeys,
'states' => $states,
'available_count' => $availableCount,
'total_count' => $totalCount,
'rotation_index' => $effectiveIndex,
'rotation_date' => $rotationDate !== '' ? $rotationDate : null,
'next_key' => $nextKey,
'next_label' => $nextKey !== null ? (string) ($assessors[$nextKey]['label'] ?? $nextKey) : '',
'next_position' => $nextPosition,
];
}
function cc_test_fetch_reparto_settings(PDO $pdo, string $storeKey, array $assessors = []): array
{
cc_test_ensure_reparto_settings_table($pdo);
$storeKey = trim(mb_strtolower($storeKey));
$defaults = [
'store_key' => $storeKey,
'reparto_mode' => 'manual',
'rotation_date' => null,
'rotation_index' => 0,
'assessor_states' => cc_test_reparto_normalize_states([], $assessors),
];
try {
$stmt = $pdo->prepare('SELECT store_key, reparto_mode, rotation_date, rotation_index, assessor_states_json FROM callcenter_test_reparto_settings WHERE store_key = ? LIMIT 1');
$stmt->execute([$storeKey]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return $defaults;
}
$rawStates = [];
$statesJson = trim((string) ($row['assessor_states_json'] ?? ''));
if ($statesJson !== '') {
$decoded = json_decode($statesJson, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$rawStates = $decoded;
}
}
return [
'store_key' => $storeKey,
'reparto_mode' => cc_test_normalize_reparto_mode($row['reparto_mode'] ?? null),
'rotation_date' => trim((string) ($row['rotation_date'] ?? '')) !== '' ? (string) $row['rotation_date'] : null,
'rotation_index' => max(0, (int) ($row['rotation_index'] ?? 0)),
'assessor_states' => cc_test_reparto_normalize_states($rawStates, $assessors),
];
} catch (Throwable $exception) {
error_log('cc_test_fetch_reparto_settings: ' . $exception->getMessage());
return $defaults;
}
}
function cc_test_upsert_reparto_settings(PDO $pdo, string $storeKey, array $settings): void
{
cc_test_ensure_reparto_settings_table($pdo);
$storeKey = trim(mb_strtolower($storeKey));
$mode = cc_test_normalize_reparto_mode($settings['reparto_mode'] ?? null);
$rotationDate = trim((string) ($settings['rotation_date'] ?? ''));
if ($rotationDate !== '') {
try {
$rotationDate = (new DateTimeImmutable($rotationDate))->format('Y-m-d');
} catch (Throwable $exception) {
$rotationDate = null;
}
} else {
$rotationDate = null;
}
$rotationIndex = max(0, (int) ($settings['rotation_index'] ?? 0));
$assessorStates = $settings['assessor_states'] ?? [];
if (!is_array($assessorStates)) {
$assessorStates = [];
}
$statesJson = json_encode($assessorStates, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($statesJson === false) {
$statesJson = '{}';
}
$stmt = $pdo->prepare("INSERT INTO callcenter_test_reparto_settings (store_key, reparto_mode, rotation_date, rotation_index, assessor_states_json)
VALUES (:store_key, :reparto_mode, :rotation_date, :rotation_index, :assessor_states_json)
ON DUPLICATE KEY UPDATE
reparto_mode = VALUES(reparto_mode),
rotation_date = VALUES(rotation_date),
rotation_index = VALUES(rotation_index),
assessor_states_json = VALUES(assessor_states_json),
updated_at = CURRENT_TIMESTAMP");
$stmt->bindValue(':store_key', $storeKey, PDO::PARAM_STR);
$stmt->bindValue(':reparto_mode', $mode, PDO::PARAM_STR);
if ($rotationDate === null) {
$stmt->bindValue(':rotation_date', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue(':rotation_date', $rotationDate, PDO::PARAM_STR);
}
$stmt->bindValue(':rotation_index', $rotationIndex, PDO::PARAM_INT);
$stmt->bindValue(':assessor_states_json', $statesJson, PDO::PARAM_STR);
$stmt->execute();
}
function cc_test_reparto_settings_signature(array $settings): string
{
$states = $settings['assessor_states'] ?? [];
if (!is_array($states)) {
$states = [];
}
ksort($states);
$payload = [
'mode' => cc_test_normalize_reparto_mode($settings['reparto_mode'] ?? null),
'rotation_date' => trim((string) ($settings['rotation_date'] ?? '')),
'rotation_index' => max(0, (int) ($settings['rotation_index'] ?? 0)),
'assessor_states' => $states,
];
return sha1(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
function cc_test_apply_daily_reparto(PDO $pdo, string $storeKey, array $orders, array $assessors, array $settings = []): array
{
cc_test_ensure_reparto_settings_table($pdo);
$storeKey = trim(mb_strtolower($storeKey));
$currentSettings = cc_test_fetch_reparto_settings($pdo, $storeKey, $assessors);
if (!empty($settings)) {
$currentSettings = array_replace($currentSettings, $settings);
$currentSettings['assessor_states'] = cc_test_reparto_normalize_states($currentSettings['assessor_states'] ?? [], $assessors);
}
$preview = cc_test_reparto_preview($assessors, $currentSettings);
$orderedKeys = $preview['ordered_keys'] ?? [];
$states = $preview['states'] ?? [];
$totalCount = (int) ($preview['total_count'] ?? 0);
$todayKey = (new DateTimeImmutable('today'))->format('Y-m-d');
$effectiveIndex = (int) ($preview['rotation_index'] ?? 0);
if ($totalCount <= 0) {
return [
'assigned_count' => 0,
'rotation_index' => 0,
'settings' => $currentSettings,
'preview' => $preview,
'message' => 'No hay asesoras configuradas para repartir.',
];
}
if (($currentSettings['rotation_date'] ?? null) !== $todayKey) {
$effectiveIndex = 0;
} else {
$effectiveIndex = $effectiveIndex % $totalCount;
}
$pendingOrders = [];
foreach ($orders as $order) {
if (!empty($order['eliminado'])) {
continue;
}
if ((int) ($order['user_id'] ?? 0) > 0) {
continue;
}
if (trim((string) ($order['source_key'] ?? '')) === '') {
continue;
}
$pendingOrders[] = $order;
}
usort($pendingOrders, static function (array $a, array $b): int {
$aTime = 0;
foreach (['drive_imported_at', 'first_seen_at'] as $field) {
$value = trim((string) ($a[$field] ?? ''));
if ($value !== '') {
$parsed = strtotime($value);
if ($parsed !== false) {
$aTime = $parsed;
break;
}
}
}
$bTime = 0;
foreach (['drive_imported_at', 'first_seen_at'] as $field) {
$value = trim((string) ($b[$field] ?? ''));
if ($value !== '') {
$parsed = strtotime($value);
if ($parsed !== false) {
$bTime = $parsed;
break;
}
}
}
if ($aTime !== $bTime) {
return $aTime <=> $bTime;
}
$aRow = (int) ($a['source_row'] ?? 0);
$bRow = (int) ($b['source_row'] ?? 0);
if ($aRow !== $bRow) {
return $aRow <=> $bRow;
}
$aId = (int) ($a['id'] ?? 0);
$bId = (int) ($b['id'] ?? 0);
if ($aId !== $bId) {
return $aId <=> $bId;
}
return strcmp((string) ($a['source_key'] ?? ''), (string) ($b['source_key'] ?? ''));
});
$assignedCount = 0;
$totalOrders = count($orderedKeys);
foreach ($pendingOrders as $order) {
$chosenKey = null;
$chosenPosition = null;
for ($offset = 0; $offset < $totalOrders; $offset++) {
$position = ($effectiveIndex + $offset) % $totalOrders;
$candidateKey = $orderedKeys[$position];
if (($states[$candidateKey] ?? 'disponible') === 'disponible') {
$chosenKey = $candidateKey;
$chosenPosition = $position;
break;
}
}
if ($chosenKey === null || $chosenPosition === null) {
break;
}
$chosenAssessor = $assessors[$chosenKey] ?? null;
$userId = (int) ($chosenAssessor['id'] ?? 0);
if ($userId <= 0) {
continue;
}
cc_test_upsert_assignee($pdo, (string) $order['source_key'], $userId);
$assignedCount++;
$effectiveIndex = ($chosenPosition + 1) % $totalOrders;
}
if ($assignedCount > 0) {
$currentSettings['rotation_date'] = $todayKey;
$currentSettings['rotation_index'] = $effectiveIndex;
cc_test_upsert_reparto_settings($pdo, $storeKey, $currentSettings);
}
$preview = cc_test_reparto_preview($assessors, $currentSettings);
return [
'assigned_count' => $assignedCount,
'rotation_index' => $effectiveIndex,
'settings' => $currentSettings,
'preview' => $preview,
'message' => $assignedCount > 0
? 'Se asignaron ' . $assignedCount . ' pedidos.'
: 'No había pedidos pendientes o no había asesoras disponibles.',
];
}
function cc_test_fetch_assessors(PDO $pdo, array $allowedNames = []): array
{
if (empty($allowedNames)) {