Files
brahman/crates/modules/nahual/libs/meta-runtime/src/metric.rs
T
sergio ab2b8f6638 feat(nakui): Fase 5 del ERP — tablero de KPIs
View::Dashboard: grilla de tarjetas de agregados. Metric Count/Sum/
GroupBy con filtro opcional (CardFilter), computado por compute_metric
en meta-runtime (MetricResult Scalar/Breakdown). meta-form render_dashboard
pinta cada tarjeta con el número grande formateado o un breakdown con
barras de texto. El CRM gana una vista «Panorama»: clientes,
oportunidades, pipeline, ganadas, y breakdowns por etapa y canal.

Tests de compute_metric; verificación del panorama en nakui-ui. Clippy
limpio en las libs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:29:27 +00:00

138 lines
4.0 KiB
Rust

//! Cómputo de los agregados de un tablero (`DashboardCard`).
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
use nahual_meta_schema::{CardFilter, Metric};
/// Resultado de computar una [`Metric`] sobre un conjunto de records.
#[derive(Debug, Clone, PartialEq)]
pub enum MetricResult {
/// Un único número — `Count` o `Sum`.
Scalar(f64),
/// Conteo por grupo, ordenado de mayor a menor — `GroupBy`.
Breakdown(Vec<(String, usize)>),
}
/// Computa el agregado de una tarjeta sobre `records`, aplicando el
/// `filter` si lo hay.
pub fn compute_metric(
metric: &Metric,
filter: Option<&CardFilter>,
records: &[(Uuid, Value)],
) -> MetricResult {
let passes = |v: &Value| match filter {
None => true,
Some(f) => field_as_text(v, &f.field).as_deref() == Some(f.equals.as_str()),
};
match metric {
Metric::Count => {
let n = records.iter().filter(|(_, v)| passes(v)).count();
MetricResult::Scalar(n as f64)
}
Metric::Sum { field } => {
let total: f64 = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
.sum();
MetricResult::Scalar(total)
}
Metric::GroupBy { field } => {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
let key = field_as_text(v, field).unwrap_or_else(|| "(vacío)".to_string());
*counts.entry(key).or_default() += 1;
}
let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
// Mayor conteo primero; empates ordenados por nombre.
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
MetricResult::Breakdown(ranked)
}
}
}
/// Valor de un campo de nivel superior como texto plano, para comparar
/// (filtros) o agrupar (`GroupBy`).
fn field_as_text(v: &Value, field: &str) -> Option<String> {
match v.get(field)? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn recs(items: &[Value]) -> Vec<(Uuid, Value)> {
items.iter().map(|v| (Uuid::new_v4(), v.clone())).collect()
}
#[test]
fn count_all_and_filtered() {
let rs = recs(&[
json!({"etapa": "ganada"}),
json!({"etapa": "ganada"}),
json!({"etapa": "perdida"}),
]);
assert_eq!(
compute_metric(&Metric::Count, None, &rs),
MetricResult::Scalar(3.0)
);
let f = CardFilter {
field: "etapa".into(),
equals: "ganada".into(),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&f), &rs),
MetricResult::Scalar(2.0)
);
}
#[test]
fn sum_skips_missing_and_non_numeric() {
let rs = recs(&[
json!({"monto": 1000}),
json!({"monto": 2500}),
json!({"otro": 1}),
]);
assert_eq!(
compute_metric(
&Metric::Sum {
field: "monto".into()
},
None,
&rs
),
MetricResult::Scalar(3500.0)
);
}
#[test]
fn group_by_counts_and_ranks_by_frequency() {
let rs = recs(&[
json!({"etapa": "prospecto"}),
json!({"etapa": "ganada"}),
json!({"etapa": "ganada"}),
]);
assert_eq!(
compute_metric(
&Metric::GroupBy {
field: "etapa".into()
},
None,
&rs
),
MetricResult::Breakdown(vec![
("ganada".to_string(), 2),
("prospecto".to_string(), 1),
])
);
}
}