feat(nakui): módulo crm — clientes, pipeline de ventas, interacciones
Módulo CRM declarativo (schema.ncl + nsmc.json + morfismos Rhai) con tres entities (Cliente, Oportunidad, Interaccion) y tres morfismos: abrir_oportunidad, mover_oportunidad (pipeline con validación de transiciones) y registrar_interaccion. crm_demo: demo realista de 18 eventos que —a diferencia de los otros demos— conserva el event log e imprime el comando de nakui-explorer, así el explorador muestra un CRM con cuerpo. tests/crm.rs: 8 tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// abrir_oportunidad
|
||||
// Abre una oportunidad de venta para un cliente existente. La etapa
|
||||
// inicial la fija el morfismo ("prospecto"), no el caller — una
|
||||
// oportunidad siempre nace al principio del pipeline.
|
||||
//
|
||||
// ids.cliente: UUID del Cliente (la oportunidad guarda la FK).
|
||||
// params: { oportunidad_id, titulo, monto:i64, currency:str, timestamp:str }
|
||||
|
||||
let opp_id = input.params.oportunidad_id;
|
||||
if type_of(opp_id) == "()" {
|
||||
throw "params.oportunidad_id es obligatorio (idempotencia)"
|
||||
}
|
||||
|
||||
let titulo = input.params.titulo;
|
||||
if type_of(titulo) == "()" {
|
||||
throw "params.titulo es obligatorio"
|
||||
}
|
||||
|
||||
let monto = input.params.monto;
|
||||
if monto <= 0 {
|
||||
throw "monto debe ser positivo"
|
||||
}
|
||||
|
||||
[
|
||||
#{
|
||||
op: "create",
|
||||
entity: "Oportunidad",
|
||||
id: opp_id,
|
||||
data: #{
|
||||
id: opp_id,
|
||||
cliente_id: input.ids.cliente,
|
||||
titulo: titulo,
|
||||
monto: monto,
|
||||
currency: input.params.currency,
|
||||
etapa: "prospecto",
|
||||
timestamp: input.params.timestamp,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
// mover_oportunidad
|
||||
// Mueve una oportunidad por el pipeline de ventas. La etapa destino la
|
||||
// fija el caller; el morfismo valida que la transición sea legal:
|
||||
// - la etapa destino debe ser conocida,
|
||||
// - una oportunidad cerrada (ganada/perdida) ya no se mueve,
|
||||
// - dentro del pipeline abierto sólo se avanza, no se retrocede;
|
||||
// cerrar (ganada/perdida) es legal desde cualquier etapa abierta.
|
||||
//
|
||||
// states.oportunidad: el registro Oportunidad.
|
||||
// params: { etapa:str, timestamp:str }
|
||||
|
||||
let etapa_actual = input.states.oportunidad.etapa;
|
||||
let etapa_destino = input.params.etapa;
|
||||
|
||||
// Rango de cada etapa en el pipeline (-1 = etapa desconocida).
|
||||
let r_actual = -1;
|
||||
if etapa_actual == "prospecto" { r_actual = 0; }
|
||||
if etapa_actual == "calificado" { r_actual = 1; }
|
||||
if etapa_actual == "propuesta" { r_actual = 2; }
|
||||
if etapa_actual == "negociacion" { r_actual = 3; }
|
||||
if etapa_actual == "ganada" { r_actual = 4; }
|
||||
if etapa_actual == "perdida" { r_actual = 5; }
|
||||
|
||||
let r_destino = -1;
|
||||
if etapa_destino == "prospecto" { r_destino = 0; }
|
||||
if etapa_destino == "calificado" { r_destino = 1; }
|
||||
if etapa_destino == "propuesta" { r_destino = 2; }
|
||||
if etapa_destino == "negociacion" { r_destino = 3; }
|
||||
if etapa_destino == "ganada" { r_destino = 4; }
|
||||
if etapa_destino == "perdida" { r_destino = 5; }
|
||||
|
||||
if r_destino < 0 {
|
||||
throw "etapa destino desconocida: " + etapa_destino
|
||||
}
|
||||
if etapa_actual == "ganada" || etapa_actual == "perdida" {
|
||||
throw "la oportunidad ya está cerrada (etapa " + etapa_actual + ")"
|
||||
}
|
||||
|
||||
// Cerrar es legal desde cualquier etapa abierta; dentro del pipeline
|
||||
// abierto sólo se avanza.
|
||||
let cerrando = etapa_destino == "ganada" || etapa_destino == "perdida";
|
||||
if !cerrando {
|
||||
if r_destino <= r_actual {
|
||||
throw "transición inválida " + etapa_actual + " -> " + etapa_destino
|
||||
+ " (no se retrocede)"
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
#{
|
||||
op: "set",
|
||||
path: #{ entity: "Oportunidad", id: input.ids.oportunidad, field: "etapa" },
|
||||
value: etapa_destino,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
// registrar_interaccion
|
||||
// Registra una interacción (llamada, email o reunión) con un cliente.
|
||||
// Crea un registro Interaccion — el historial de contacto del CRM.
|
||||
//
|
||||
// ids.cliente: UUID del Cliente (la interacción guarda la FK).
|
||||
// params: { interaccion_id, canal:str, nota:str, timestamp:str }
|
||||
|
||||
let int_id = input.params.interaccion_id;
|
||||
if type_of(int_id) == "()" {
|
||||
throw "params.interaccion_id es obligatorio (idempotencia)"
|
||||
}
|
||||
|
||||
let canal = input.params.canal;
|
||||
if canal != "llamada" && canal != "email" && canal != "reunion" {
|
||||
throw "canal desconocido: " + canal + " (esperado: llamada | email | reunion)"
|
||||
}
|
||||
|
||||
[
|
||||
#{
|
||||
op: "create",
|
||||
entity: "Interaccion",
|
||||
id: int_id,
|
||||
data: #{
|
||||
id: int_id,
|
||||
cliente_id: input.ids.cliente,
|
||||
canal: canal,
|
||||
nota: input.params.nota,
|
||||
timestamp: input.params.timestamp,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"module": "crm",
|
||||
"morphisms": [
|
||||
{
|
||||
"name": "abrir_oportunidad",
|
||||
"inputs": [
|
||||
{ "role": "cliente", "entity": "Cliente" }
|
||||
],
|
||||
"reads": [],
|
||||
"writes": ["Oportunidad"],
|
||||
"depends_on": [],
|
||||
"script": "morphisms/abrir_oportunidad.rhai"
|
||||
},
|
||||
{
|
||||
"name": "mover_oportunidad",
|
||||
"inputs": [
|
||||
{ "role": "oportunidad", "entity": "Oportunidad" }
|
||||
],
|
||||
"reads": ["oportunidad.etapa"],
|
||||
"writes": ["oportunidad.etapa"],
|
||||
"depends_on": [],
|
||||
"script": "morphisms/mover_oportunidad.rhai"
|
||||
},
|
||||
{
|
||||
"name": "registrar_interaccion",
|
||||
"inputs": [
|
||||
{ "role": "cliente", "entity": "Cliente" }
|
||||
],
|
||||
"reads": [],
|
||||
"writes": ["Interaccion"],
|
||||
"depends_on": [],
|
||||
"script": "morphisms/registrar_interaccion.rhai"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Schema Nickel para el módulo `crm` (Cliente + Oportunidad + Interaccion).
|
||||
#
|
||||
# Un CRM: clientes, oportunidades de venta que recorren un pipeline e
|
||||
# interacciones registradas. El kernel de Nakui valida cada entity
|
||||
# contra el contract de su record — antes y después de cada morfismo.
|
||||
|
||||
let non_empty_string = std.contract.from_predicate (fun s =>
|
||||
std.is_string s && std.string.length s > 0
|
||||
) in
|
||||
|
||||
let positive_int = std.contract.from_predicate (fun n =>
|
||||
std.is_number n && n > 0
|
||||
) in
|
||||
|
||||
let currency_iso = std.contract.from_predicate (fun s =>
|
||||
std.is_string s && std.string.length s == 3
|
||||
) in
|
||||
|
||||
# Etapas del pipeline de ventas. `ganada` y `perdida` son terminales.
|
||||
let etapa_pipeline = std.contract.from_predicate (fun s =>
|
||||
std.is_string s
|
||||
&& std.array.elem s
|
||||
["prospecto", "calificado", "propuesta", "negociacion", "ganada", "perdida"]
|
||||
) in
|
||||
|
||||
let canal_interaccion = std.contract.from_predicate (fun s =>
|
||||
std.is_string s && std.array.elem s ["llamada", "email", "reunion"]
|
||||
) in
|
||||
|
||||
{
|
||||
Cliente = {
|
||||
id | String,
|
||||
nombre | non_empty_string,
|
||||
email | non_empty_string,
|
||||
empresa | String,
|
||||
},
|
||||
|
||||
Oportunidad = {
|
||||
id | String,
|
||||
cliente_id | String,
|
||||
titulo | non_empty_string,
|
||||
monto | positive_int,
|
||||
currency | currency_iso,
|
||||
etapa | etapa_pipeline,
|
||||
timestamp | String,
|
||||
},
|
||||
|
||||
Interaccion = {
|
||||
id | String,
|
||||
cliente_id | String,
|
||||
canal | canal_interaccion,
|
||||
nota | String,
|
||||
timestamp | String,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user