prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); $stmt->execute([$column]); if (!$stmt->fetch()) { $pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); } $cache[$key] = true; } function cc_test_column_exists(PDO $pdo, string $table, string $column): bool { static $cache = []; $key = $table . '.' . $column; if (array_key_exists($key, $cache)) { return $cache[$key]; } try { $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); $stmt->execute([$column]); $cache[$key] = (bool) $stmt->fetch(PDO::FETCH_ASSOC); } catch (Throwable $exception) { $cache[$key] = false; } return $cache[$key]; } function cc_test_normalize_hex_color(?string $value): ?string { $value = strtoupper(trim((string) $value)); if ($value === '') { return null; } if ($value[0] !== '#') { $value = '#' . $value; } return preg_match('/^#[0-9A-F]{6}$/', $value) ? $value : null; } function cc_test_hex_to_rgb_triplet(string $hex): ?array { $normalized = cc_test_normalize_hex_color($hex); if ($normalized === null) { return null; } $normalized = ltrim($normalized, '#'); return [ hexdec(substr($normalized, 0, 2)), hexdec(substr($normalized, 2, 2)), hexdec(substr($normalized, 4, 2)), ]; } function cc_test_assessor_default_color_hex(string $assessorKey): string { $normalizedKey = cc_test_normalize_user_key($assessorKey); if ($normalizedKey === '') { return '#6C757D'; } $palette = ['#F97316', '#198754', '#FD7E14', '#DC3545', '#20C997', '#FFC107', '#D63384']; $paletteIndex = abs((int) crc32($normalizedKey)) % count($palette); return match ($normalizedKey) { 'KARINA' => '#99EAFD', 'ROSA' => '#0D6EFD', 'ESTEFANYA' => '#198754', 'CARMEN' => '#FF00FF', 'MIRELLA' => '#EFBF04', 'LUCIBOT' => '#FD7E14', 'MARIA', 'MARÍA' => '#800080', default => $palette[$paletteIndex], }; } function cc_test_assessor_effective_color_hex(string $assessorKey, ?string $storedColorHex = null): string { return cc_test_normalize_hex_color($storedColorHex) ?? cc_test_assessor_default_color_hex($assessorKey); } function cc_test_hex_relative_luminance(string $hex): float { $rgb = cc_test_hex_to_rgb_triplet($hex) ?? [108, 117, 125]; $linear = array_map( static function (int $channel): float { $value = $channel / 255; return $value <= 0.03928 ? $value / 12.92 : pow(($value + 0.055) / 1.055, 2.4); }, $rgb ); return (0.2126 * $linear[0]) + (0.7152 * $linear[1]) + (0.0722 * $linear[2]); } function cc_test_assessor_contrast_text_hex(string $hex): string { static $darkText = '#212529'; static $darkLuminance = null; if ($darkLuminance === null) { $darkLuminance = cc_test_hex_relative_luminance($darkText); } $luminance = cc_test_hex_relative_luminance($hex); $contrastWithWhite = 1.05 / ($luminance + 0.05); $contrastWithDark = ($luminance + 0.05) / ($darkLuminance + 0.05); return $contrastWithDark >= $contrastWithWhite ? $darkText : '#FFFFFF'; } function cc_test_assessor_css_vars(string $assessorKey, ?string $storedColorHex = null): string { $hex = cc_test_assessor_effective_color_hex($assessorKey, $storedColorHex); $rgb = cc_test_hex_to_rgb_triplet($hex) ?? [108, 117, 125]; return '--cc-assessor-accent:' . $hex . '; --cc-assessor-accent-rgb:' . implode(', ', $rgb) . '; --cc-assessor-accent-contrast:' . cc_test_assessor_contrast_text_hex($hex) . ';'; } function cc_test_ensure_tracking_table(PDO $pdo): void { static $checked = false; if ($checked) { return; } $pdo->exec("CREATE TABLE IF NOT EXISTS `callcenter_test_tracking` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `source_key` CHAR(40) NOT NULL, `estado` VARCHAR(40) NOT NULL DEFAULT 'POR LLAMAR', `nota_seguimiento` TEXT NULL, `user_id` INT NULL, `assigned_at` DATETIME NULL, `direccion` TEXT NULL, `referencia` TEXT NULL, `agencia` VARCHAR(80) NULL, `sede_agencia` VARCHAR(120) NULL, `sede` VARCHAR(120) NULL, `ciudad` VARCHAR(120) NULL, `distrito` VARCHAR(120) NULL, `dni` VARCHAR(40) NULL, `observaciones` TEXT NULL, `coordenadas` VARCHAR(255) NULL, `producto` VARCHAR(255) NULL, `cantidad` VARCHAR(50) NULL, `precio` VARCHAR(80) NULL, `monto_adelantado` DECIMAL(10,2) NULL, `confirmacion_producto` VARCHAR(255) NULL, `confirmacion_cantidad` VARCHAR(50) NULL, `confirmacion_precio` VARCHAR(80) NULL, `confirmacion_producto_extra` VARCHAR(255) NULL, `confirmacion_cantidad_extra` VARCHAR(50) NULL, `confirmacion_precio_extra` VARCHAR(80) NULL, `proxima_llamada_at` DATETIME NULL, `fecha_entrega_programada` DATE NULL, `numero_cuenta_enviado_at` DATETIME NULL, `numero_cuenta_sede_id` VARCHAR(120) NULL, `numero_cuenta_dni` VARCHAR(40) NULL, `promo_final_evidencia_path` VARCHAR(255) NULL, `promo_final_evidencia_subido_at` DATETIME NULL, `promo_final_evidencia_subido_por` INT NULL, `cancelado_evidencia_path` VARCHAR(255) NULL, `cancelado_evidencia_subido_at` DATETIME NULL, `cancelado_evidencia_subido_por` INT NULL, `eliminado_at` DATETIME NULL, `eliminado_por` INT NULL, `ruta_contraentrega_pedido_id` INT NULL, `ruta_contraentrega_subido_at` DATETIME NULL, `ruta_contraentrega_subido_por` INT NULL, `pedido_rotulado_pedido_id` INT NULL, `pedido_rotulado_subido_at` DATETIME NULL, `pedido_rotulado_subido_por` INT NULL, `ultima_gestion_at` DATETIME NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_callcenter_test_tracking_source_key` (`source_key`), KEY `idx_callcenter_test_tracking_estado` (`estado`), KEY `idx_callcenter_test_tracking_proxima` (`proxima_llamada_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'assigned_at', 'DATETIME NULL AFTER `user_id`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'direccion', 'TEXT NULL AFTER `user_id`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'referencia', 'TEXT NULL AFTER `direccion`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'agencia', 'VARCHAR(80) NULL AFTER `referencia`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'sede_agencia', 'VARCHAR(120) NULL AFTER `agencia`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'sede', 'VARCHAR(120) NULL AFTER `sede_agencia`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'ciudad', 'VARCHAR(120) NULL AFTER `sede`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'distrito', 'VARCHAR(120) NULL AFTER `ciudad`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'dni', 'VARCHAR(40) NULL AFTER `distrito`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'observaciones', 'TEXT NULL AFTER `dni`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'coordenadas', 'VARCHAR(255) NULL AFTER `observaciones`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'producto', 'VARCHAR(255) NULL AFTER `coordenadas`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'cantidad', 'VARCHAR(50) NULL AFTER `producto`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'precio', 'VARCHAR(80) NULL AFTER `cantidad`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'monto_adelantado', 'DECIMAL(10,2) NULL AFTER `precio`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_producto', 'VARCHAR(255) NULL AFTER `precio`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_cantidad', 'VARCHAR(50) NULL AFTER `confirmacion_producto`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_precio', 'VARCHAR(80) NULL AFTER `confirmacion_cantidad`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_producto_extra', 'VARCHAR(255) NULL AFTER `confirmacion_precio`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_cantidad_extra', 'VARCHAR(50) NULL AFTER `confirmacion_producto_extra`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'confirmacion_precio_extra', 'VARCHAR(80) NULL AFTER `confirmacion_cantidad_extra`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'proxima_llamada_at', 'DATETIME NULL AFTER `confirmacion_precio`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'fecha_entrega_programada', 'DATE NULL AFTER `proxima_llamada_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'numero_cuenta_enviado_at', 'DATETIME NULL AFTER `fecha_entrega_programada`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'numero_cuenta_sede_id', 'VARCHAR(120) NULL AFTER `numero_cuenta_enviado_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'numero_cuenta_dni', 'VARCHAR(40) NULL AFTER `numero_cuenta_sede_id`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'promo_final_evidencia_path', 'VARCHAR(255) NULL AFTER `numero_cuenta_dni`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'promo_final_evidencia_subido_at', 'DATETIME NULL AFTER `promo_final_evidencia_path`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'promo_final_evidencia_subido_por', 'INT NULL AFTER `promo_final_evidencia_subido_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'cancelado_evidencia_path', 'VARCHAR(255) NULL AFTER `promo_final_evidencia_subido_por`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'cancelado_evidencia_subido_at', 'DATETIME NULL AFTER `cancelado_evidencia_path`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'cancelado_evidencia_subido_por', 'INT NULL AFTER `cancelado_evidencia_subido_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'eliminado_at', 'DATETIME NULL AFTER `cancelado_evidencia_subido_por`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'eliminado_por', 'INT NULL AFTER `eliminado_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'ruta_contraentrega_pedido_id', 'INT NULL AFTER `eliminado_por`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'ruta_contraentrega_subido_at', 'DATETIME NULL AFTER `ruta_contraentrega_pedido_id`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'ruta_contraentrega_subido_por', 'INT NULL AFTER `ruta_contraentrega_subido_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'pedido_rotulado_pedido_id', 'INT NULL AFTER `ruta_contraentrega_subido_por`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'pedido_rotulado_subido_at', 'DATETIME NULL AFTER `pedido_rotulado_pedido_id`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'pedido_rotulado_subido_por', 'INT NULL AFTER `pedido_rotulado_subido_at`'); cc_test_ensure_column($pdo, 'callcenter_test_tracking', 'ultima_gestion_at', 'DATETIME NULL AFTER `ruta_contraentrega_subido_por`'); $stmtAssignedBackfill = $pdo->query("SELECT id FROM callcenter_test_tracking WHERE assigned_at IS NULL AND user_id IS NOT NULL LIMIT 1"); if ($stmtAssignedBackfill && $stmtAssignedBackfill->fetchColumn()) { $pdo->exec("UPDATE callcenter_test_tracking SET assigned_at = created_at, updated_at = updated_at WHERE assigned_at IS NULL AND user_id IS NOT NULL"); } $checked = true; } function cc_test_default_assessor_keys(): array { return ['KARINA', 'ESTEFANYA', 'CARMEN']; } function cc_test_is_tuani_store_key(string $storeKey): bool { $storeKey = trim(mb_strtolower($storeKey)); return in_array($storeKey, ['flower', 'otra_tienda', 'flower_recuperables', 'tuani_recuperables'], true); } function cc_test_is_tuani_main_store_key(string $storeKey): bool { $storeKey = trim(mb_strtolower($storeKey)); return in_array($storeKey, ['flower', 'otra_tienda'], true); } function cc_test_is_recoverables_store_key(string $storeKey): bool { $storeKey = trim(mb_strtolower($storeKey)); return in_array($storeKey, ['flower_recuperables', 'tuani_recuperables'], true); } function cc_test_is_tuani_recoverables_store_key(string $storeKey): bool { return cc_test_is_recoverables_store_key($storeKey); } function cc_test_callcenter_pro_default_view_key(string $storeKey): string { if (cc_test_is_tuani_main_store_key($storeKey)) { return 'ultimos_3_dias'; } if (cc_test_is_recoverables_store_key($storeKey)) { return 'todos'; } return 'pendientes_hoy'; } 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 = ''): array { $storeKey = trim(mb_strtolower($storeKey)); if (!cc_test_is_recoverables_store_key($storeKey) || empty($orders)) { return $orders; } $mainStoreKey = trim(mb_strtolower($mainStoreKey)); if ($mainStoreKey === '') { $mainStoreKey = $storeKey === 'flower_recuperables' ? 'flower' : 'otra_tienda'; } if ($mainStoreKey === '') { return $orders; } $targetPhoneMap = []; foreach ($orders as $index => $order) { $orders[$index]['pedido_repetido_en_tienda'] = false; $orders[$index]['pedido_repetido_en_tienda_label'] = ''; $phoneKeys = cc_test_phone_match_keys((string) ($order['celular'] ?? '')); if (empty($phoneKeys)) { continue; } foreach ($phoneKeys as $phoneKey) { $targetPhoneMap[$phoneKey][] = $index; } } if (empty($targetPhoneMap)) { return $orders; } try { drive_test_ensure_orders_table($pdo); $stmt = $pdo->prepare("SELECT celular FROM callcenter_test_orders WHERE store_key = ? AND celular IS NOT NULL AND celular <> ''"); $stmt->execute([$mainStoreKey]); $remainingTargets = count($targetPhoneMap); while (($phoneValue = $stmt->fetchColumn()) !== false) { $phoneKeys = cc_test_phone_match_keys((string) $phoneValue); if (empty($phoneKeys)) { continue; } foreach ($phoneKeys as $phoneKey) { if (!isset($targetPhoneMap[$phoneKey])) { continue; } foreach ($targetPhoneMap[$phoneKey] as $orderIndex) { $orders[$orderIndex]['pedido_repetido_en_tienda'] = true; $orders[$orderIndex]['pedido_repetido_en_tienda_label'] = 'Pedido repetido en tienda'; } unset($targetPhoneMap[$phoneKey]); $remainingTargets--; if ($remainingTargets <= 0) { break 2; } } } } catch (Throwable $exception) { error_log('cc_test_mark_recoverables_duplicate_orders: ' . $exception->getMessage()); } 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)); if (in_array($value, ['automatico', 'automático', 'auto', 'hibrido', 'híbrido', 'hybrid'], true)) { return 'automatico'; } return 'manual'; } function cc_test_reparto_mode_label(string $mode): string { return match (cc_test_normalize_reparto_mode($mode)) { 'automatico' => 'Automático', default => 'Manual', }; } function cc_test_reparto_mode_is_auto(?string $mode): bool { return cc_test_normalize_reparto_mode($mode) === 'automatico'; } 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_lock_name(string $storeKey): string { $storeKey = trim(mb_strtolower($storeKey)); return 'cc_test_auto_reparto_' . substr(sha1($storeKey), 0, 24); } function cc_test_acquire_named_lock(PDO $pdo, string $lockName, int $timeoutSeconds = 0): bool { try { $stmt = $pdo->prepare('SELECT GET_LOCK(:lock_name, :timeout_seconds)'); $stmt->bindValue(':lock_name', $lockName, PDO::PARAM_STR); $stmt->bindValue(':timeout_seconds', $timeoutSeconds, PDO::PARAM_INT); $stmt->execute(); $result = $stmt->fetchColumn(); return (int) $result === 1; } catch (Throwable $exception) { error_log('cc_test_acquire_named_lock: ' . $exception->getMessage()); return false; } } function cc_test_release_named_lock(PDO $pdo, string $lockName): void { try { $stmt = $pdo->prepare('SELECT RELEASE_LOCK(:lock_name)'); $stmt->bindValue(':lock_name', $lockName, PDO::PARAM_STR); $stmt->execute(); } catch (Throwable $exception) { error_log('cc_test_release_named_lock: ' . $exception->getMessage()); } } function cc_test_load_reparto_orders(PDO $pdo, string $storeKey): array { $storeKey = trim(mb_strtolower($storeKey)); if ($storeKey === '') { return []; } $availableStores = function_exists('drive_test_available_stores') ? drive_test_available_stores() : []; $storeConfig = $availableStores[$storeKey] ?? []; if (!empty($storeConfig['incremental_sync']) && function_exists('drive_test_sync_orders_incremental')) { drive_test_sync_orders_incremental($pdo, $storeKey, 0); } if (!function_exists('drive_test_fetch_orders_from_db') || !function_exists('drive_test_fetch_tracking') || !function_exists('drive_test_merge_tracking')) { return []; } $orders = drive_test_fetch_orders_from_db($pdo, $storeKey); $tracking = drive_test_fetch_tracking($pdo, array_column($orders, 'source_key')); $orders = drive_test_merge_tracking($orders, $tracking); return array_values(array_filter($orders, static function (array $order): bool { return empty($order['eliminado']); })); } function cc_test_tuani_reparto_is_eligible(array $order, string $storeKey, array $storeConfig): bool { return true; } if (!function_exists('cc_test_filter_reparto_orders')) { function cc_test_filter_reparto_orders(array $orders, string $storeKey, array $storeConfig): array { $filteredOrders = []; foreach ($orders as $order) { if (!is_array($order)) { continue; } if (!cc_test_tuani_reparto_is_eligible($order, $storeKey, $storeConfig)) { continue; } $filteredOrders[] = $order; } return array_values($filteredOrders); } } function cc_test_run_locked_reparto_job(PDO $pdo, string $storeKey, array $assessors, callable $loadOrders, array $settings = [], ?callable $filterOrders = null, int $lockTimeoutSeconds = 5): array { $lockName = cc_test_reparto_lock_name($storeKey); if (!cc_test_acquire_named_lock($pdo, $lockName, $lockTimeoutSeconds)) { $currentSettings = cc_test_fetch_reparto_settings($pdo, $storeKey, $assessors); return [ 'assigned_count' => 0, 'rotation_index' => (int) ($currentSettings['rotation_index'] ?? 0), 'settings' => $currentSettings, 'preview' => cc_test_reparto_preview($assessors, $currentSettings), 'message' => 'El reparto automático ya se está ejecutando en otro proceso.', 'locked' => false, ]; } try { $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); } $orders = $loadOrders(); if (!is_array($orders)) { $orders = []; } if ($filterOrders !== null) { $filteredOrders = $filterOrders($orders); if (is_array($filteredOrders)) { $orders = array_values($filteredOrders); } } if (empty($orders)) { return [ 'assigned_count' => 0, 'rotation_index' => (int) ($currentSettings['rotation_index'] ?? 0), 'settings' => $currentSettings, 'preview' => cc_test_reparto_preview($assessors, $currentSettings), 'message' => 'No hay pedidos pendientes para repartir.', 'locked' => true, ]; } $result = cc_test_apply_daily_reparto($pdo, $storeKey, $orders, $assessors, $currentSettings); $result['locked'] = true; return $result; } finally { cc_test_release_named_lock($pdo, $lockName); } } 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 { $allowedLookup = []; if (!empty($allowedNames)) { $allowedLookup = array_fill_keys(array_map('cc_test_normalize_user_key', $allowedNames), true); } $hasColorColumn = cc_test_column_exists($pdo, 'users', 'color_hex'); $selectColorColumn = $hasColorColumn ? ', color_hex' : ', NULL AS color_hex'; $stmt = $pdo->prepare("SELECT id, username, nombre_asesor{$selectColorColumn} FROM users WHERE role = 'Asesor'"); $stmt->execute(); $byKey = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $key = trim(mb_strtoupper((string) ($row['nombre_asesor'] ?: $row['username'] ?: ''))); $key = preg_replace('/\s+/', ' ', $key) ?? $key; if ($key === '' || (!empty($allowedLookup) && !isset($allowedLookup[$key]))) { continue; } $byKey[$key] = [ 'id' => (int) $row['id'], 'label' => trim((string) ($row['nombre_asesor'] ?: $row['username'] ?: ('Asesor #' . (int) $row['id']))), 'color_hex' => cc_test_normalize_hex_color($row['color_hex'] ?? null), ]; } return $byKey; } function cc_test_normalize_user_key(?string $value): string { $value = trim((string) $value); if ($value === '') { return ''; } $value = preg_replace('/\s+/', ' ', $value) ?? $value; return mb_strtoupper($value); } function cc_test_callcenter_assessor_filter_session_key(): string { return 'callcenter_assessor_filter'; } function cc_test_callcenter_assessor_filter_clear_values(): array { return ['ALL', 'TODAS', 'TODOS', '__ALL__']; } function cc_test_callcenter_store_session_key(): string { return 'callcenter_store'; } function cc_test_callcenter_view_session_key(): string { return 'callcenter_view'; } function cc_test_normalize_context_key(?string $value): string { $value = trim((string) $value); if ($value === '') { return ''; } $value = preg_replace('/\s+/', ' ', $value) ?? $value; return mb_strtolower($value); } function cc_test_resolve_callcenter_context_key(?string $incomingValue, string $sessionKey, string $fallback = ''): string { $incomingValue = cc_test_normalize_context_key($incomingValue ?? ''); if ($incomingValue !== '') { return $incomingValue; } $sessionValue = cc_test_normalize_context_key($_SESSION[$sessionKey] ?? ''); if ($sessionValue !== '') { return $sessionValue; } return cc_test_normalize_context_key($fallback); } function cc_test_is_recoverables_order_code(?string $codigo): bool { $codigo = trim((string) $codigo); if ($codigo === '') { return false; } $codigo = ltrim($codigo, '#'); return preg_match('/^D\d+$/i', $codigo) === 1; } function cc_test_recoverables_order_sort_value(array $order): int { $codigo = trim((string) ($order['codigo'] ?? '')); if ($codigo !== '' && preg_match('/^#?D(\d+)$/i', $codigo, $matches) === 1) { return (int) ($matches[1] ?? 0); } $sourceRow = (int) ($order['source_row'] ?? 0); if ($sourceRow > 0) { return $sourceRow; } return (int) ($order['id'] ?? 0); } function cc_test_filter_recoverables_orders(array $orders, int $minimumSourceRow = 0): array { $minimumSourceRow = max(0, $minimumSourceRow); return array_values(array_filter($orders, static function (array $order) use ($minimumSourceRow): bool { if (!cc_test_is_recoverables_order_code($order['codigo'] ?? '')) { return false; } $sourceRow = (int) ($order['source_row'] ?? 0); if ($minimumSourceRow > 0 && $sourceRow < $minimumSourceRow) { return false; } return true; })); } function cc_test_compare_recoverables_orders_desc(array $a, array $b): int { $aValue = cc_test_recoverables_order_sort_value($a); $bValue = cc_test_recoverables_order_sort_value($b); if ($aValue !== $bValue) { return $bValue <=> $aValue; } $aSourceRow = (int) ($a['source_row'] ?? 0); $bSourceRow = (int) ($b['source_row'] ?? 0); if ($aSourceRow !== $bSourceRow) { return $bSourceRow <=> $aSourceRow; } $aId = (int) ($a['id'] ?? 0); $bId = (int) ($b['id'] ?? 0); if ($aId !== $bId) { return $bId <=> $aId; } return strcmp((string) ($a['source_key'] ?? ''), (string) ($b['source_key'] ?? '')); } function cc_test_recoverables_summary(array $orders): ?array { $importCount = count($orders); $firstSourceRow = null; $lastSourceRow = null; $latestOrder = null; $latestRow = null; foreach ($orders as $order) { $sourceRow = (int) ($order['source_row'] ?? 0); if ($sourceRow <= 0) { continue; } if ($firstSourceRow === null || $sourceRow < $firstSourceRow) { $firstSourceRow = $sourceRow; } if ($lastSourceRow === null || $sourceRow > $lastSourceRow) { $lastSourceRow = $sourceRow; } if ($latestRow === null || $sourceRow > $latestRow) { $latestRow = $sourceRow; $latestOrder = $order; continue; } if ($latestOrder !== null && $sourceRow === $latestRow) { $currentValue = cc_test_recoverables_order_sort_value($order); $bestValue = cc_test_recoverables_order_sort_value($latestOrder); if ($currentValue > $bestValue) { $latestOrder = $order; } } } $notice = null; if ($latestRow !== null) { $label = 'Sin pedido detectado'; if ($latestOrder !== null) { $codigo = trim((string) ($latestOrder['codigo'] ?? '')); if ($codigo !== '') { $label = '#' . ltrim($codigo, '#'); } elseif (!empty($latestOrder['is_agregado'])) { $label = 'AGREGADOS'; } else { $label = 'Sin número'; } } $notice = [ 'row_label' => (string) $latestRow, 'label' => $label, ]; } return [ 'import_count' => $importCount, 'first_source_row' => $firstSourceRow, 'last_source_row' => $lastSourceRow, 'notice' => $notice, ]; } function cc_test_resolve_callcenter_assessor_filter(array $assessors, ?string $incomingFilter = null, bool $useSessionFallback = true, bool $persistSession = true): string { $sessionKey = cc_test_callcenter_assessor_filter_session_key(); $incomingFilter = cc_test_normalize_user_key($incomingFilter ?? ''); $sessionFilter = cc_test_normalize_user_key($_SESSION[$sessionKey] ?? ''); if ($incomingFilter !== '') { if (in_array($incomingFilter, cc_test_callcenter_assessor_filter_clear_values(), true)) { unset($_SESSION[$sessionKey]); return ''; } if (isset($assessors[$incomingFilter])) { if ($persistSession) { $_SESSION[$sessionKey] = $incomingFilter; } return $incomingFilter; } if ($persistSession) { unset($_SESSION[$sessionKey]); } return ''; } if ($useSessionFallback && $sessionFilter !== '' && isset($assessors[$sessionFilter])) { return $sessionFilter; } if (!$persistSession) { unset($_SESSION[$sessionKey]); } return ''; } function cc_test_allowed_module_user_keys(): array { return cc_test_default_assessor_keys(); } function cc_test_is_allowed_module_user(?string $role = null, ?string $username = null, ?string $nombreAsesor = null): bool { $role = trim((string) $role); if (in_array($role, ['Administrador', 'admin'], true)) { return true; } if ($role === 'Asesor') { return true; } $allowedLookup = array_fill_keys(cc_test_allowed_module_user_keys(), true); foreach ([$username, $nombreAsesor] as $candidate) { $key = cc_test_normalize_user_key($candidate); if ($key !== '' && isset($allowedLookup[$key])) { return true; } } return false; } function cc_test_current_user_can_access_module(?PDO $pdo = null): bool { $role = $_SESSION['user_role'] ?? ''; $username = $_SESSION['username'] ?? ''; $nombreAsesor = null; if ($pdo instanceof PDO && !empty($_SESSION['user_id']) && !in_array($role, ['Administrador', 'admin'], true)) { try { $stmt = $pdo->prepare('SELECT username, nombre_asesor FROM users WHERE id = ? LIMIT 1'); $stmt->execute([$_SESSION['user_id']]); if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $username = $row['username'] ?? $username; $nombreAsesor = $row['nombre_asesor'] ?? null; } } catch (Throwable $exception) { // Fall back to the session values if the lookup fails. } } return cc_test_is_allowed_module_user($role, $username, $nombreAsesor); } function cc_test_current_user_identity_keys(): array { $keys = []; foreach ([(string) ($_SESSION['nombre_asesor'] ?? ''), (string) ($_SESSION['username'] ?? '')] as $candidate) { $key = cc_test_normalize_user_key($candidate); if ($key !== '' && !in_array($key, $keys, true)) { $keys[] = $key; } } return $keys; } function cc_test_current_user_can_view_flower_recoverables(): bool { $role = trim((string) ($_SESSION['user_role'] ?? '')); if (in_array($role, ['Administrador', 'admin'], true)) { return true; } return !in_array('CARMEN', cc_test_current_user_identity_keys(), true); } function cc_test_filter_visible_drive_stores(array $availableStores): array { if (cc_test_current_user_can_view_flower_recoverables()) { return $availableStores; } unset($availableStores['flower_recuperables']); return $availableStores; } function cc_test_upsert_assignee(PDO $pdo, string $sourceKey, ?int $userId): void { cc_test_ensure_tracking_table($pdo); $assignedAt = $userId === null ? null : (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'); $stmt = $pdo->prepare("INSERT INTO callcenter_test_tracking (source_key, user_id, assigned_at, ultima_gestion_at) VALUES (:source_key, :user_id, :assigned_at, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), assigned_at = CASE WHEN VALUES(user_id) IS NULL THEN NULL WHEN assigned_at IS NULL OR user_id IS NULL OR user_id <> VALUES(user_id) THEN VALUES(assigned_at) ELSE assigned_at END, ultima_gestion_at = VALUES(ultima_gestion_at), updated_at = CURRENT_TIMESTAMP"); $stmt->bindValue(':source_key', $sourceKey, PDO::PARAM_STR); if ($userId === null) { $stmt->bindValue(':user_id', null, PDO::PARAM_NULL); $stmt->bindValue(':assigned_at', null, PDO::PARAM_NULL); } else { $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); $stmt->bindValue(':assigned_at', $assignedAt, PDO::PARAM_STR); } $stmt->execute(); } function cc_test_ensure_historial_llamadas_table(PDO $pdo): void { static $checked = false; if ($checked) { return; } $pdo->exec("CREATE TABLE IF NOT EXISTS `historial_llamadas` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `pedido_id` VARCHAR(80) NOT NULL, `asesor_id` INT UNSIGNED NOT NULL, `resultado` VARCHAR(120) NOT NULL, `observacion` TEXT NULL, `fecha_llamada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX `idx_historial_llamadas_pedido` (`pedido_id`), INDEX `idx_historial_llamadas_asesor` (`asesor_id`), INDEX `idx_historial_llamadas_fecha` (`fecha_llamada`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); $checked = true; } function cc_test_normalize_state(string $estado): string { $estado = trim($estado); return match ($estado) { 'CONFIRMADO CONTRAENTREGA FECHA', 'CONFIRMADO FECHA', 'CONTRAENTREGA CONFIRMADO' => 'CONFIRMADO CONTRAENTREGA', 'ENVIO REPETIDO' => 'REPETIDO', default => $estado, }; } function cc_test_valid_states(): array { return [ 'POR LLAMAR', 'DEVOLVER LLAMADA', 'OBSERVADO', 'SE ENVIO NUMERO DE CUENTA', 'CONFIRMADO CONTRAENTREGA', 'CONFIRMADO ENVIO', 'CANCELADO', 'REPETIDO', ]; } if (!function_exists('cc_test_open_states')) { function cc_test_open_states(): array { return ['POR LLAMAR', 'DEVOLVER LLAMADA', 'OBSERVADO']; } } function cc_test_confirmed_states(): array { return ['CONFIRMADO CONTRAENTREGA', 'CONFIRMADO ENVIO']; } if (!function_exists('cc_test_closed_states')) { function cc_test_closed_states(): array { return ['CANCELADO', 'REPETIDO']; } } function cc_test_requires_delivery_date(string $estado): bool { return cc_test_normalize_state($estado) === 'CONFIRMADO CONTRAENTREGA'; } function cc_test_requires_shipping_details(string $estado): bool { return cc_test_normalize_state($estado) === 'CONFIRMADO ENVIO'; } function cc_test_requires_account_number_fields(string $estado): bool { return in_array(cc_test_normalize_state($estado), ['SE ENVIO NUMERO DE CUENTA', 'CONFIRMADO ENVIO'], true); } function cc_test_pending_logistica_destination(array $order): ?string { $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); if ($estado === 'CONFIRMADO CONTRAENTREGA') { return !empty($order['ruta_contraentrega_pedido_id']) && (int) $order['ruta_contraentrega_pedido_id'] > 0 ? null : 'ruta'; } if ($estado === 'CONFIRMADO ENVIO') { return !empty($order['pedido_rotulado_pedido_id']) && (int) $order['pedido_rotulado_pedido_id'] > 0 ? null : 'rotulado'; } return null; } function cc_test_has_pending_logistica_upload(array $order): bool { return cc_test_pending_logistica_destination($order) !== null; } function cc_test_pending_logistica_label(array $order): ?string { return cc_test_has_pending_logistica_upload($order) ? 'PENDIENTE A SUBIR' : null; } function cc_test_account_followup_semaforo(?string $value): ?array { $date = cc_test_parse_datetime($value); if (!$date) { return null; } $now = new DateTimeImmutable('now'); $days = (int) $date->diff($now)->format('%a'); if ($date > $now) { $days = 0; } if ($days <= 1) { return [ 'class' => 'bg-success-subtle text-success-emphasis', 'label' => 'Verde', 'range' => '0–1 días', 'days' => $days, 'description' => 'Aún está en ventana de seguimiento amable.', ]; } if ($days === 2) { return [ 'class' => 'bg-warning-subtle text-warning-emphasis', 'label' => 'Amarillo', 'range' => '2 días', 'days' => $days, 'description' => 'Ya conviene insistir con llamada o WhatsApp.', ]; } return [ 'class' => 'bg-danger-subtle text-danger-emphasis', 'label' => 'Rojo', 'range' => '3+ días', 'days' => $days, 'description' => 'Necesita seguimiento urgente para no perder el pedido.', ]; } function cc_test_followup_tracking_states(): array { return ['POR LLAMAR', 'DEVOLVER LLAMADA', 'OBSERVADO', 'SE ENVIO NUMERO DE CUENTA']; } function cc_test_recoverable_claimable_states(): array { return cc_test_followup_tracking_states(); } function cc_test_is_recoverable_claimable_state(string $estado): bool { $estado = cc_test_normalize_state($estado); if ($estado === '') { return true; } return in_array($estado, cc_test_recoverable_claimable_states(), true); } function cc_test_can_claim_recoverable_order(?array $tracking): bool { if ($tracking === null) { return true; } return cc_test_is_recoverable_claimable_state((string) ($tracking['estado'] ?? '')); } function cc_test_followup_parse_datetime(?string $value): ?DateTimeImmutable { $value = trim((string) $value); if ($value === '') { return null; } $formats = [ 'Y-m-d\TH:i:sP', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i:s', 'Y-m-d H:i', DateTimeInterface::ATOM, 'Y-m-d', 'd/m/Y H:i:s', 'd/m/Y H:i', 'd/m/Y', ]; foreach ($formats as $format) { try { $date = DateTimeImmutable::createFromFormat($format, $value); if ($date instanceof DateTimeImmutable) { return $date; } } catch (Throwable $exception) { // Intentar el siguiente formato. } } try { return new DateTimeImmutable($value); } catch (Throwable $exception) { return null; } } function cc_test_followup_started_at(array $order): ?DateTimeImmutable { foreach (['assigned_at', 'drive_imported_at', 'first_seen_at', 'import_id'] as $field) { $date = cc_test_followup_parse_datetime($order[$field] ?? null); if ($date instanceof DateTimeImmutable) { return $date; } } return null; } function cc_test_followup_promo_final_cutoff(): DateTimeImmutable { return new DateTimeImmutable('2026-07-29 00:00:00'); } function cc_test_followup_is_from_promo_final_cutoff(array $order): bool { $startedAt = cc_test_followup_started_at($order); if (!$startedAt instanceof DateTimeImmutable) { return false; } return $startedAt->format('Y-m-d') >= cc_test_followup_promo_final_cutoff()->format('Y-m-d'); } function cc_test_followup_promo_final_threshold_day(array $order): int { return 4; } function cc_test_followup_promo_final_evidence_day(array $order): int { return max(1, cc_test_followup_promo_final_threshold_day($order) - 1); } function cc_test_followup_day_info(array $order): ?array { $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); if (!in_array($estado, cc_test_followup_tracking_states(), true)) { return null; } $startedAt = cc_test_followup_started_at($order); if (!$startedAt) { return null; } $today = new DateTimeImmutable('today', $startedAt->getTimezone()); $startedDay = $startedAt->setTime(0, 0, 0); $daysElapsed = (int) $startedDay->diff($today)->format('%a'); if ($startedDay > $today) { $daysElapsed = 0; } $dayNumber = $daysElapsed + 1; $promoFinalThresholdDay = cc_test_followup_promo_final_threshold_day($order); $promoFinalEvidenceDay = cc_test_followup_promo_final_evidence_day($order); $badgeClass = 'bg-primary-subtle text-primary-emphasis border'; $textClass = 'text-primary-emphasis'; $description = 'Seguimiento dentro de la ventana óptima.'; $noticeText = ''; $rowNoticeClass = ''; if ($dayNumber >= $promoFinalThresholdDay) { $badgeClass = 'bg-danger-subtle text-danger-emphasis border'; $textClass = 'text-danger-emphasis'; $description = 'Ya debe pasar a Promo Final.'; $noticeText = 'Este pedido lleva ' . $dayNumber . ' días y debe moverse a Promo Final con imagen de sustento.'; $rowNoticeClass = 'text-danger-emphasis'; } elseif ($dayNumber === max(1, $promoFinalThresholdDay - 1)) { $badgeClass = 'bg-warning-subtle text-warning-emphasis border'; $textClass = 'text-warning-emphasis'; $description = 'Último día de seguimiento antes de Promo Final.'; $noticeText = 'Último día de seguimiento. Ya puedes cargar la imagen de sustento; mañana se podrá mover a Promo Final.'; $rowNoticeClass = 'text-warning-emphasis'; } elseif ($dayNumber === 2) { $badgeClass = 'bg-info-subtle text-info-emphasis border'; $textClass = 'text-info-emphasis'; $description = 'Aún está dentro de los primeros ' . $promoFinalThresholdDay . ' días de seguimiento.'; } return [ 'day_number' => $dayNumber, 'display_label' => $dayNumber >= $promoFinalThresholdDay ? 'Día ' . $promoFinalThresholdDay . '+' : 'Día ' . $dayNumber, 'compact_label' => $dayNumber >= $promoFinalThresholdDay ? $promoFinalThresholdDay . '+' : (string) $dayNumber, 'badge_class' => $badgeClass, 'text_class' => $textClass, 'description' => $description, 'notice_text' => $noticeText, 'row_notice_class' => $rowNoticeClass, 'started_at_label' => $startedAt->format('d/m/Y'), 'promo_final_threshold_day' => $promoFinalThresholdDay, 'promo_final_evidence_day' => $promoFinalEvidenceDay, 'can_upload_promo_final_evidence' => $dayNumber >= $promoFinalEvidenceDay, 'is_promo_final' => $dayNumber >= $promoFinalThresholdDay, ]; } function cc_test_followup_owner_lock_key(array $order): string { $userId = (int) ($order['user_id'] ?? 0); if ($userId > 0) { return 'user:' . $userId; } $assessorKey = trim((string) ($order['assessor_key'] ?? '')); if ($assessorKey !== '' && mb_strtolower($assessorKey) !== 'sin_asignar') { return 'assessor:' . mb_strtolower($assessorKey); } return ''; } function cc_test_followup_pending_promo_final_counts(array $orders): array { $counts = []; foreach ($orders as $order) { if (empty($order['promo_final_pendiente'])) { continue; } if (!cc_test_followup_is_from_promo_final_cutoff($order)) { continue; } $ownerKey = cc_test_followup_owner_lock_key($order); if ($ownerKey === '') { continue; } $counts[$ownerKey] = ($counts[$ownerKey] ?? 0) + 1; } return $counts; } function cc_test_followup_day1_phone_hidden(array $order, array $pendingPromoFinalCounts = []): bool { $followupDayInfo = $order['seguimiento_dia_info'] ?? null; if (!is_array($followupDayInfo) || (int) ($followupDayInfo['day_number'] ?? 0) !== 1) { return false; } $ownerKey = cc_test_followup_owner_lock_key($order); if ($ownerKey === '') { return false; } return !empty($pendingPromoFinalCounts[$ownerKey]); } function cc_test_dashboard_date_key(?string $value): ?string { $date = cc_test_followup_parse_datetime($value); return $date instanceof DateTimeImmutable ? $date->format('Y-m-d') : null; } function cc_test_dashboard_origin_datetime(array $order): ?DateTimeImmutable { foreach (['first_seen_at', 'drive_imported_at', 'import_id', 'assigned_at', 'updated_at'] as $field) { $date = cc_test_followup_parse_datetime($order[$field] ?? null); if ($date instanceof DateTimeImmutable) { return $date; } } return null; } function cc_test_dashboard_action_datetime(array $order): ?DateTimeImmutable { $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); $fields = match ($estado) { 'CONFIRMADO CONTRAENTREGA' => ['ruta_contraentrega_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'], 'CONFIRMADO ENVIO' => ['pedido_rotulado_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'], 'CANCELADO' => ['cancelado_evidencia_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'], 'SE ENVIO NUMERO DE CUENTA' => ['numero_cuenta_enviado_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'], default => ['updated_at', 'ultima_gestion_at', 'assigned_at'], }; foreach ($fields as $field) { $date = cc_test_followup_parse_datetime($order[$field] ?? null); if ($date instanceof DateTimeImmutable) { return $date; } } return null; } function cc_test_resolve_kpi_period(string $period, ?string $customDate = null): array { $period = mb_strtolower(trim($period)); $today = new DateTimeImmutable('today'); $todayEnd = $today->setTime(23, 59, 59); $start = $today; $end = $todayEnd; $label = 'Hoy'; $trendDays = 1; $customDateValue = null; $customDate = trim((string) $customDate); if ($customDate !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $customDate, $matches) === 1 && checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) { $customDateValue = new DateTimeImmutable($customDate); } if (in_array($period, ['custom', 'date', 'specific'], true)) { if ($customDateValue instanceof DateTimeImmutable) { $start = $customDateValue->setTime(0, 0, 0); $end = $customDateValue->setTime(23, 59, 59); $label = $customDateValue->format('d/m/Y'); $trendDays = 1; $period = 'custom'; } else { $period = 'today'; } } switch ($period) { case 'yesterday': $start = $today->sub(new DateInterval('P1D')); $end = $start->setTime(23, 59, 59); $label = 'Ayer'; break; case '7': $start = $today->sub(new DateInterval('P6D')); $end = $todayEnd; $label = 'Últimos 7 días'; $trendDays = 7; break; case '15': $start = $today->sub(new DateInterval('P14D')); $end = $todayEnd; $label = 'Últimos 15 días'; $trendDays = 15; break; case '30': $start = $today->sub(new DateInterval('P29D')); $end = $todayEnd; $label = 'Últimos 30 días'; $trendDays = 30; break; case 'month': $start = $today->modify('first day of this month'); $end = $todayEnd; $label = 'Este mes'; $trendDays = max(1, $start->diff($end)->days + 1); break; case 'custom': break; case 'today': default: $period = 'today'; break; } return [ 'key' => $period, 'label' => $label, 'start' => $start, 'end' => $end, 'trend_days' => $trendDays, 'custom_date' => $customDateValue, ]; } function cc_test_is_recovered_today(array $order, ?DateTimeImmutable $todayStart = null): bool { $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); if (!in_array($estado, cc_test_confirmed_states(), true)) { return false; } $todayStart = $todayStart ?? new DateTimeImmutable('today'); $assignedDate = cc_test_followup_parse_datetime($order['assigned_at'] ?? null); $actionDate = cc_test_dashboard_action_datetime($order); if (!$assignedDate || !$actionDate) { return false; } return $assignedDate->format('Y-m-d') < $todayStart->format('Y-m-d') && $actionDate->format('Y-m-d') === $todayStart->format('Y-m-d'); } function cc_test_dashboard_bucket_key(array $order, array $assessors, bool $includeUnassigned = true): string { $assessorKey = trim((string) ($order['assessor_key'] ?? '')); if ($assessorKey !== '') { return $assessorKey; } $userId = (int) ($order['user_id'] ?? 0); if ($userId > 0) { foreach ($assessors as $key => $assessor) { if ((int) ($assessor['id'] ?? 0) === $userId) { return (string) $key; } } } return $includeUnassigned ? 'SIN_ASIGNAR' : ''; } function cc_test_dashboard_bucket_label(string $bucketKey, array $assessors): string { if ($bucketKey === '' || $bucketKey === 'SIN_ASIGNAR') { return 'Sin asignar'; } return (string) ($assessors[$bucketKey]['label'] ?? $bucketKey); } function cc_test_build_performance_dashboard(array $orders, array $assessors, int $trendDays = 7): array { $trendDays = max(1, $trendDays); $today = new DateTimeImmutable('today'); $todayKey = $today->format('Y-m-d'); $trend = []; $trendLabels = []; for ($i = $trendDays - 1; $i >= 0; $i--) { $day = $today->sub(new DateInterval('P' . $i . 'D')); $dayKey = $day->format('Y-m-d'); $trend[$dayKey] = [ 'label' => $day->format('d/m'), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, ]; $trendLabels[] = $day->format('d/m'); } $orderedAssessorKeys = cc_test_ordered_assessor_keys($assessors); $bucketStats = []; foreach ($orderedAssessorKeys as $assessorKey) { $bucketStats[$assessorKey] = [ 'label' => cc_test_dashboard_bucket_label($assessorKey, $assessors), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, 'followup_open' => 0, 'repeated' => 0, ]; } $summary = [ 'scope_total' => 0, 'assigned_today' => 0, 'assigned_effective_today' => 0, 'confirmed_today_new' => 0, 'confirmed_today_recovered' => 0, 'confirmed_today_total' => 0, 'canceled_today' => 0, 'repeated_today' => 0, 'followup_open' => 0, 'observados_today' => 0, 'confirm_rate_today' => 0.0, 'recovery_rate_today' => 0.0, 'performance_today_rate' => 0.0, ]; $hasUnassignedBucket = false; $followupStateCounts = [ 'POR LLAMAR' => 0, 'DEVOLVER LLAMADA' => 0, 'OBSERVADO' => 0, 'SE ENVIO NUMERO DE CUENTA' => 0, ]; foreach ($orders as $order) { $summary['scope_total']++; $bucketKey = cc_test_dashboard_bucket_key($order, $assessors, true); if ($bucketKey === '') { continue; } if ($bucketKey === 'SIN_ASIGNAR') { $hasUnassignedBucket = true; } if (!isset($bucketStats[$bucketKey])) { $bucketStats[$bucketKey] = [ 'label' => cc_test_dashboard_bucket_label($bucketKey, $assessors), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, 'followup_open' => 0, 'repeated' => 0, ]; } $assignedDate = cc_test_followup_parse_datetime($order['assigned_at'] ?? null); $assignedKey = $assignedDate ? $assignedDate->format('Y-m-d') : null; $actionDate = cc_test_dashboard_action_datetime($order); $actionKey = $actionDate ? $actionDate->format('Y-m-d') : null; $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); $followupInfo = cc_test_followup_day_info($order); $isPromoFinal = !empty($followupInfo['is_promo_final']) && !empty($order['promo_final_evidence_path']); $isConfirmed = in_array($estado, cc_test_confirmed_states(), true); $isCanceled = $estado === 'CANCELADO'; $isRepeated = $estado === 'REPETIDO'; $isFollowupState = in_array($estado, cc_test_followup_tracking_states(), true); $isFollowupOpen = $isFollowupState && !$isPromoFinal; $assignmentKey = $assignedKey; $isAssignedToday = $assignmentKey === $todayKey; if ($isAssignedToday) { $bucketStats[$bucketKey]['assigned_today']++; $summary['assigned_today']++; if (isset($trend[$assignmentKey])) { $trend[$assignmentKey]['assigned_today']++; } if ($isFollowupOpen) { $bucketStats[$bucketKey]['followup_open']++; $summary['followup_open']++; if (isset($followupStateCounts[$estado])) { $followupStateCounts[$estado]++; } if ($estado === 'OBSERVADO') { $summary['observados_today']++; } } } $confirmedReferenceKey = $assignmentKey; if ($isConfirmed && $actionKey === $todayKey) { if ($confirmedReferenceKey !== null && $confirmedReferenceKey < $todayKey) { $bucketStats[$bucketKey]['recovered']++; $summary['confirmed_today_recovered']++; } elseif ($isAssignedToday) { $bucketStats[$bucketKey]['confirmed_new']++; $summary['confirmed_today_new']++; } if ($isAssignedToday && isset($trend[$actionKey])) { if ($confirmedReferenceKey !== null && $confirmedReferenceKey < $actionKey) { $trend[$actionKey]['recovered']++; } else { $trend[$actionKey]['confirmed_new']++; } } } if ($isAssignedToday && $isCanceled && $actionKey === $todayKey) { $bucketStats[$bucketKey]['canceled']++; $summary['canceled_today']++; if (isset($trend[$actionKey])) { $trend[$actionKey]['canceled']++; } } if ($isAssignedToday && $isRepeated && $actionKey === $todayKey) { $bucketStats[$bucketKey]['repeated']++; $summary['repeated_today']++; } } $summary['confirmed_today_total'] = $summary['confirmed_today_new'] + $summary['confirmed_today_recovered']; $summary['assigned_effective_today'] = max(0, (int) ($summary['assigned_today'] ?? 0) - (int) ($summary['repeated_today'] ?? 0)); $effectiveAssignedToday = (int) ($summary['assigned_effective_today'] ?? 0); $summary['confirm_rate_today'] = $effectiveAssignedToday > 0 ? round(($summary['confirmed_today_new'] / $effectiveAssignedToday) * 100, 1) : 0.0; $summary['recovery_rate_today'] = $summary['confirmed_today_total'] > 0 ? round(($summary['confirmed_today_recovered'] / $summary['confirmed_today_total']) * 100, 1) : 0.0; $summary['performance_today_rate'] = $effectiveAssignedToday > 0 ? round(($summary['confirmed_today_total'] / $effectiveAssignedToday) * 100, 1) : 0.0; $advisorLabels = []; $advisorAssigned = []; $advisorConfirmedNew = []; $advisorRecovered = []; $advisorCanceled = []; $advisorFollowup = []; $finalBucketKeys = $orderedAssessorKeys; foreach (array_keys($bucketStats) as $bucketKey) { if (!in_array($bucketKey, $finalBucketKeys, true) && $bucketKey !== 'SIN_ASIGNAR') { $finalBucketKeys[] = $bucketKey; } } if ($hasUnassignedBucket) { $finalBucketKeys[] = 'SIN_ASIGNAR'; } foreach ($finalBucketKeys as $bucketKey) { if (!isset($bucketStats[$bucketKey])) { continue; } $bucket = $bucketStats[$bucketKey]; $advisorLabels[] = $bucket['label']; $advisorAssigned[] = (int) $bucket['assigned_today']; $advisorConfirmedNew[] = (int) $bucket['confirmed_new']; $advisorRecovered[] = (int) $bucket['recovered']; $advisorCanceled[] = (int) $bucket['canceled']; $advisorFollowup[] = (int) $bucket['followup_open']; } if (empty($advisorLabels)) { $advisorLabels = ['Sin datos']; $advisorAssigned = [0]; $advisorConfirmedNew = [0]; $advisorRecovered = [0]; $advisorCanceled = [0]; $advisorFollowup = [0]; } $trendAssigned = []; $trendConfirmedNew = []; $trendRecovered = []; $trendCanceled = []; foreach (array_keys($trend) as $dayKey) { $trendAssigned[] = (int) $trend[$dayKey]['assigned_today']; $trendConfirmedNew[] = (int) $trend[$dayKey]['confirmed_new']; $trendRecovered[] = (int) $trend[$dayKey]['recovered']; $trendCanceled[] = (int) $trend[$dayKey]['canceled']; } $advisorCount = 0; foreach ($advisorLabels as $advisorLabel) { if ($advisorLabel !== 'Sin datos' && $advisorLabel !== 'Sin asignar') { $advisorCount++; } } return [ 'summary' => $summary, 'status_chart' => [ 'labels' => ['Confirmados nuevos', 'Recuperados', 'Por llamar', 'Devolver llamada', 'Observados', 'Se envió número de cuenta', 'Repetidos', 'Cancelados'], 'datasets' => [ [ 'label' => 'Estados', 'data' => [ (int) ($summary['confirmed_today_new'] ?? 0), (int) ($summary['confirmed_today_recovered'] ?? 0), (int) ($followupStateCounts['POR LLAMAR'] ?? 0), (int) ($followupStateCounts['DEVOLVER LLAMADA'] ?? 0), (int) ($followupStateCounts['OBSERVADO'] ?? 0), (int) ($followupStateCounts['SE ENVIO NUMERO DE CUENTA'] ?? 0), (int) ($summary['repeated_today'] ?? 0), (int) ($summary['canceled_today'] ?? 0), ], 'backgroundColor' => [ 'rgba(25, 135, 84, 0.9)', 'rgba(20, 184, 166, 0.84)', 'rgba(13, 110, 253, 0.88)', 'rgba(13, 202, 240, 0.86)', 'rgba(255, 193, 7, 0.82)', 'rgba(253, 126, 20, 0.84)', 'rgba(108, 117, 125, 0.84)', 'rgba(220, 53, 69, 0.9)', ], 'borderColor' => [ 'rgba(25, 135, 84, 1)', 'rgba(13, 148, 136, 1)', 'rgba(13, 110, 253, 1)', 'rgba(13, 202, 240, 1)', 'rgba(245, 158, 11, 1)', 'rgba(253, 126, 20, 1)', 'rgba(108, 117, 125, 1)', 'rgba(220, 53, 69, 1)', ], 'borderWidth' => 2, 'hoverOffset' => 6, ], ], ], 'advisor_count' => $advisorCount, 'advisor_chart' => [ 'labels' => $advisorLabels, 'datasets' => [ [ 'label' => 'Asignados hoy', 'data' => $advisorAssigned, 'backgroundColor' => 'rgba(13, 110, 253, 0.75)', 'borderColor' => 'rgba(13, 110, 253, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Confirmados nuevos hoy', 'data' => $advisorConfirmedNew, 'backgroundColor' => 'rgba(25, 135, 84, 0.78)', 'borderColor' => 'rgba(25, 135, 84, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Recuperados de otros días', 'data' => $advisorRecovered, 'backgroundColor' => 'rgba(255, 193, 7, 0.82)', 'borderColor' => 'rgba(245, 158, 11, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Cancelados hoy', 'data' => $advisorCanceled, 'backgroundColor' => 'rgba(220, 53, 69, 0.75)', 'borderColor' => 'rgba(220, 53, 69, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], ], ], 'trend_chart' => [ 'labels' => $trendLabels, 'datasets' => [ [ 'label' => 'Asignados', 'data' => $trendAssigned, 'borderColor' => 'rgba(13, 110, 253, 1)', 'backgroundColor' => 'rgba(13, 110, 253, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Confirmados nuevos', 'data' => $trendConfirmedNew, 'borderColor' => 'rgba(25, 135, 84, 1)', 'backgroundColor' => 'rgba(25, 135, 84, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Recuperados', 'data' => $trendRecovered, 'borderColor' => 'rgba(245, 158, 11, 1)', 'backgroundColor' => 'rgba(245, 158, 11, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Cancelados', 'data' => $trendCanceled, 'borderColor' => 'rgba(220, 53, 69, 1)', 'backgroundColor' => 'rgba(220, 53, 69, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], ], ], ]; } function cc_test_build_performance_dashboard_period(array $orders, array $assessors, DateTimeImmutable $scopeStart, DateTimeImmutable $scopeEnd): array { if ($scopeStart > $scopeEnd) { [$scopeStart, $scopeEnd] = [$scopeEnd, $scopeStart]; } $trendDays = max(1, $scopeStart->diff($scopeEnd)->days + 1); $trend = []; $trendLabels = []; for ($i = 0; $i < $trendDays; $i++) { $day = $scopeStart->add(new DateInterval('P' . $i . 'D')); $dayKey = $day->format('Y-m-d'); $trend[$dayKey] = [ 'label' => $day->format('d/m'), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, ]; $trendLabels[] = $day->format('d/m'); } $orderedAssessorKeys = cc_test_ordered_assessor_keys($assessors); $bucketStats = []; foreach ($orderedAssessorKeys as $assessorKey) { $bucketStats[$assessorKey] = [ 'label' => cc_test_dashboard_bucket_label($assessorKey, $assessors), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, 'followup_open' => 0, 'repeated' => 0, ]; } $summary = [ 'scope_total' => 0, 'assigned_today' => 0, 'assigned_effective_today' => 0, 'confirmed_today_new' => 0, 'confirmed_today_recovered' => 0, 'confirmed_today_total' => 0, 'canceled_today' => 0, 'repeated_today' => 0, 'followup_open' => 0, 'observados_today' => 0, 'confirm_rate_today' => 0.0, 'recovery_rate_today' => 0.0, 'performance_today_rate' => 0.0, ]; $hasUnassignedBucket = false; $followupStateCounts = [ 'POR LLAMAR' => 0, 'DEVOLVER LLAMADA' => 0, 'OBSERVADO' => 0, 'SE ENVIO NUMERO DE CUENTA' => 0, ]; foreach ($orders as $order) { $bucketKey = cc_test_dashboard_bucket_key($order, $assessors, true); if ($bucketKey === '') { continue; } if ($bucketKey === 'SIN_ASIGNAR') { $hasUnassignedBucket = true; } if (!isset($bucketStats[$bucketKey])) { $bucketStats[$bucketKey] = [ 'label' => cc_test_dashboard_bucket_label($bucketKey, $assessors), 'assigned_today' => 0, 'confirmed_new' => 0, 'recovered' => 0, 'canceled' => 0, 'followup_open' => 0, 'repeated' => 0, ]; } $assignedDate = cc_test_followup_parse_datetime($order['assigned_at'] ?? null); $assignedKey = $assignedDate ? $assignedDate->format('Y-m-d') : null; $actionDate = cc_test_dashboard_action_datetime($order); $actionKey = $actionDate ? $actionDate->format('Y-m-d') : null; $estado = cc_test_normalize_state((string) ($order['estado'] ?? '')); $followupInfo = cc_test_followup_day_info($order); $isPromoFinal = !empty($followupInfo['is_promo_final']) && !empty($order['promo_final_evidence_path']); $isConfirmed = in_array($estado, cc_test_confirmed_states(), true); $isCanceled = $estado === 'CANCELADO'; $isRepeated = $estado === 'REPETIDO'; $isFollowupState = in_array($estado, cc_test_followup_tracking_states(), true); $isFollowupOpen = $isFollowupState && !$isPromoFinal; $assignedInScope = $assignedDate instanceof DateTimeImmutable && $assignedDate >= $scopeStart && $assignedDate <= $scopeEnd; $actionInScope = $actionDate instanceof DateTimeImmutable && $actionDate >= $scopeStart && $actionDate <= $scopeEnd; if (!$assignedInScope && !$actionInScope) { continue; } $summary['scope_total']++; if ($assignedInScope) { $bucketStats[$bucketKey]['assigned_today']++; $summary['assigned_today']++; if (isset($trend[$assignedKey])) { $trend[$assignedKey]['assigned_today']++; } if ($isFollowupOpen) { $bucketStats[$bucketKey]['followup_open']++; $summary['followup_open']++; if (isset($followupStateCounts[$estado])) { $followupStateCounts[$estado]++; } if ($estado === 'OBSERVADO') { $summary['observados_today']++; } } } if ($isConfirmed && $actionInScope && $assignedDate instanceof DateTimeImmutable) { $isNewConfirmation = $assignedKey === $actionKey; $isRecoveredConfirmation = $assignedDate < $actionDate; if ($isNewConfirmation) { $bucketStats[$bucketKey]['confirmed_new']++; $summary['confirmed_today_new']++; } elseif ($isRecoveredConfirmation) { $bucketStats[$bucketKey]['recovered']++; $summary['confirmed_today_recovered']++; } if (($isNewConfirmation || $isRecoveredConfirmation) && isset($trend[$actionKey])) { $trend[$actionKey][$isNewConfirmation ? 'confirmed_new' : 'recovered']++; } } if ($isCanceled && $assignedInScope && $actionInScope && $assignedKey === $actionKey) { $bucketStats[$bucketKey]['canceled']++; $summary['canceled_today']++; if (isset($trend[$actionKey])) { $trend[$actionKey]['canceled']++; } } if ($isRepeated && $assignedInScope && $actionInScope && $assignedKey === $actionKey) { $bucketStats[$bucketKey]['repeated']++; $summary['repeated_today']++; } } $summary['confirmed_today_total'] = $summary['confirmed_today_new'] + $summary['confirmed_today_recovered']; $summary['assigned_effective_today'] = max(0, (int) ($summary['assigned_today'] ?? 0) - (int) ($summary['repeated_today'] ?? 0)); $effectiveAssignedToday = (int) ($summary['assigned_effective_today'] ?? 0); $summary['confirm_rate_today'] = $effectiveAssignedToday > 0 ? round(($summary['confirmed_today_new'] / $effectiveAssignedToday) * 100, 1) : 0.0; $summary['recovery_rate_today'] = $summary['confirmed_today_total'] > 0 ? round(($summary['confirmed_today_recovered'] / $summary['confirmed_today_total']) * 100, 1) : 0.0; $summary['performance_today_rate'] = $effectiveAssignedToday > 0 ? round(($summary['confirmed_today_total'] / $effectiveAssignedToday) * 100, 1) : 0.0; $advisorLabels = []; $advisorAssigned = []; $advisorConfirmedNew = []; $advisorRecovered = []; $advisorCanceled = []; $advisorFollowup = []; $finalBucketKeys = $orderedAssessorKeys; foreach (array_keys($bucketStats) as $bucketKey) { if (!in_array($bucketKey, $finalBucketKeys, true) && $bucketKey !== 'SIN_ASIGNAR') { $finalBucketKeys[] = $bucketKey; } } if ($hasUnassignedBucket) { $finalBucketKeys[] = 'SIN_ASIGNAR'; } foreach ($finalBucketKeys as $bucketKey) { if (!isset($bucketStats[$bucketKey])) { continue; } $bucket = $bucketStats[$bucketKey]; $advisorLabels[] = $bucket['label']; $advisorAssigned[] = (int) $bucket['assigned_today']; $advisorConfirmedNew[] = (int) $bucket['confirmed_new']; $advisorRecovered[] = (int) $bucket['recovered']; $advisorCanceled[] = (int) $bucket['canceled']; $advisorFollowup[] = (int) $bucket['followup_open']; } if (empty($advisorLabels)) { $advisorLabels = ['Sin datos']; $advisorAssigned = [0]; $advisorConfirmedNew = [0]; $advisorRecovered = [0]; $advisorCanceled = [0]; $advisorFollowup = [0]; } $trendAssigned = []; $trendConfirmedNew = []; $trendRecovered = []; $trendCanceled = []; foreach (array_keys($trend) as $dayKey) { $trendAssigned[] = (int) $trend[$dayKey]['assigned_today']; $trendConfirmedNew[] = (int) $trend[$dayKey]['confirmed_new']; $trendRecovered[] = (int) $trend[$dayKey]['recovered']; $trendCanceled[] = (int) $trend[$dayKey]['canceled']; } $advisorCount = 0; foreach ($advisorLabels as $advisorLabel) { if ($advisorLabel !== 'Sin datos' && $advisorLabel !== 'Sin asignar') { $advisorCount++; } } return [ 'summary' => $summary, 'status_chart' => [ 'labels' => ['Confirmados nuevos', 'Recuperados', 'Por llamar', 'Devolver llamada', 'Observados', 'Se envió número de cuenta', 'Repetidos', 'Cancelados'], 'datasets' => [ [ 'label' => 'Estados', 'data' => [ (int) ($summary['confirmed_today_new'] ?? 0), (int) ($summary['confirmed_today_recovered'] ?? 0), (int) ($followupStateCounts['POR LLAMAR'] ?? 0), (int) ($followupStateCounts['DEVOLVER LLAMADA'] ?? 0), (int) ($followupStateCounts['OBSERVADO'] ?? 0), (int) ($followupStateCounts['SE ENVIO NUMERO DE CUENTA'] ?? 0), (int) ($summary['repeated_today'] ?? 0), (int) ($summary['canceled_today'] ?? 0), ], 'backgroundColor' => [ 'rgba(25, 135, 84, 0.9)', 'rgba(20, 184, 166, 0.84)', 'rgba(13, 110, 253, 0.88)', 'rgba(13, 202, 240, 0.86)', 'rgba(255, 193, 7, 0.82)', 'rgba(253, 126, 20, 0.84)', 'rgba(108, 117, 125, 0.84)', 'rgba(220, 53, 69, 0.9)', ], 'borderColor' => [ 'rgba(25, 135, 84, 1)', 'rgba(13, 148, 136, 1)', 'rgba(13, 110, 253, 1)', 'rgba(13, 202, 240, 1)', 'rgba(245, 158, 11, 1)', 'rgba(253, 126, 20, 1)', 'rgba(108, 117, 125, 1)', 'rgba(220, 53, 69, 1)', ], 'borderWidth' => 2, 'hoverOffset' => 6, ], ], ], 'advisor_count' => $advisorCount, 'advisor_chart' => [ 'labels' => $advisorLabels, 'datasets' => [ [ 'label' => 'Asignados en el periodo', 'data' => $advisorAssigned, 'backgroundColor' => 'rgba(13, 110, 253, 0.75)', 'borderColor' => 'rgba(13, 110, 253, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Confirmados nuevos', 'data' => $advisorConfirmedNew, 'backgroundColor' => 'rgba(25, 135, 84, 0.78)', 'borderColor' => 'rgba(25, 135, 84, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Recuperados de otros días', 'data' => $advisorRecovered, 'backgroundColor' => 'rgba(255, 193, 7, 0.82)', 'borderColor' => 'rgba(245, 158, 11, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], [ 'label' => 'Cancelados', 'data' => $advisorCanceled, 'backgroundColor' => 'rgba(220, 53, 69, 0.75)', 'borderColor' => 'rgba(220, 53, 69, 1)', 'borderWidth' => 1, 'borderRadius' => 6, 'maxBarThickness' => 22, ], ], ], 'trend_chart' => [ 'labels' => $trendLabels, 'datasets' => [ [ 'label' => 'Asignados', 'data' => $trendAssigned, 'borderColor' => 'rgba(13, 110, 253, 1)', 'backgroundColor' => 'rgba(13, 110, 253, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Confirmados nuevos', 'data' => $trendConfirmedNew, 'borderColor' => 'rgba(25, 135, 84, 1)', 'backgroundColor' => 'rgba(25, 135, 84, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Recuperados', 'data' => $trendRecovered, 'borderColor' => 'rgba(245, 158, 11, 1)', 'backgroundColor' => 'rgba(245, 158, 11, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], [ 'label' => 'Cancelados', 'data' => $trendCanceled, 'borderColor' => 'rgba(220, 53, 69, 1)', 'backgroundColor' => 'rgba(220, 53, 69, 0.12)', 'borderWidth' => 2, 'tension' => 0.35, 'fill' => false, 'pointRadius' => 2, 'pointHoverRadius' => 4, ], ], ], ]; } function cc_test_filter_dashboard_status_chart(array $dashboard, array $keepLabels): array { $keepLookup = []; foreach ($keepLabels as $label) { $label = trim((string) $label); if ($label !== '') { $keepLookup[$label] = true; } } if ($keepLookup === []) { return $dashboard; } $statusChart = (array) ($dashboard['status_chart'] ?? []); $labels = array_values((array) ($statusChart['labels'] ?? [])); $datasets = array_values((array) ($statusChart['datasets'] ?? [])); if ($labels === [] || $datasets === []) { return $dashboard; } $keptIndexes = []; $filteredLabels = []; foreach ($labels as $index => $label) { $label = (string) $label; if (!isset($keepLookup[$label])) { continue; } $keptIndexes[] = $index; $filteredLabels[] = $label; } if ($filteredLabels === []) { return $dashboard; } $filteredDatasets = []; foreach ($datasets as $dataset) { $dataset = (array) $dataset; $filteredDataset = $dataset; foreach (['data', 'backgroundColor', 'borderColor'] as $field) { if (!isset($dataset[$field]) || !is_array($dataset[$field])) { continue; } $values = array_values($dataset[$field]); $filteredValues = []; foreach ($keptIndexes as $index) { if (array_key_exists($index, $values)) { $filteredValues[] = $values[$index]; } } $filteredDataset[$field] = $filteredValues; } $filteredDatasets[] = $filteredDataset; } $statusChart['labels'] = $filteredLabels; $statusChart['datasets'] = $filteredDatasets; $dashboard['status_chart'] = $statusChart; return $dashboard; } function cc_test_render_performance_dashboard(array $dashboard, array $options = []): string { $title = trim((string) ($options['title'] ?? 'KPI diario por asesora')); $subtitle = trim((string) ($options['subtitle'] ?? 'Confirmación = pedidos asignados hoy confirmados hoy, sin contar repetidos. Recuperación = pedidos asignados antes de hoy confirmados hoy. Rendimiento del día = confirmados totales (nuevos + recuperados) sobre asignados efectivos sin repetidos.')); $chart1Title = trim((string) ($options['chart1_title'] ?? 'Distribución de estados de hoy')); $chart1Note = trim((string) ($options['chart1_note'] ?? 'La dona separa los pedidos de hoy por estado: confirmados nuevos, recuperados, POR LLAMAR, DEVOLVER LLAMADA, OBSERVADO, SE ENVIO NUMERO DE CUENTA, repetidos (solo cantidad) y cancelados.')); $chart2Title = trim((string) ($options['chart2_title'] ?? 'Evolución de los últimos 7 días')); $chart2Note = trim((string) ($options['chart2_note'] ?? 'La línea de confirmados separa pedidos asignados hoy y pedidos asignados antes de hoy.')); $footnote = trim((string) ($options['footnote'] ?? 'Seguimiento abierto = pedidos asignados hoy en POR LLAMAR, DEVOLVER LLAMADA, OBSERVADO y SE ENVIO NUMERO DE CUENTA que todavía no pasan a Promo Final.')); $performanceLabel = trim((string) ($options['performance_label'] ?? 'Rendimiento del día')); $statusDetailHeading = trim((string) ($options['status_detail_heading'] ?? 'Detalle del KPI de hoy (%)')); $sectionId = preg_replace('/[^A-Za-z0-9_-]+/', '', (string) ($options['section_id'] ?? 'ccPerformanceDashboard')); if ($sectionId === '') { $sectionId = 'ccPerformanceDashboard'; } $summary = (array) ($dashboard['summary'] ?? []); $trendChart = (array) ($dashboard['trend_chart'] ?? []); $statusChart = (array) ($dashboard['status_chart'] ?? []); $statusChartLabels = array_values((array) ($statusChart['labels'] ?? [])); $statusChartDataset = (array) (($statusChart['datasets'][0] ?? []) ?: []); $statusChartValues = array_map(static function ($value) { return (int) $value; }, array_values((array) ($statusChartDataset['data'] ?? []))); $statusChartColors = is_array($statusChartDataset['backgroundColor'] ?? null) ? array_values($statusChartDataset['backgroundColor']) : []; $statusTotal = array_sum($statusChartValues); $statusRepeatedTotal = 0; foreach ($statusChartLabels as $index => $label) { if (preg_match('/repetid/i', (string) $label) === 1) { $statusRepeatedTotal += (int) ($statusChartValues[$index] ?? 0); } } $statusPercentageTotal = max(0, $statusTotal - $statusRepeatedTotal); $confirmedTodayNew = (int) ($summary['confirmed_today_new'] ?? 0); $confirmedTodayRecovered = (int) ($summary['confirmed_today_recovered'] ?? 0); $confirmedTodayTotal = (int) ($summary['confirmed_today_total'] ?? 0); $effectiveAssignedToday = (int) ($summary['assigned_effective_today'] ?? max(0, (int) ($summary['assigned_today'] ?? 0) - (int) ($summary['repeated_today'] ?? 0))); $performanceTodayRate = (float) ($summary['performance_today_rate'] ?? 0); $hidePerformanceTodayValue = !empty($options['performance_value_hidden']); $performanceTodayMetaTitle = trim((string) ($options['performance_meta_title'] ?? '')); if ($performanceTodayMetaTitle === '') { $performanceTodayMetaTitle = sprintf( '%d confirmados sobre %d asignados efectivos = %d nuevos + %d recuperado%s', $confirmedTodayTotal, $effectiveAssignedToday, $confirmedTodayNew, $confirmedTodayRecovered, $confirmedTodayRecovered === 1 ? '' : 's' ); } $performanceTodayValue = ''; if (!$hidePerformanceTodayValue) { $performanceTodayValue = trim((string) ($options['performance_value'] ?? '')); if ($performanceTodayValue === '') { $performanceTodayValue = number_format($performanceTodayRate, 1) . '%'; } } $statusLegendItems = []; if ($statusTotal > 0) { foreach ($statusChartValues as $index => $value) { $label = (string) ($statusChartLabels[$index] ?? ('Estado ' . ($index + 1))); $isQuantityOnly = preg_match('/repetid/i', $label) === 1; $statusLegendItems[] = [ 'label' => $label, 'value' => (int) $value, 'percentage' => $isQuantityOnly ? null : ($statusPercentageTotal > 0 ? round(((float) $value / $statusPercentageTotal) * 100, 1) : 0.0), 'is_quantity_only' => $isQuantityOnly, 'color' => (string) ($statusChartColors[$index] ?? 'rgba(108, 117, 125, 0.65)'), ]; } } else { $statusLegendItems[] = [ 'label' => 'Sin estados', 'value' => 0, 'percentage' => 0.0, 'color' => 'rgba(108, 117, 125, 0.65)', ]; } $badges = [ ['label' => 'Asignados hoy', 'value' => (int) ($summary['assigned_today'] ?? 0), 'class' => 'cc-kpi-pill-primary'], ['label' => 'Confirmados hoy', 'value' => $confirmedTodayTotal, 'class' => 'cc-kpi-pill-success'], ['label' => 'Recuperados', 'value' => $confirmedTodayRecovered, 'class' => 'cc-kpi-pill-warning'], ['label' => 'Seguimiento abierto', 'value' => (int) ($summary['followup_open'] ?? 0), 'class' => 'cc-kpi-pill-info'], ['label' => 'Cancelados hoy', 'value' => (int) ($summary['canceled_today'] ?? 0), 'class' => 'cc-kpi-pill-danger'], ['label' => '% confirmación', 'value' => number_format((float) ($summary['confirm_rate_today'] ?? 0), 1) . '%', 'class' => 'cc-kpi-pill-dark'], ['label' => '% recuperación', 'value' => number_format((float) ($summary['recovery_rate_today'] ?? 0), 1) . '%', 'class' => 'cc-kpi-pill-secondary'], ]; $extraLineParts = []; if (isset($summary['confirmed_today_new'])) { $extraLineParts[] = (int) $summary['confirmed_today_new'] . ' confirmados nuevos'; } if (isset($summary['confirmed_today_recovered'])) { $extraLineParts[] = (int) $summary['confirmed_today_recovered'] . ' recuperados'; } if (isset($summary['repeated_today'])) { $extraLineParts[] = (int) $summary['repeated_today'] . ' repetidos'; } if (isset($summary['canceled_today'])) { $extraLineParts[] = (int) $summary['canceled_today'] . ' cancelados'; } if (isset($summary['followup_open'])) { $extraLineParts[] = (int) $summary['followup_open'] . ' en seguimiento'; } $extraLine = implode(' · ', $extraLineParts); ob_start(); ?>
Panel KPI

pedidos

'SE ENVIÓ NÚMERO DE CUENTA', 'CONFIRMADO CONTRAENTREGA' => 'CONFIRMADO CONTRAENTREGA', 'CONFIRMADO ENVIO' => 'CONFIRMADO ENVIO', 'REPETIDO' => 'REPETIDO', default => $estado, }; } function cc_test_normalize_contraentrega_coordinates(string $value): ?string { $value = trim($value); if ($value === '') { return null; } if (!preg_match('/^\s*(-?\d{1,2}\.\d{6,})\s*,\s*(-?\d{1,3}\.\d{6,})\s*$/', $value, $matches)) { return null; } $latitud = (float) $matches[1]; $longitud = (float) $matches[2]; if ($latitud < -90 || $latitud > 90 || $longitud < -180 || $longitud > 180) { return null; } return $matches[1] . ', ' . $matches[2]; } function cc_test_parse_quantity(?string $value): ?int { $value = trim((string) $value); if ($value === '') { return null; } if (!preg_match('/-?\d+/', $value, $matches)) { return null; } $cantidad = (int) $matches[0]; if ($cantidad <= 0) { return null; } return $cantidad; } function cc_test_parse_amount(?string $value): ?float { $value = trim((string) $value); if ($value === '') { return null; } $value = str_ireplace(['S/', 'USD', '$'], '', $value); $value = preg_replace('/\s+/', '', $value) ?? $value; if (strpos($value, ',') !== false && strpos($value, '.') === false) { $value = str_replace(',', '.', $value); } else { $value = str_replace(',', '', $value); } $value = preg_replace('/[^0-9.\-]/', '', $value) ?? $value; if ($value === '' || !is_numeric($value)) { return null; } return round((float) $value, 2); } function cc_test_shipping_agency_options(): array { return ['SHALOM', 'OLVA', 'OTROS']; } function cc_test_normalize_shipping_agency(?string $value): ?string { $value = strtoupper(trim((string) $value)); if ($value === '') { return null; } return match ($value) { 'OLVA COURIER', 'OLVA' => 'OLVA', 'SHALOM' => 'SHALOM', 'OTRO', 'OTROS', 'OTRAS' => 'OTROS', default => $value, }; } function cc_test_fetch_shalom_sedes(PDO $pdo): array { if (!cc_test_table_exists($pdo, 'sedes_shalom')) { return []; } $stmt = $pdo->query('SELECT nombre_sede FROM sedes_shalom ORDER BY nombre_sede ASC'); $sedes = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; return is_array($sedes) ? array_values(array_filter(array_map('strval', $sedes), static fn ($value) => trim($value) !== '')) : []; } function cc_test_parse_quantity_value(mixed $value, int $default = 1): int { if (is_int($value)) { return $value > 0 ? $value : $default; } if (is_float($value)) { $quantity = (int) round($value); return $quantity > 0 ? $quantity : $default; } $value = trim((string) $value); if ($value === '') { return $default; } if (strpos($value, '+') !== false) { $sum = 0; foreach (explode('+', $value) as $part) { $part = trim($part); if ($part === '') { continue; } if (is_numeric($part)) { $sum += (int) $part; continue; } if (preg_match('/\d+/', $part, $match)) { $sum += (int) $match[0]; } } return $sum > 0 ? $sum : $default; } if (preg_match('/\d+/', $value, $match)) { $quantity = (int) $match[0]; return $quantity > 0 ? $quantity : $default; } return $default; } function cc_test_parse_product_detail_rows_from_text(string $text): array { $text = trim($text); if ($text === '') { return []; } if (!preg_match_all('/(?:Detalle de productos|Detalle confirmado):\s*(.+)$/mi', $text, $matches) || empty($matches[1])) { return []; } $lastLine = trim((string) end($matches[1])); if ($lastLine === '') { return []; } $parts = preg_split('/\s*,\s*/u', $lastLine); if (!is_array($parts)) { return []; } $rows = []; foreach ($parts as $part) { $part = trim((string) $part); if ($part === '') { continue; } $name = $part; $quantity = 1; if (preg_match('/^(.*?)\s*\(x\s*(\d+)\s*\)$/iu', $part, $match)) { $name = trim((string) $match[1]); $quantity = (int) $match[2]; } $name = trim($name); if ($name === '') { continue; } $rows[] = [ 'nombre' => $name, 'cantidad' => $quantity > 0 ? $quantity : 1, ]; } return $rows; } function cc_test_product_rows_from_tracking(array $tracking): array { $rows = []; $mainName = trim((string) ($tracking['confirmacion_producto'] ?? '')); $mainQty = cc_test_parse_quantity_value($tracking['confirmacion_cantidad'] ?? '', 1); if ($mainName !== '') { $rows[] = [ 'nombre' => $mainName, 'cantidad' => $mainQty, ]; } $extraName = trim((string) ($tracking['confirmacion_producto_extra'] ?? '')); $extraQty = cc_test_parse_quantity_value($tracking['confirmacion_cantidad_extra'] ?? '', 1); if ($extraName !== '') { $rows[] = [ 'nombre' => $extraName, 'cantidad' => $extraQty, ]; } return $rows; } function cc_test_resolve_pedido_product_rows(PDO $pdo, array $pedido, ?string $trackingLinkColumn = null): array { $pedidoId = (int) ($pedido['id'] ?? 0); $allowedTrackingColumns = ['ruta_contraentrega_pedido_id', 'pedido_rotulado_pedido_id']; if ($pedidoId > 0 && $trackingLinkColumn !== null && in_array($trackingLinkColumn, $allowedTrackingColumns, true) && cc_test_table_exists($pdo, 'callcenter_test_tracking')) { $sql = 'SELECT confirmacion_producto, confirmacion_cantidad, confirmacion_producto_extra, confirmacion_cantidad_extra FROM callcenter_test_tracking WHERE ' . $trackingLinkColumn . ' = ? ORDER BY id DESC LIMIT 1'; $stmt = $pdo->prepare($sql); $stmt->execute([$pedidoId]); $tracking = $stmt->fetch(PDO::FETCH_ASSOC); if (is_array($tracking)) { $rows = cc_test_product_rows_from_tracking($tracking); if (!empty($rows)) { return $rows; } } } $rows = cc_test_parse_product_detail_rows_from_text((string) ($pedido['notas'] ?? '')); if (!empty($rows)) { return $rows; } $producto = trim((string) ($pedido['producto'] ?? '')); if ($producto === '') { return []; } $names = array_values(array_filter(array_map('trim', preg_split('/\s*,\s*/u', $producto) ?: []), static fn ($value) => $value !== '')); if (empty($names)) { return []; } $cantidadTotal = cc_test_parse_quantity_value($pedido['cantidad'] ?? 1, 1); $cantidadField = $pedido['cantidad'] ?? ''; $qtyParts = []; if (is_string($cantidadField) && strpos(trim($cantidadField), '+') !== false) { $qtyParts = array_values(array_map('trim', explode('+', trim($cantidadField)))); } $rows = []; $nameCount = count($names); foreach ($names as $index => $name) { if ($nameCount === 1) { $quantity = $cantidadTotal; } elseif (!empty($qtyParts) && isset($qtyParts[$index]) && is_numeric($qtyParts[$index])) { $quantity = (int) $qtyParts[$index]; } else { $quantity = 1; } $rows[] = [ 'nombre' => $name, 'cantidad' => $quantity > 0 ? $quantity : 1, ]; } return $rows; } function cc_test_row_background_style(string $estado): string { $color = match (cc_test_normalize_state($estado)) { // Blanco 'POR LLAMAR', 'DEVOLVER LLAMADA' => '#ffffff', // Amarillo 'OBSERVADO' => '#FFFF00', // Verde brillante 'SE ENVIO NUMERO DE CUENTA' => '#00FF00', // Naranja 'CONFIRMADO CONTRAENTREGA' => '#ffb74d', // Verde Google 'CONFIRMADO ENVIO' => '#34A853', // Rojo 'CANCELADO' => '#FF0000', // Plomo 'REPETIDO' => '#CCCCCC', default => '#ffffff', }; // We set a CSS variable so we can paint the entire row (all cells). // Bootstrap/table styles often apply opaque backgrounds to , which can hide // the background. A CSS rule below will apply this variable to every cell. return '--cc-row-bg:' . $color . ';'; } if (!function_exists('cc_test_table_exists')) { function cc_test_table_exists(PDO $pdo, string $tableName): bool { static $cache = []; if (array_key_exists($tableName, $cache)) { return $cache[$tableName]; } $stmt = $pdo->prepare('SHOW TABLES LIKE ?'); $stmt->execute([$tableName]); $cache[$tableName] = (bool) $stmt->fetchColumn(); return $cache[$tableName]; } } function cc_test_fetch_historial(PDO $pdo, string $pedidoId): array { if (cc_test_table_exists($pdo, 'usuarios')) { $stmt = $pdo->prepare("SELECT h.*, u.nombre as asesor FROM historial_llamadas h LEFT JOIN usuarios u ON h.asesor_id = u.id WHERE h.pedido_id = ? ORDER BY h.fecha_llamada DESC"); $stmt->execute([$pedidoId]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } $stmt = $pdo->prepare("SELECT h.*, NULL as asesor FROM historial_llamadas h WHERE h.pedido_id = ? ORDER BY h.fecha_llamada DESC"); $stmt->execute([$pedidoId]); return $stmt->fetchAll(PDO::FETCH_ASSOC); }