feat(monday): alinear Frasa-CAS con CRM real del cliente (7 mejoras)

- Vocabulario funnel: labels Intento de contacto/Abandono en Leads,
  etapas completas en Oportunidades (Cotizador, Ganado, Perdido...)
- Apartados: pipeline Enkontrol (8 estados) + checkboxes CURP/RFC/Comprobante
  que la app de alta prende al subir documentos
- Motivos No Compra: board catálogo + dropdown + endpoint /oportunidades/:id/perder
- Perfil demográfico en Contactos mapeado desde el alta (estado civil,
  vivienda, propósito, fecha nac., rango de edad calculado)
- Desarrollos: catálogo maestro sincronizado (precios, m2, tipo, entrega)
- Actividades: board nuevo + creación automática en round-robin e inactividad
- Cobranza: plan liquidado mueve el apartado a 'X Escriturar' en Monday
- Scripts: setup:crm y sync:desarrollos (idempotentes)
- Tests 119/119 verdes
This commit is contained in:
2026-08-22 07:18:48 +00:00
parent afb454b54c
commit f0f923b2ef
22 changed files with 1178 additions and 13 deletions

View File

@@ -0,0 +1,240 @@
/**
* Setup idempotente del workspace Frasa-CAS para alinearlo con el CRM FRASA real:
*
* - Leads: labels 'Intento de contacto' y 'Abandono' en el status Estado.
* - Oportunidades: labels de Etapa del funnel CRM + dropdown 'Motivo No Compra'.
* - Apartado: labels del pipeline Enkontrol + checkboxes CURP / RFC / Comprobante.
* - Contactos: dropdowns del perfil demográfico + date Fecha Nacimiento.
* - Tableros nuevos 'Motivos No Compra' y 'Actividades' (si MONDAY_BOARD_MOTIVOS /
* MONDAY_BOARD_ACTIVIDADES están vacíos en .env). Imprime los IDs creados.
*
* Uso: npm run setup:crm (desde api-gateway)
* Es seguro re-ejecutarlo: solo crea lo que falta.
*/
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const monday = new MondayService()
// ---------- Vocabulario CRM FRASA (normalizado, sin los typos del original) ----------
const ETIQUETAS_LEADS_ESTADO = ['Intento de contacto', 'Abandono']
const ETIQUETAS_ETAPA_OPORTUNIDAD = [
'Afluencia',
'Cotizador',
'Agendo Cita',
'Aparto',
'Ganado',
'Perdido',
'Abandono',
'No Asistio',
]
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
]
const ETIQUETAS_APARTADO_ESTADO = [
'Entrego Documentacion',
'Pago Apartado',
'Alta Enkontrol',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente (Venta)',
'Cancelacion',
'En Definicion',
// Postventa (cobranza): al liquidarse el plan de pagos
'X Escriturar',
'Escriturado',
]
const PERFIL_CONTACTOS: { titulo: string; tipo: string; labels?: string[] }[] = [
{ titulo: 'Sexo', tipo: 'dropdown', labels: ['Hombre', 'Mujer', 'No define'] },
{ titulo: 'Estado Civil', tipo: 'dropdown', labels: ['Soltero', 'Casado', 'Union Libre', 'Divorciado', 'Viudo'] },
{ titulo: 'Rango de Edad', tipo: 'dropdown', labels: ['Menor de 24', 'Entre 24 y 40', 'Mayor de 40'] },
{
titulo: 'Actividad Economica',
tipo: 'dropdown',
labels: ['Empleado', 'Empresario', 'Pensionado', 'Comerciante'],
},
{
titulo: 'Vivienda Actual',
tipo: 'dropdown',
labels: ['Propia', 'Rentada', 'Prestada', 'Comparte con familia'],
},
{ titulo: 'Proposito de Compra', tipo: 'dropdown', labels: ['Habitarla', 'Inversion'] },
{ titulo: 'Fecha Nacimiento', tipo: 'date' },
]
// ---------- Helpers ----------
interface Columna {
id: string
title: string
type: string
settings_str: string
}
async function columnas(boardId: string): Promise<Columna[]> {
const data = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`,
{ ids: [boardId] },
)
return data.boards[0]?.columns ?? []
}
/** Labels actuales de una columna status/dropdown (parsea settings_str) */
function labelsDe(col: Columna): string[] {
try {
const s = JSON.parse(col.settings_str) as { labels?: unknown }
if (Array.isArray(s.labels)) {
// dropdown: [{id, name}]
return s.labels.map((l) => (typeof l === 'object' && l !== null ? String((l as { name?: string }).name) : String(l)))
}
if (s.labels !== null && typeof s.labels === 'object') {
// status: {"0": "Label", ...}
return Object.values(s.labels as Record<string, string>)
}
} catch {
// settings no parseable: sin labels conocidos
}
return []
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, defaults?: unknown): Promise<void> {
const cols = await columnas(boardId)
if (cols.some((c) => c.title === titulo)) {
console.log(` = columna "${titulo}" ya existe (${boardId})`)
return
}
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!, $defaults: JSON) {
create_column(board_id: $boardId, title: $title, column_type: $tipo, defaults: $defaults) { id }
}`,
{ boardId, title: titulo, tipo, ...(defaults !== undefined ? { defaults: JSON.stringify(defaults) } : {}) },
)
console.log(` + columna "${titulo}" (${tipo}) creada en ${boardId}`)
}
/**
* Agrega labels faltantes a una columna status/dropdown usando un item scratch
* con create_labels_if_missing (change_column_metadata no siempre está disponible).
*/
async function asegurarEtiquetas(boardId: string, tituloColumna: string, etiquetas: string[]): Promise<void> {
const cols = await columnas(boardId)
const col = cols.find((c) => c.title === tituloColumna)
if (!col) throw new Error(`Columna "${tituloColumna}" no existe en ${boardId}`)
const actuales = new Set(labelsDe(col).map((l) => l.toLowerCase()))
const faltantes = etiquetas.filter((e) => !actuales.has(e.toLowerCase()))
if (faltantes.length === 0) {
console.log(` = "${tituloColumna}" ya tiene todas las etiquetas (${boardId})`)
return
}
// Item scratch: se crea, se le escriben los labels nuevos y se borra
const creado = await monday.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`,
{ boardId, name: '__scratch_labels__' },
)
const itemId = creado.create_item.id
try {
for (const etiqueta of faltantes) {
const valor = col.type === 'dropdown' ? { labels: [etiqueta] } : { label: etiqueta }
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: JSON.stringify({ [col.id]: valor }) },
)
console.log(` + etiqueta "${etiqueta}" en "${tituloColumna}" (${boardId})`)
}
} finally {
await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId })
}
}
async function crearTablero(nombre: string): Promise<string> {
const workspaces = await monday.graphql<{ workspaces: { id: string; name: string }[] }>(
`query { workspaces { id name } }`,
)
const ws = workspaces.workspaces.find((w) => w.name === config.monday.workspace)
const data = await monday.graphql<{ create_board: { id: string } }>(
`mutation ($name: String!, $kind: BoardKind!, $ws: ID) {
create_board(board_name: $name, board_kind: $kind, workspace_id: $ws) { id }
}`,
{ name: nombre, kind: 'public', ...(ws ? { ws: ws.id } : {}) },
)
return data.create_board.id
}
// ---------- Main ----------
async function main(): Promise<void> {
const b = config.monday.boards
console.log('== Leads: etiquetas de Estado ==')
await asegurarEtiquetas(b.leads, 'Estado', ETIQUETAS_LEADS_ESTADO)
console.log('== Oportunidades: Etapa (columna status "Estado") + Motivo No Compra ==')
await asegurarEtiquetas(b.oportunidades, 'Estado', ETIQUETAS_ETAPA_OPORTUNIDAD)
await asegurarColumna(b.oportunidades, 'Motivo No Compra', 'dropdown')
await asegurarEtiquetas(b.oportunidades, 'Motivo No Compra', MOTIVOS_NO_COMPRA)
console.log('== Apartado: pipeline Enkontrol + checkboxes de documentos ==')
await asegurarEtiquetas(b.apartado, 'Estado', ETIQUETAS_APARTADO_ESTADO)
for (const titulo of ['CURP', 'RFC', 'Comprobante']) {
await asegurarColumna(b.apartado, titulo, 'checkbox')
}
console.log('== Contactos: perfil demográfico ==')
for (const col of PERFIL_CONTACTOS) {
await asegurarColumna(b.contactos, col.titulo, col.tipo)
if (col.labels) await asegurarEtiquetas(b.contactos, col.titulo, col.labels)
}
console.log('== Tablero Motivos No Compra ==')
let motivosId = b.motivos
if (motivosId === '') {
motivosId = await crearTablero('Motivos No Compra')
console.log(` + tablero creado: ${motivosId} → MONDAY_BOARD_MOTIVOS="${motivosId}" en .env`)
} else {
console.log(` = ya configurado: ${motivosId}`)
}
await asegurarColumna(motivosId, 'Categoria', 'status')
await asegurarEtiquetas(motivosId, 'Categoria', MOTIVOS_NO_COMPRA)
await asegurarColumna(motivosId, 'Descripcion', 'long_text')
await asegurarColumna(motivosId, 'Accion Recomendada', 'long_text')
await asegurarColumna(motivosId, 'Responsable', 'people')
console.log('== Tablero Actividades ==')
let actividadesId = b.actividades
if (actividadesId === '') {
actividadesId = await crearTablero('Actividades')
console.log(` + tablero creado: ${actividadesId} → MONDAY_BOARD_ACTIVIDADES="${actividadesId}" en .env`)
} else {
console.log(` = ya configurado: ${actividadesId}`)
}
await asegurarColumna(actividadesId, 'Tipo', 'status')
await asegurarEtiquetas(actividadesId, 'Tipo', ['Llamada', 'Reunion', 'Tarea'])
await asegurarColumna(actividadesId, 'Responsable', 'people')
await asegurarColumna(actividadesId, 'Fecha', 'date')
await asegurarColumna(actividadesId, 'Estado', 'status')
await asegurarEtiquetas(actividadesId, 'Estado', ['Abierto', 'Listo'])
// board_relation NO se puede crear vía API ("column type is not supported yet"):
// la columna "Lead" (relación al tablero Leads) debe crearse a mano en la UI.
// El servicio la usa si existe y la omite si no.
console.log(' ! crea a mano la columna "Lead" (Conectar tableros → Leads) en el tablero Actividades')
console.log('\nListo. Si se crearon tableros nuevos, copia los IDs a .env y .env.example.')
}
main().catch((err) => {
console.error('setup-crm falló:', err)
process.exit(1)
})

View File

@@ -0,0 +1,196 @@
/**
* Sincroniza el tablero Desarrollos (catálogo maestro, estilo CRM FRASA) con
* los inventarios reales:
* - DIAGONAL: apps/cotizador/src/data/unidades.ts
* - ATELIER / ESTANZA / SENDA / SOLANA: reference/cotizadores/*.json
*
* Calcula (solo unidades DISPONIBLE): Precio Desde/Hasta, M2 Construccion
* (rango), Fecha Entrega (la más próxima) y Modelos; y fija el Tipo de
* Inmueble (DIAGONAL/ATELIER = Departamentos; el resto = Casas).
*
* Idempotente: crea las columnas y los items que falten; actualiza valores.
* Uso: npm run sync:desarrollos (desde api-gateway)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const monday = new MondayService()
const TIPO_POR_DESARROLLO: Record<string, string> = {
DIAGONAL: 'Departamentos',
ATELIER: 'Departamentos',
ESTANZA: 'Casas',
SENDA: 'Casas',
SOLANA: 'Casas',
}
interface UnidadInventario {
codigo: string
modelo: string
fechaEntrega: string | null
supConstruccion: number | null
precioSinAcabados: number | null
precioConAcabados: number | null
status: string
extras?: { precioLista?: number | null; precioPreventa?: number | null } & Record<string, unknown>
}
interface StatsDesarrollo {
nombre: string
tipoInmueble: string
precioDesde: number | null
precioHasta: number | null
m2Construccion: string // 'min - max' o ''
fechaEntrega: string | null // la más próxima entre DISPONIBLEs
modelos: string // únicos, separados por coma
disponibles: number
}
/** Precio de referencia: con acabados, si no sin acabados, si no lista/preventa (extras) */
function precioDe(u: UnidadInventario): number | null {
return u.precioConAcabados ?? u.precioSinAcabados ?? u.extras?.precioLista ?? u.extras?.precioPreventa ?? null
}
export function calcularStats(nombre: string, unidades: UnidadInventario[]): StatsDesarrollo {
const disponibles = unidades.filter((u) => u.status.toUpperCase() === 'DISPONIBLE')
const precios = disponibles.map(precioDe).filter((p): p is number => p !== null && p > 0)
const m2 = disponibles.map((u) => u.supConstruccion).filter((s): s is number => s !== null && s > 0)
const fechas = disponibles
.map((u) => u.fechaEntrega)
.filter((f): f is string => f !== null && f !== '')
.sort()
const modelos = [...new Set(disponibles.map((u) => u.modelo).filter((m) => m !== ''))].sort()
const redondeo = (n: number): number => Math.round(n * 100) / 100
return {
nombre,
tipoInmueble: TIPO_POR_DESARROLLO[nombre] ?? 'Casas',
precioDesde: precios.length > 0 ? Math.min(...precios) : null,
precioHasta: precios.length > 0 ? Math.max(...precios) : null,
m2Construccion: m2.length > 0 ? `${redondeo(Math.min(...m2))} - ${redondeo(Math.max(...m2))}` : '',
fechaEntrega: fechas[0] ?? null,
modelos: modelos.join(', '),
disponibles: disponibles.length,
}
}
async function cargarInventarios(): Promise<Map<string, UnidadInventario[]>> {
const mapa = new Map<string, UnidadInventario[]>()
// DIAGONAL vive en el seed del cotizador (TS)
const mod = (await import(
pathToFileURL2('../../apps/cotizador/src/data/unidades.ts')
)) as { UNIDADES_SEED: UnidadInventario[] }
mapa.set('DIAGONAL', mod.UNIDADES_SEED)
for (const nombre of ['ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']) {
const crudo = await readFile(path.join(RAIZ, `reference/cotizadores/${nombre}.json`), 'utf8')
const json = JSON.parse(crudo) as { unidades: UnidadInventario[] }
mapa.set(nombre, json.unidades)
}
return mapa
}
/** Ruta file:// absoluta para import dinámico de TS fuera del paquete */
function pathToFileURL2(relDesdeScript: string): string {
const abs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), relDesdeScript)
return `file://${abs}`
}
// ---------- Monday ----------
interface Columna {
id: string
title: string
type: string
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, existentes: Columna[]): Promise<void> {
if (existentes.some((c) => c.title === titulo)) return
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) {
create_column(board_id: $boardId, title: $title, column_type: $tipo) { id }
}`,
{ boardId, title: titulo, tipo },
)
console.log(` + columna "${titulo}" (${tipo})`)
}
async function main(): Promise<void> {
const boardId = config.monday.boards.desarrollos
const colsData = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = colsData.boards[0]?.columns ?? []
await asegurarColumna(boardId, 'Precio Desde', 'numbers', columnas)
await asegurarColumna(boardId, 'Precio Hasta', 'numbers', columnas)
await asegurarColumna(boardId, 'M2 Construccion', 'text', columnas)
await asegurarColumna(boardId, 'Tipo de Inmueble', 'dropdown', columnas)
await asegurarColumna(boardId, 'Fecha Entrega', 'date', columnas)
await asegurarColumna(boardId, 'Modelos', 'text', columnas)
// Recargar columnas tras posibles creaciones y mapear por título
const colsFrescas = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const porTitulo = new Map((colsFrescas.boards[0]?.columns ?? []).map((c) => [c.title, c]))
const itemsData = await monday.graphql<{ boards: { items_page: { items: { id: string; name: string }[] } }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 100) { items { id name } } } }`,
{ ids: [boardId] },
)
const itemsPorNombre = new Map(
(itemsData.boards[0]?.items_page.items ?? []).map((i) => [i.name.trim().toUpperCase(), i.id]),
)
const inventarios = await cargarInventarios()
for (const [nombre, unidades] of inventarios) {
const stats = calcularStats(nombre, unidades)
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown): void => {
const col = porTitulo.get(titulo)
if (col) valores[col.id] = valor
}
if (stats.precioDesde !== null) poner('Precio Desde', stats.precioDesde)
if (stats.precioHasta !== null) poner('Precio Hasta', stats.precioHasta)
if (stats.m2Construccion !== '') poner('M2 Construccion', stats.m2Construccion)
poner('Tipo de Inmueble', { labels: [stats.tipoInmueble] })
if (stats.fechaEntrega !== null) poner('Fecha Entrega', { date: stats.fechaEntrega })
if (stats.modelos !== '') poner('Modelos', stats.modelos)
const columnValues = JSON.stringify(valores)
const itemId = itemsPorNombre.get(nombre)
if (itemId === undefined) {
await monday.graphql(
`mutation ($boardId: ID!, $name: String!, $values: JSON!) {
create_item(board_id: $boardId, item_name: $name, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, name: nombre, values: columnValues },
)
console.log(`+ ${nombre}: item creado (${stats.disponibles} disponibles)`)
} else {
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: columnValues },
)
console.log(`= ${nombre}: actualizado (${stats.disponibles} disponibles)`)
}
console.log(
` ${stats.tipoInmueble} | $${stats.precioDesde ?? '-'} - $${stats.precioHasta ?? '-'} | ` +
`m2 ${stats.m2Construccion || '-'} | entrega ${stats.fechaEntrega ?? '-'} | modelos: ${stats.modelos || '-'}`,
)
}
console.log('\nSincronización de Desarrollos completa.')
}
main().catch((err) => {
console.error('sync-desarrollos falló:', err)
process.exit(1)
})