34849-vm/includes/callcenter_test_helpers.php
2026-08-10 19:41:44 +00:00

3400 lines
124 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
function cc_test_ensure_column(PDO $pdo, string $table, string $column, string $definition): void
{
static $cache = [];
$key = $table . '.' . $column;
if (isset($cache[$key])) {
return;
}
$stmt = $pdo->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' => '01 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();
?>
<section class="mb-4">
<div class="card border-0 shadow-sm cc-kpi-panel">
<div class="card-body">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div class="pe-xl-3">
<div class="small text-uppercase text-primary fw-semibold mb-1">Panel KPI</div>
<h2 class="h4 fw-bold mb-1"><?php echo htmlspecialchars($title); ?></h2>
<p class="text-muted small mb-0"><?php echo htmlspecialchars($subtitle); ?></p>
</div>
<div class="d-flex flex-wrap gap-2 cc-kpi-pill-group justify-content-xl-end">
<?php foreach ($badges as $badge): ?>
<div class="cc-kpi-pill <?php echo htmlspecialchars($badge['class']); ?>">
<span class="cc-kpi-label"><?php echo htmlspecialchars((string) $badge['label']); ?></span>
<span class="cc-kpi-value"><?php echo htmlspecialchars((string) $badge['value']); ?></span>
<?php if (!empty($badge['meta'])): ?>
<span class="cc-kpi-meta"><?php echo htmlspecialchars((string) $badge['meta']); ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($extraLine !== ''): ?>
<div class="cc-kpi-footnote mt-3"><?php echo htmlspecialchars($extraLine); ?></div>
<?php endif; ?>
<div class="row g-3 mt-2">
<div class="col-12 col-xl-8">
<div class="card border-0 shadow-sm h-100 cc-kpi-chart-card">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-start gap-2">
<div>
<h3 class="h6 fw-bold mb-1"><?php echo htmlspecialchars($chart1Title); ?></h3>
<div class="text-muted small"><?php echo htmlspecialchars($chart1Note); ?></div>
</div>
<span class="badge text-bg-light border align-self-start"><?php echo (int) $statusTotal; ?> <?php echo $statusTotal === 1 ? 'estado' : 'estados'; ?></span>
</div>
<div class="row g-3 mt-3 align-items-start">
<div class="col-12 col-xl-4">
<div class="cc-kpi-chart-wrap">
<canvas id="<?php echo htmlspecialchars($sectionId . 'StatusChart'); ?>"></canvas>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="cc-kpi-status-legend">
<div class="cc-kpi-pill cc-kpi-pill-total cc-kpi-performance-box mb-0" title="<?php echo htmlspecialchars($performanceTodayMetaTitle); ?>" style="flex-direction:column; align-items:flex-start; gap:.1rem;">
<div class="cc-kpi-performance-head">
<span class="cc-kpi-label"><?php echo htmlspecialchars($performanceLabel); ?></span>
<?php if ($performanceTodayValue !== ''): ?>
<span class="cc-kpi-value"><?php echo htmlspecialchars($performanceTodayValue); ?></span>
<?php endif; ?>
</div>
<div class="cc-kpi-meta"><?php echo htmlspecialchars($performanceTodayMetaTitle); ?></div>
</div>
<div class="small text-uppercase text-muted fw-semibold mb-0 cc-kpi-status-heading"><?php echo htmlspecialchars($statusDetailHeading); ?></div>
<?php foreach ($statusLegendItems as $item): ?>
<div class="cc-kpi-status-item">
<span class="cc-kpi-status-dot" style="background: <?php echo htmlspecialchars((string) $item['color']); ?>;"></span>
<div class="cc-kpi-status-text">
<div class="cc-kpi-status-label"><?php echo htmlspecialchars((string) $item['label']); ?></div>
<div class="cc-kpi-status-count"><?php echo (int) $item['value']; ?> pedidos</div>
</div>
<div class="cc-kpi-status-percent"><?php echo !empty($item['is_quantity_only']) ? '—' : (htmlspecialchars(number_format((float) $item['percentage'], 1)) . '%'); ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card border-0 shadow-sm h-100 cc-kpi-chart-card">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-start gap-2">
<div>
<h3 class="h6 fw-bold mb-1"><?php echo htmlspecialchars($chart2Title); ?></h3>
<div class="text-muted small"><?php echo htmlspecialchars($chart2Note); ?></div>
</div>
<span class="badge text-bg-light border align-self-start"><?php echo count((array) ($trendChart['labels'] ?? [])); ?> <?php echo count((array) ($trendChart['labels'] ?? [])) === 1 ? 'día' : 'días'; ?></span>
</div>
<div class="cc-kpi-chart-wrap mt-3">
<canvas id="<?php echo htmlspecialchars($sectionId . 'TrendChart'); ?>"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="cc-kpi-footnote mt-3"><?php echo htmlspecialchars($footnote); ?></div>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function () {
const dashboard = <?php echo json_encode($dashboard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
const statusCanvas = document.getElementById(<?php echo json_encode($sectionId . 'StatusChart', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
const trendCanvas = document.getElementById(<?php echo json_encode($sectionId . 'TrendChart', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
if (!window.Chart) {
return;
}
const statusData = dashboard.status_chart || { labels: [], datasets: [] };
const trendData = dashboard.trend_chart || { labels: [], datasets: [] };
const statusLabels = Array.isArray(statusData.labels) ? statusData.labels : [];
const statusDataset = Array.isArray(statusData.datasets) && statusData.datasets.length > 0 && statusData.datasets[0]
? statusData.datasets[0]
: { label: 'Estados', data: [] };
const statusValues = Array.isArray(statusDataset.data)
? statusDataset.data.map(function (value) {
return Number(value) || 0;
})
: [];
const statusTotal = statusValues.reduce(function (sum, value) {
return sum + value;
}, 0);
const statusHasData = statusTotal > 0;
const statusPercentageBase = <?php echo (int) $statusPercentageTotal; ?>;
const statusBackgroundColors = Array.isArray(statusDataset.backgroundColor) && statusDataset.backgroundColor.length === statusValues.length
? statusDataset.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)'
];
const statusBorderColors = Array.isArray(statusDataset.borderColor) && statusDataset.borderColor.length === statusValues.length
? statusDataset.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)'
];
const statusChartData = statusHasData
? {
labels: statusLabels,
datasets: [{
label: statusDataset.label || 'Estados',
data: statusValues,
backgroundColor: statusBackgroundColors,
borderColor: statusBorderColors,
borderWidth: 2,
hoverOffset: 6
}]
}
: {
labels: ['Sin estados'],
datasets: [{
label: 'Sin datos',
data: [1],
backgroundColor: ['rgba(108, 117, 125, 0.22)'],
borderColor: ['rgba(108, 117, 125, 0.55)'],
borderWidth: 2,
hoverOffset: 0
}]
};
if (statusCanvas) {
new Chart(statusCanvas, {
type: 'doughnut',
data: statusChartData,
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '58%',
radius: '92%',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
if (!statusHasData) {
return 'Sin estados';
}
const label = context.label || '';
const value = typeof context.raw === 'number' ? context.raw : Number(context.raw || 0);
if (label.toLowerCase().includes('repetid')) {
return label + ': ' + value;
}
const percentage = statusPercentageBase > 0 ? ((value / statusPercentageBase) * 100).toFixed(1) : '0.0';
return label + ': ' + value + ' (' + percentage + '%)';
}
}
}
}
}
});
}
if (trendCanvas) {
new Chart(trendCanvas, {
type: 'line',
data: trendData,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
stepSize: 1
}
}
}
}
});
}
})();
</script>
<?php
return trim((string) ob_get_clean());
}
function cc_test_state_label(string $estado): string
{
return match (cc_test_normalize_state($estado)) {
'SE ENVIO NUMERO DE CUENTA' => '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 <td> cells).
// Bootstrap/table styles often apply opaque backgrounds to <td>, which can hide
// the <tr> 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);
}