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:
sergio
2026-05-21 18:21:09 +00:00
parent bb21c28eb1
commit 78fbde12b4
38 changed files with 1229 additions and 334 deletions
@@ -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,
},
}