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