3400 lines
124 KiB
PHP
3400 lines
124 KiB
PHP
<?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' => '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();
|
||
?>
|
||
<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);
|
||
}
|