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:
2026-09-01 20:10:51 +00:00
parent ead7a71025
commit 98fedbd8d3
14 changed files with 854 additions and 53 deletions

View File

@@ -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