refactor: las afluencias viven en Oportunidades (no en Leads)
- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia) con todo el perfil (desarrollo, modelo, estado civil, credito, etc.) en las columnas del board; dedup por telefono contra Oportunidades; asignacion al capturista via columna Asesor o round-robin del equipo. - Round-robin, escalado e inactividad operan sobre Oportunidades (solo etapa Afluencia sin asesor entra al reparto). - Acciones del vendedor (contactado/cita/descartar) escriben directo en la oportunidad; descarte = Perdido + Motivo No Compra. - Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en Afluencia; misOportunidades.porEtapa ya es real (columna Asesor). - El picker del alta busca en Leads (historico) Y Oportunidades, con insignia de origen; moverAAparto tolera ambos ids. - Lectura de status con value null = vacio (quirk del default Monday). - Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
@@ -86,9 +86,27 @@ export interface OportunidadMonday {
|
||||
id: string
|
||||
nombre: string // nombre del cliente
|
||||
createdAt: string // ISO
|
||||
updatedAt: string // ISO (updated_at del item en Monday)
|
||||
etapa: string // label de la columna status 'Estado'
|
||||
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||||
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
|
||||
asesorIds: number[] // user IDs asignados en la columna people 'Asesor'
|
||||
telefono: string // columna phone 'Cliente teléfono' ('' si no tiene)
|
||||
email: string // columna email 'Cliente email' ('' si no tiene)
|
||||
origen: string // dropdown 'Origen del Lead' ('' si no tiene)
|
||||
// Perfilamiento de la afluencia (alimenta las gráficas de /dash/); todos
|
||||
// tolerantes: si la columna no existe en el board o viene vacía, quedan '' / null
|
||||
desarrollo?: string // status 'Desarrollo'
|
||||
modeloInteres?: string // status 'Modelo de Interés'
|
||||
medioContacto?: string // text 'Medio de Contacto'
|
||||
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
|
||||
estadoCivil?: string // dropdown 'Estado Civil'
|
||||
tipoEmpleo?: string // status 'Tipo de Empleo'
|
||||
credito?: string // status 'Crédito'
|
||||
tipoEconomia?: string // status 'Tipo de Econimia' (SIC: el título tiene typo en Monday)
|
||||
califica?: string // dropdown 'Lead Califica'
|
||||
rangoSalarial?: string // text 'Rango Salarial'
|
||||
loQuierePara?: string // dropdown 'Proposito de Compra'
|
||||
}
|
||||
|
||||
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
||||
@@ -371,17 +389,17 @@ export class MondayService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el label real de una columna status tolerando mayúsculas y
|
||||
* minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
|
||||
* un rename en la UI). Sin match devuelve el valor tal cual (Monday dirá
|
||||
* que el label no existe, como antes).
|
||||
* Resuelve el label real de una columna status/dropdown tolerando mayúsculas
|
||||
* y minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
|
||||
* un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
|
||||
* match devuelve el valor tal cual (Monday dirá que el label no existe).
|
||||
*/
|
||||
private async resolverLabelStatus(
|
||||
private async resolverLabel(
|
||||
boardId: string,
|
||||
col: ColumnaMeta,
|
||||
valor: string | number | boolean,
|
||||
): Promise<string | number | boolean> {
|
||||
if (col.type !== 'status') return valor
|
||||
if (col.type !== 'status' && col.type !== 'dropdown') return valor
|
||||
const buscado = String(valor).trim().toUpperCase()
|
||||
const labels = await this.labelsDeColumna(boardId, col.title)
|
||||
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
|
||||
@@ -395,7 +413,7 @@ export class MondayService {
|
||||
for (const [titulo, crudo] of Object.entries(valores)) {
|
||||
if (crudo === undefined || crudo === '') continue
|
||||
const col = await this.columnaId(boardId, titulo)
|
||||
out[col.id] = this.valorPorTipo(col, await this.resolverLabelStatus(boardId, col, crudo))
|
||||
out[col.id] = this.valorPorTipo(col, await this.resolverLabel(boardId, col, crudo))
|
||||
}
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
@@ -429,16 +447,33 @@ export class MondayService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
|
||||
* (entrada al funnel; sin unidad ni precios todavía).
|
||||
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
|
||||
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
|
||||
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
|
||||
*/
|
||||
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const hoy = new Date()
|
||||
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Estado: 'Afluencia',
|
||||
Fecha: fechaHoy,
|
||||
'Cliente teléfono': datos.telefono,
|
||||
'Cliente email': datos.email,
|
||||
Desarrollo: datos.desarrolloLabel,
|
||||
Estado: 'Afluencia',
|
||||
'Modelo de Interés': datos.modeloLabel,
|
||||
'Origen del Lead': datos.origenLabel,
|
||||
'Medio de Contacto': datos.medioContacto,
|
||||
Edad: datos.edad,
|
||||
'Estado Civil': datos.estadoCivilLabel,
|
||||
'Tipo de Empleo': datos.tipoEmpleoLabel,
|
||||
Ocupación: datos.ocupacion,
|
||||
'Rango Salarial': datos.rangoSalarial,
|
||||
Crédito: datos.creditoLabel,
|
||||
'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real de la columna
|
||||
'Lead Califica': datos.calificaLabel,
|
||||
'Proposito de Compra': datos.loQuierePara,
|
||||
COMENTARIOS: datos.notas,
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
}
|
||||
@@ -561,7 +596,7 @@ export class MondayService {
|
||||
valor: string | number | boolean,
|
||||
): Promise<void> {
|
||||
const col = await this.columnaId(boardId, tituloColumna)
|
||||
const resuelto = await this.resolverLabelStatus(boardId, col, valor)
|
||||
const resuelto = await this.resolverLabel(boardId, col, valor)
|
||||
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 }
|
||||
@@ -689,21 +724,6 @@ export class MondayService {
|
||||
return items.map((item) => this.mapearLead(item, cols))
|
||||
}
|
||||
|
||||
/** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */
|
||||
async getLeadPorId(itemId: string): Promise<LeadMonday | null> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const data = await this.graphql<{ items: (ItemConFechas | null)[] }>(
|
||||
`query ($ids: [ID!]) {
|
||||
items(ids: $ids) { id name created_at updated_at column_values { id text value } }
|
||||
}`,
|
||||
{ ids: [itemId] },
|
||||
)
|
||||
const item = data.items[0]
|
||||
if (!item) return null
|
||||
return this.mapearLead(item, cols)
|
||||
}
|
||||
|
||||
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
|
||||
const colVendedor = cols.get('Vendedor')
|
||||
if (!colVendedor || colVendedor.type !== 'people') {
|
||||
@@ -761,16 +781,16 @@ export class MondayService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Items del tablero Oportunidades con etapa, unidad y precio final
|
||||
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
|
||||
* Items del tablero Oportunidades con etapa, unidad, precio final, asesor
|
||||
* asignado y perfil de afluencia (los consumen las automatizaciones
|
||||
* Aparto/Ganado, el round-robin, Mi día y los reportes).
|
||||
*/
|
||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colEstado = cols.get('Estado')
|
||||
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
||||
const colUnidad = cols.get('Unidad')
|
||||
const colPrecio = cols.get('Precio final')
|
||||
if (!cols.has('Estado')) {
|
||||
throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
||||
}
|
||||
|
||||
const items: ItemConFechas[] = []
|
||||
let cursor: string | null = null
|
||||
@@ -805,18 +825,68 @@ export class MondayService {
|
||||
cursor = page.cursor
|
||||
} while (cursor !== null)
|
||||
|
||||
return items.map((item) => {
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
etapa: porId.get(colEstado.id)?.text ?? '',
|
||||
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
||||
precioFinal: Number.isFinite(precio) ? precio : 0,
|
||||
}
|
||||
})
|
||||
return items.map((item) => this.mapearOportunidad(item, cols))
|
||||
}
|
||||
|
||||
private mapearOportunidad(item: ItemConFechas, cols: Map<string, ColumnaMeta>): OportunidadMonday {
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
// Lectura tolerante por título ('' si falta la columna o el valor)
|
||||
const textoDe = (titulo: string): string => {
|
||||
const col = cols.get(titulo)
|
||||
return col ? (porId.get(col.id)?.text ?? '') : ''
|
||||
}
|
||||
// Misma corrección que mapearLead: cuando el status nunca se ha escrito,
|
||||
// Monday devuelve value=null pero `text` muestra el default del tablero;
|
||||
// la etapa real es vacía. Se lee `text` SOLO si hay value.
|
||||
const colEstado = cols.get('Estado')
|
||||
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||||
const etapa = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||||
const colAsesor = cols.get('Asesor')
|
||||
const precio = Number(textoDe('Precio final'))
|
||||
const edadRaw = textoDe('Edad')
|
||||
const edadNum = Number(edadRaw)
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
etapa,
|
||||
unidadCodigo: textoDe('Unidad'),
|
||||
precioFinal: Number.isFinite(precio) ? precio : 0,
|
||||
asesorIds: colAsesor ? this.peopleIds(porId.get(colAsesor.id)?.value) : [],
|
||||
telefono: textoDe('Cliente teléfono'),
|
||||
email: textoDe('Cliente email'),
|
||||
origen: textoDe('Origen del Lead'),
|
||||
desarrollo: textoDe('Desarrollo'),
|
||||
modeloInteres: textoDe('Modelo de Interés'),
|
||||
medioContacto: textoDe('Medio de Contacto'),
|
||||
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
|
||||
estadoCivil: textoDe('Estado Civil'),
|
||||
tipoEmpleo: textoDe('Tipo de Empleo'),
|
||||
credito: textoDe('Crédito'),
|
||||
tipoEconomia: textoDe('Tipo de Econimia'), // SIC: typo en el título real
|
||||
califica: textoDe('Lead Califica'),
|
||||
rangoSalarial: textoDe('Rango Salarial'),
|
||||
loQuierePara: textoDe('Proposito de Compra'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
|
||||
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10
|
||||
* (tolerante a lada/prefijo 52). null si no hay duplicado.
|
||||
*/
|
||||
async buscarOportunidadPorTelefono(telefono: string): Promise<OportunidadMonday | null> {
|
||||
const digitos = telefono.replace(/\D/g, '')
|
||||
if (digitos.length < 10) return null
|
||||
const clave = digitos.slice(-10)
|
||||
const oportunidades = await this.getOportunidades()
|
||||
return (
|
||||
oportunidades.find((o) => {
|
||||
const d = o.telefono.replace(/\D/g, '')
|
||||
return d.length >= 10 && d.slice(-10) === clave
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Mi día (CRM ligero para vendedores) ----------
|
||||
@@ -963,31 +1033,6 @@ export class MondayService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */
|
||||
async crearLead(datos: DatosLead): Promise<string> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Estado: 'NUEVO',
|
||||
Teléfono: datos.telefono,
|
||||
Email: datos.email,
|
||||
Origen: datos.origenLabel,
|
||||
Desarrollo: datos.desarrolloLabel,
|
||||
Notas: datos.notas,
|
||||
'Modelo de Interés': datos.modeloLabel,
|
||||
'Medio de Contacto': datos.medioContacto,
|
||||
Edad: datos.edad,
|
||||
'Estado Civil': datos.estadoCivilLabel,
|
||||
'Tipo de Empleo': datos.tipoEmpleoLabel,
|
||||
Ocupación: datos.ocupacion,
|
||||
'Rango Salarial': datos.rangoSalarial,
|
||||
Crédito: datos.creditoLabel,
|
||||
'Tipo de Economía': datos.tipoEconomiaLabel,
|
||||
Califica: datos.calificaLabel,
|
||||
'Lo Quiere Para': datos.loQuierePara,
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
|
||||
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
|
||||
@@ -1066,10 +1111,10 @@ export class MondayService {
|
||||
return abiertas
|
||||
}
|
||||
|
||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const col = await this.columnaId(boardId, 'Vendedor')
|
||||
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
|
||||
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const col = await this.columnaId(boardId, 'Asesor')
|
||||
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 }
|
||||
@@ -1141,10 +1186,25 @@ export interface DatosOportunidad {
|
||||
}
|
||||
|
||||
export interface DatosOportunidadAfluencia {
|
||||
nombre: string // name del item = nombre del lead
|
||||
nombre: string // name del item = nombre del interesado
|
||||
telefono?: string | undefined
|
||||
email?: string | undefined
|
||||
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
||||
// Campos de calificación de la afluencia (labels reales de las columnas del
|
||||
// board Oportunidades; los dropdown no auto-crean labels, deben venir sembrados)
|
||||
origenLabel?: string | undefined // dropdown 'Origen del Lead'
|
||||
modeloLabel?: string | undefined // status 'Modelo de Interés'
|
||||
medioContacto?: string | undefined // text 'Medio de Contacto'
|
||||
edad?: number | undefined
|
||||
estadoCivilLabel?: string | undefined // dropdown 'Estado Civil'
|
||||
tipoEmpleoLabel?: string | undefined // status 'Tipo de Empleo'
|
||||
ocupacion?: string | undefined // text 'Ocupación'
|
||||
rangoSalarial?: string | undefined // text 'Rango Salarial'
|
||||
creditoLabel?: string | undefined // status 'Crédito'
|
||||
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
|
||||
calificaLabel?: string | undefined // dropdown 'Lead Califica'
|
||||
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
|
||||
notas?: string | undefined // long_text 'COMENTARIOS'
|
||||
}
|
||||
|
||||
export interface DatosContacto {
|
||||
@@ -1195,27 +1255,6 @@ export interface DatosActividad {
|
||||
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
|
||||
}
|
||||
|
||||
export interface DatosLead {
|
||||
nombre: string
|
||||
telefono?: string | undefined
|
||||
email?: string | undefined
|
||||
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
|
||||
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
|
||||
notas?: string | undefined
|
||||
// Campos de calificación del interesado (labels reales de las columnas status del board Leads)
|
||||
modeloLabel?: string | undefined // label de la columna status 'Modelo de Interés' (modelo según desarrollo)
|
||||
medioContacto?: string | undefined
|
||||
edad?: number | undefined
|
||||
estadoCivilLabel?: string | undefined
|
||||
tipoEmpleoLabel?: string | undefined
|
||||
ocupacion?: string | undefined
|
||||
rangoSalarial?: string | undefined
|
||||
creditoLabel?: string | undefined
|
||||
tipoEconomiaLabel?: string | undefined
|
||||
calificaLabel?: string | undefined
|
||||
loQuierePara?: string | undefined
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||||
export const mondayService = new MondayService()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user