Files
FRASA-CAS/api-gateway/scripts/setup-crm.ts
Consult AS Technology f0f923b2ef 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
2026-08-22 07:18:48 +00:00

241 lines
9.3 KiB
TypeScript

/**
* 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)
})