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.
+
+
+
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();
?>
-