From 4a8d769440abd8c4fadabfa43e32b2074bd2c096 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 23 Jul 2026 20:16:17 +0000 Subject: [PATCH] Autosave: 20260723-201741 --- call_center_pro.php | 286 ++++++++-- ...callcenter_test_reparto_settings_table.sql | 10 + gestiones_callcenter.php | 9 +- includes/callcenter_test_helpers.php | 520 ++++++++++++++++++ 4 files changed, 788 insertions(+), 37 deletions(-) create mode 100644 db/migrations/093_create_callcenter_test_reparto_settings_table.sql diff --git a/call_center_pro.php b/call_center_pro.php index c273c50a..fa8e08df 100644 --- a/call_center_pro.php +++ b/call_center_pro.php @@ -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: Carritos recuperables, Seguimiento, Observados, Confirmados, Recuperados y Todos. 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. Promo Final se habilita desde el Día 4 y exige imagen de sustento para mover el pedido.'; + ? 'Estados disponibles: Carritos recuperables, Seguimiento, Observados, Confirmados, Recuperados y Todos. 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 TUANI principal, se marca en gris como Pedido repetido en tienda 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. Promo Final 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'; + + +
+
+
+
+
+ Reparto + + Siguiente: + + Sin asesoras disponibles + + Sin asignar: +
+

Reparto intercalado diario

+

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.

+
+ Asesoras totales: + Disponibles: + Ciclo: +
+
+ + + · + + +
+
+ +
+
+ + + + + + +
+ + +
+
+ +
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+ + + + + + + +
+
+ +
+
+
Control de reparto
+
Solo el administrador puede cambiar el modo o pausar asesoras. Tú ves aquí el orden intercalado y el siguiente turno.
+
+
+ +
+
+
+ +
@@ -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(); ?> - +
@@ -1164,6 +1377,9 @@ require_once 'layout_header.php';
+ + + llamadas diff --git a/db/migrations/093_create_callcenter_test_reparto_settings_table.sql b/db/migrations/093_create_callcenter_test_reparto_settings_table.sql new file mode 100644 index 00000000..ec1a4a60 --- /dev/null +++ b/db/migrations/093_create_callcenter_test_reparto_settings_table.sql @@ -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; diff --git a/gestiones_callcenter.php b/gestiones_callcenter.php index 38cd1f34..bb413ed4 100644 --- a/gestiones_callcenter.php +++ b/gestiones_callcenter.php @@ -297,7 +297,7 @@ if (!isset($allowedViews[$view])) { } $viewStatesNote = $isTuaniRecuperablesStore - ? 'Estados disponibles: Carritos recuperables, Seguimiento, Observados, Confirmados, Recuperados y Todos. 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: Carritos recuperables, Seguimiento, Observados, Confirmados, Recuperados y Todos. 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 TUANI principal, se marca en gris como Pedido repetido en tienda 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. Promo Final 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(); ?> - +