feat(funnel): leads entran como Afluencia al funnel de Oportunidades
- Captura rápida crea lead + oportunidad en etapa Afluencia (vinculadas por nueva tabla LeadOportunidad); también aplica a leads creados directo en Monday vía webhook - Transiciones automáticas: AGENDA CITA → Agendo Cita, descarte → Perdido (+ dropdown Motivo No Compra), cotización con mismo teléfono ACTUALIZA la oportunidad (no duplica) → Cotizador, alta de cliente con leadMondayId → Aparto (dedup previo para no duplicar Apartado) - Columna Desarrollo agregada al board Oportunidades (color_mm6sa20t) - Migración lead_oportunidad aplicada con migrate diff + resolve (la DB tiene drift histórico por db push; migrate dev pedía reset) - Tests: 322 verdes; smoke en vivo verificado y limpiado
This commit is contained in:
@@ -390,6 +390,44 @@ export class MondayService {
|
||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
|
||||
* (entrada al funnel; sin unidad ni precios todavía).
|
||||
*/
|
||||
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
'Cliente teléfono': datos.telefono,
|
||||
'Cliente email': datos.email,
|
||||
Desarrollo: datos.desarrolloLabel,
|
||||
Estado: 'Afluencia',
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza una oportunidad existente con los datos de una cotización
|
||||
* (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'.
|
||||
*/
|
||||
async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise<void> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Unidad: datos.unidadCodigo,
|
||||
'Tipo propuesta': datos.tipoPropuestaLabel,
|
||||
'Precio de lista': datos.precioLista,
|
||||
'Descuento %': datos.descuentoPct,
|
||||
'Precio final': datos.precioFinal,
|
||||
'Fecha cotización': datos.fechaCotizacion,
|
||||
Estado: datos.estadoLabel,
|
||||
})
|
||||
await this.graphql(
|
||||
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
|
||||
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
|
||||
}`,
|
||||
{ boardId, itemId: oportunidadId, columnValues },
|
||||
)
|
||||
}
|
||||
|
||||
async crearContacto(datos: DatosContacto): Promise<string> {
|
||||
const boardId = config.monday.boards.contactos
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
@@ -513,11 +551,6 @@ export class MondayService {
|
||||
async getLeads(): Promise<LeadMonday[]> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colVendedor = cols.get('Vendedor')
|
||||
if (!colVendedor || colVendedor.type !== 'people') {
|
||||
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads (${boardId})`)
|
||||
}
|
||||
const colEstado = cols.get('Estado')
|
||||
|
||||
const items: ItemConFechas[] = []
|
||||
let cursor: string | null = null
|
||||
@@ -552,33 +585,55 @@ 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 rawVendedor = porId.get(colVendedor.id)?.value
|
||||
let vendedorIds: number[] = []
|
||||
if (rawVendedor) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
|
||||
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
||||
} catch {
|
||||
vendedorIds = []
|
||||
}
|
||||
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') {
|
||||
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`)
|
||||
}
|
||||
const colEstado = cols.get('Estado')
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
const rawVendedor = porId.get(colVendedor.id)?.value
|
||||
let vendedorIds: number[] = []
|
||||
if (rawVendedor) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
|
||||
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
||||
} catch {
|
||||
vendedorIds = []
|
||||
}
|
||||
const colOrigen = cols.get('Origen')
|
||||
const colTelefono = cols.get('Teléfono')
|
||||
const colEmail = cols.get('Email')
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||||
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
|
||||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||
vendedorIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
const colOrigen = cols.get('Origen')
|
||||
const colTelefono = cols.get('Teléfono')
|
||||
const colEmail = cols.get('Email')
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||||
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
|
||||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||
vendedorIds,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -933,6 +988,13 @@ export interface DatosOportunidad {
|
||||
clienteTelefono?: string
|
||||
}
|
||||
|
||||
export interface DatosOportunidadAfluencia {
|
||||
nombre: string // name del item = nombre del lead
|
||||
telefono?: string | undefined
|
||||
email?: string | undefined
|
||||
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
||||
}
|
||||
|
||||
export interface DatosContacto {
|
||||
nombre: string
|
||||
telefono?: string
|
||||
|
||||
Reference in New Issue
Block a user