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

@@ -279,10 +279,12 @@ export class MondayService {
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query).
*/
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
private valorPorTipo(col: ColumnaMeta, crudo: string | number | boolean): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'dropdown':
return { labels: [String(crudo)] }
case 'date':
return { date: String(crudo) }
case 'numbers':
@@ -291,6 +293,14 @@ export class MondayService {
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
case 'checkbox':
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
case 'people':
return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] }
case 'board_relation':
return { item_ids: [Number(crudo)] }
case 'long_text':
return { text: String(crudo) }
default:
return String(crudo)
}
@@ -298,7 +308,7 @@ export class MondayService {
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | undefined>,
valores: Record<string, string | number | boolean | undefined>,
): Promise<string> {
const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) {
@@ -346,6 +356,14 @@ export class MondayService {
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
// Perfil demográfico (vocabulario CRM FRASA); solo se envía lo capturado
Sexo: datos.sexoLabel,
'Estado Civil': datos.estadoCivilLabel,
'Rango de Edad': datos.rangoEdadLabel,
'Actividad Economica': datos.actividadEconomicaLabel,
'Vivienda Actual': datos.viviendaActualLabel,
'Proposito de Compra': datos.propositoCompraLabel,
'Fecha Nacimiento': datos.fechaNacimiento,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
@@ -378,6 +396,74 @@ export class MondayService {
this.invalidarCacheUnidades()
}
// ---------- Escrituras genéricas ----------
/**
* Cambia UNA columna de un item, resolviendo el column ID por título y
* serializando según el tipo de columna (status, dropdown, checkbox, date...).
*/
async cambiarValorColumna(
boardId: string,
itemId: string,
tituloColumna: string,
valor: string | number | boolean,
): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, valor)) },
)
}
/** Nombre de un item por ID (cualquier tablero) */
async obtenerNombreItem(itemId: string): Promise<string> {
const data = await this.graphql<{ items: { name: string }[] }>(
`query ($ids: [ID!]) { items(ids: $ids) { name } }`,
{ ids: [itemId] },
)
const nombre = data.items[0]?.name
if (nombre === undefined) throw new AppError(404, `Item ${itemId} no encontrado en Monday`)
return nombre
}
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
const boardId = config.monday.boards.motivos
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
const columnValues = await this.construirColumnValues(boardId, {
Categoria: datos.categoria,
Descripcion: datos.descripcion,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/** Actividad de seguimiento ligada a un lead (bitácora del CRM) */
async crearActividad(datos: DatosActividad): Promise<string> {
const boardId = config.monday.boards.actividades
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_ACTIVIDADES no está configurado')
// La columna board_relation "Lead" no puede crearse vía API (limitación de
// Monday): si existe en el tablero se liga, si no, la actividad se crea igual.
let lead: string | undefined
try {
await this.columnaId(boardId, 'Lead')
lead = datos.leadItemId
} catch {
lead = undefined
}
const columnValues = await this.construirColumnValues(boardId, {
Tipo: datos.tipoLabel,
Responsable: datos.responsableId,
Fecha: datos.fecha,
Estado: datos.estadoLabel,
Lead: lead,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
// ---------- Leads (automatizaciones) ----------
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
@@ -510,6 +596,14 @@ export interface DatosContacto {
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
// Perfil demográfico (labels del dropdown correspondiente)
sexoLabel?: string | undefined
estadoCivilLabel?: string | undefined
rangoEdadLabel?: string | undefined
actividadEconomicaLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
fechaNacimiento?: string | undefined // 'YYYY-MM-DD'
}
export interface DatosApartado {
@@ -518,7 +612,22 @@ export interface DatosApartado {
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE'
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA
descripcion?: string
}
export interface DatosActividad {
nombre: string
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
responsableId: number // user ID de Monday
fecha: string // 'YYYY-MM-DD'
estadoLabel: string // 'Abierto' | 'Listo'
leadItemId: string // item del tablero Leads relacionado
}
/** Singleton usado por los controllers (los tests instancian el suyo) */