- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
1748 lines
70 KiB
TypeScript
1748 lines
70 KiB
TypeScript
import { config } from '../config/index.js'
|
||
import type { UnidadCotizable } from './cotizacion.service.js'
|
||
import { AppError } from '../utils/errors.js'
|
||
import { prisma } from './prisma.js'
|
||
import { saludMonday } from './salud-monday.service.js'
|
||
|
||
/** Unidad tal como la consume el API (y el frontend del cotizador) */
|
||
export type Unidad = UnidadCotizable
|
||
|
||
interface ColumnaMeta {
|
||
id: string
|
||
title: string
|
||
type: string
|
||
}
|
||
|
||
interface ColumnValue {
|
||
id: string
|
||
text: string | null
|
||
value: string | null
|
||
}
|
||
|
||
interface ItemMonday {
|
||
id: string
|
||
name: string
|
||
column_values: ColumnValue[]
|
||
}
|
||
|
||
interface ItemConFechas extends ItemMonday {
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
/** Item de cualquier tablero con el grupo donde está (automatización estado → grupo) */
|
||
export interface ItemConGrupo {
|
||
id: string
|
||
name: string
|
||
groupId: string
|
||
column_values: ColumnValue[]
|
||
}
|
||
|
||
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
|
||
export interface LeadMonday {
|
||
id: string
|
||
nombre: string
|
||
createdAt: string // ISO (created_at del item en Monday)
|
||
updatedAt: string // ISO (updated_at del item en Monday)
|
||
estado: string // label de la columna status 'Estado' ('' si no tiene)
|
||
origen: string // label de la columna status 'Origen' ('' si no tiene)
|
||
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
||
email: string // columna email 'Email' ('' si no tiene)
|
||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||
// Perfilamiento (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 // status 'Estado Civil'
|
||
tipoEmpleo?: string // status 'Tipo de Empleo'
|
||
credito?: string // status 'Crédito'
|
||
tipoEconomia?: string // status 'Tipo de Economía'
|
||
califica?: string // status 'Califica'
|
||
rangoSalarial?: string // text 'Rango Salarial'
|
||
loQuierePara?: string // text 'Lo Quiere Para'
|
||
}
|
||
|
||
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
|
||
export interface ActividadMonday {
|
||
id: string
|
||
nombre: string
|
||
tipo: string // label de la columna status 'Tipo' (Llamada | Reunion | Tarea | ...)
|
||
fecha: string // 'YYYY-MM-DD' de la columna date 'Fecha' ('' si no tiene)
|
||
estado: string // label de la columna status 'Estado' (Abierto | Listo | ...)
|
||
responsableIds: number[] // user IDs de la columna people 'Responsable'
|
||
}
|
||
|
||
/** Apartado del tablero Apartado (pipeline Enkontrol) */
|
||
export interface ApartadoMonday {
|
||
id: string
|
||
nombre: string // nombre del cliente
|
||
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
||
estado: string // label de la columna status 'Estado'
|
||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||
telefono?: string // columna phone 'Teléfono' ('' si no tiene)
|
||
email?: string // columna email 'Correo electrónico' ('' si no tiene)
|
||
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
|
||
fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene)
|
||
curp?: string // columna text 'curp' ('' si no tiene)
|
||
formaPago?: string // dropdown 'Forma de Pago' ('' si no tiene)
|
||
enganche?: number // columna numbers 'Enganche' (0 si no tiene)
|
||
montoApartado?: number // columna numbers 'Monto apartado' (0 si no tiene)
|
||
}
|
||
|
||
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
|
||
export interface ContactoMonday {
|
||
id: string
|
||
nombre: string
|
||
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
||
email: string // columna email 'Email' ('' si no tiene)
|
||
estado: string // label de la columna status 'Estado' ('' si nunca se escribió)
|
||
updatedAt: string // ISO (updated_at del item en Monday)
|
||
}
|
||
|
||
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
|
||
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) */
|
||
export interface UsuarioMonday {
|
||
id: number
|
||
nombre: string
|
||
email: string
|
||
}
|
||
|
||
/** Entrada del activity log de Monday (boards { activity_logs }) */
|
||
interface ActivityLogMonday {
|
||
id: string
|
||
event: string
|
||
created_at: string // string numérico, normalmente en microsegundos
|
||
user_id: string
|
||
data: string // JSON string; pulse_id identifica el item
|
||
}
|
||
|
||
/** Evento del activity log de un item (historial completo, no solo el último) */
|
||
export interface EventoHistorialMonday {
|
||
itemId: string
|
||
fechaISO: string
|
||
usuarioId: number | null // user_id de Monday; null si no es numérico (automatizaciones)
|
||
evento: string // event crudo (create_pulse, update_column_value, ...)
|
||
}
|
||
|
||
/** Último evento del activity log de un item (para "Último cambio" de Mi día) */
|
||
export interface UltimoCambioMonday {
|
||
fechaISO: string
|
||
usuarioId: number | null // user_id de Monday; null si no es numérico
|
||
evento: string // event crudo del activity log (create_pulse, update_status, ...)
|
||
}
|
||
|
||
interface GraphQLErrorItem {
|
||
message: string
|
||
extensions?: { code?: string }
|
||
}
|
||
|
||
interface GraphQLResponse<T> {
|
||
data?: T
|
||
errors?: GraphQLErrorItem[]
|
||
error_code?: string
|
||
error_message?: string
|
||
}
|
||
|
||
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
|
||
class MondayRetryableError extends Error {}
|
||
|
||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||
|
||
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
|
||
|
||
/**
|
||
* Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026).
|
||
* El descarte crea el motivo directo en su grupo; si la categoría no está
|
||
* mapeada, el item cae en el grupo default del tablero.
|
||
*/
|
||
export const GRUPOS_MOTIVOS_NO_COMPRA: Record<string, string> = {
|
||
Timing: 'group_mm6spcsj',
|
||
Cliente: 'group_mm6sp7nr',
|
||
Competencia: 'group_mm6sbdfy',
|
||
Financiamiento: 'group_mm6ssymc',
|
||
Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday
|
||
Producto: 'group_mm6s7j0b',
|
||
Precio: 'group_mm6sze0z',
|
||
}
|
||
|
||
export interface MondayServiceOptions {
|
||
apiKey?: string
|
||
apiUrl?: string
|
||
maxReintentos?: number
|
||
baseDelayMs?: number
|
||
}
|
||
|
||
export class MondayService {
|
||
private readonly apiKey: string
|
||
private readonly apiUrl: string
|
||
private readonly maxReintentos: number
|
||
private readonly baseDelayMs: number
|
||
|
||
// Cache de metadatos de columnas: boardId -> (título -> meta)
|
||
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
|
||
// Cache de unidades + mapa codigo -> itemId (para escrituras)
|
||
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
|
||
|
||
constructor(opts: MondayServiceOptions = {}) {
|
||
this.apiKey = opts.apiKey ?? config.monday.apiKey
|
||
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
|
||
this.maxReintentos = opts.maxReintentos ?? 3
|
||
this.baseDelayMs = opts.baseDelayMs ?? 500
|
||
}
|
||
|
||
/**
|
||
* Helper GraphQL contra la API de Monday.
|
||
* - Headers Authorization / API-Version.
|
||
* - Lanza en errores GraphQL.
|
||
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
|
||
* (máx `maxReintentos` reintentos).
|
||
*/
|
||
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||
let ultimoError: Error | null = null
|
||
for (let intento = 0; intento <= this.maxReintentos; intento++) {
|
||
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
|
||
try {
|
||
const data = await this.graphqlOnce<T>(query, variables)
|
||
saludMonday.registrarExito()
|
||
return data
|
||
} catch (err) {
|
||
if (!(err instanceof MondayRetryableError)) {
|
||
// Fallo definitivo (AppError 502 de Monday): alimenta el circuit breaker
|
||
saludMonday.registrarFallo()
|
||
throw err
|
||
}
|
||
ultimoError = err
|
||
}
|
||
}
|
||
saludMonday.registrarFallo()
|
||
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
|
||
}
|
||
|
||
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||
const res = await fetch(this.apiUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: this.apiKey,
|
||
'API-Version': config.monday.apiVersion,
|
||
},
|
||
body: JSON.stringify({ query, variables }),
|
||
})
|
||
|
||
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
|
||
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
|
||
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
|
||
|
||
const body = (await res.json()) as GraphQLResponse<T>
|
||
|
||
// Monday devuelve errores de complejidad como 200 con error_code
|
||
if (body.error_code) {
|
||
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
|
||
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
|
||
}
|
||
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
|
||
}
|
||
if (body.errors?.length) {
|
||
const msg = body.errors.map((e) => e.message).join('; ')
|
||
const esComplejidad = body.errors.some(
|
||
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
|
||
)
|
||
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
|
||
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
|
||
}
|
||
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
|
||
return body.data
|
||
}
|
||
|
||
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
|
||
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
|
||
const cached = this.columnasCache.get(boardId)
|
||
if (cached) return cached
|
||
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
|
||
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
|
||
{ ids: [boardId] },
|
||
)
|
||
const columnas = data.boards[0]?.columns ?? []
|
||
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
|
||
this.columnasCache.set(boardId, mapa)
|
||
return mapa
|
||
}
|
||
|
||
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
|
||
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
|
||
const cols = await this.columnasDe(boardId)
|
||
const col = cols.get(titulo)
|
||
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
|
||
return col
|
||
}
|
||
|
||
/** Ping barato para /api/health */
|
||
async ping(): Promise<boolean> {
|
||
try {
|
||
const data = await this.graphql<{ boards: { id: string }[] }>(
|
||
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
|
||
{ ids: [config.monday.boards.unidades] },
|
||
)
|
||
return (data.boards?.length ?? 0) > 0
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
|
||
invalidarCacheUnidades(): void {
|
||
this.unidadesCache = null
|
||
}
|
||
|
||
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
|
||
async getUnidades(): Promise<Unidad[]> {
|
||
const ahora = Date.now()
|
||
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
|
||
return this.unidadesCache.unidades
|
||
}
|
||
const boardId = config.monday.boards.unidades
|
||
const cols = await this.columnasDe(boardId)
|
||
|
||
const items: ItemMonday[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500) { cursor items { id name column_values { id text value } } }
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } }
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
const itemIds = new Map<string, string>()
|
||
const unidades: Unidad[] = []
|
||
for (const item of items) {
|
||
const unidad = this.mapearUnidad(item, cols)
|
||
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
|
||
// Monday) no son inventario: se excluyen del listado
|
||
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
|
||
itemIds.set(item.name, item.id)
|
||
unidades.push(unidad)
|
||
}
|
||
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
|
||
// Write-through al espejo local (tabla Unidad): mantiene fresco el
|
||
// inventario para el modo local. Best-effort: nunca rompe la lectura.
|
||
void this.persistirUnidadesLocal(unidades)
|
||
return unidades
|
||
}
|
||
|
||
/**
|
||
* Upsert best-effort de las unidades leídas de Monday a la tabla local
|
||
* `Unidad` (fallback del cotizador cuando Monday cae). Protegido para poder
|
||
* apagarlo en tests; los errores solo se loguean.
|
||
*/
|
||
protected async persistirUnidadesLocal(unidades: Unidad[]): Promise<void> {
|
||
// Los tests mockean fetch y no deben escribir en la base real
|
||
if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return
|
||
try {
|
||
for (const u of unidades) {
|
||
const datos = {
|
||
desarrollo: u.desarrollo,
|
||
modelo: u.modelo,
|
||
supConstruccion: u.supConstruccion,
|
||
supExcedente: u.supExcedente,
|
||
supEstacionamiento: u.supEstacionamiento,
|
||
precioLista: u.precioSinAcabados,
|
||
precioAcabados: u.precioConAcabados,
|
||
status: u.status,
|
||
etapa: u.etapa ?? '',
|
||
}
|
||
await prisma.unidad.upsert({
|
||
where: { codigo: u.codigo },
|
||
update: datos,
|
||
create: { codigo: u.codigo, ...datos },
|
||
})
|
||
}
|
||
} catch (err) {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[unidades] falló el write-through local (se ignora):', err)
|
||
}
|
||
}
|
||
|
||
/** Una unidad por código (U-101, U-310...); null si no existe */
|
||
async getUnidad(codigo: string): Promise<Unidad | null> {
|
||
const unidades = await this.getUnidades()
|
||
return unidades.find((u) => u.codigo === codigo) ?? null
|
||
}
|
||
|
||
/** Item ID de una unidad por código (para escrituras); null si no existe */
|
||
async itemIdDeUnidad(codigo: string): Promise<string | null> {
|
||
await this.getUnidades() // asegura cache fresco
|
||
return this.unidadesCache?.itemIds.get(codigo) ?? null
|
||
}
|
||
|
||
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
|
||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||
const textoDe = (titulo: string): string => {
|
||
const col = cols.get(titulo)
|
||
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
|
||
return porId.get(col.id)?.text ?? ''
|
||
}
|
||
const numeroDe = (titulo: string): number => {
|
||
const n = Number(textoDe(titulo))
|
||
return Number.isFinite(n) ? n : 0
|
||
}
|
||
const status = textoDe('Disponibilidad').toUpperCase()
|
||
// La columna "Meses para firma" es opcional (solo la usa SOLANA)
|
||
const colMeses = cols.get('Meses para firma')
|
||
const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : ''
|
||
const mesesNum = Number(mesesTexto)
|
||
// La columna status "Etapa" (pipeline de 10 etapas) es opcional
|
||
const colEtapa = cols.get('Etapa')
|
||
const etapaTexto = colEtapa ? (porId.get(colEtapa.id)?.text ?? '') : ''
|
||
return {
|
||
codigo: item.name,
|
||
modelo: textoDe('Modelo'),
|
||
fechaEntrega: textoDe('Fecha entrega') || null,
|
||
supEstacionamiento: numeroDe('Sup. estacionamiento'),
|
||
supConstruccion: numeroDe('Sup. construcción'),
|
||
supExcedente: numeroDe('Sup. excedente'),
|
||
precioSinAcabados: numeroDe('Precio sin acabados'),
|
||
precioConAcabados: numeroDe('Precio con acabados'),
|
||
// Se conserva el status real del board (VENDIDO, ESCRITURADO, BLOQUEADO, ...)
|
||
status: status || 'DISPONIBLE',
|
||
desarrollo: textoDe('Desarrollo'),
|
||
mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null,
|
||
etapa: etapaTexto !== '' ? etapaTexto : null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
|
||
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
|
||
* template literals dentro del query).
|
||
*/
|
||
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':
|
||
return typeof crudo === 'number' ? crudo : Number(crudo)
|
||
case 'email':
|
||
return { email: String(crudo), text: String(crudo) }
|
||
case 'phone': {
|
||
// LADA explícita: '+1...' (o 11 dígitos empezando en 1) = USA; el resto = México.
|
||
// Monday rechaza el número con espacios ("+52 664..."): se envía compacto.
|
||
const tel = String(crudo).replace(/\s+/g, '')
|
||
const digitos = tel.replace(/\D/g, '')
|
||
const esUsa = tel.startsWith('+1') || (digitos.length === 11 && digitos.startsWith('1'))
|
||
return { phone: tel, countryShortName: esUsa ? 'US' : '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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 resolverLabel(
|
||
boardId: string,
|
||
col: ColumnaMeta,
|
||
valor: string | number | boolean,
|
||
): Promise<string | number | boolean> {
|
||
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
|
||
}
|
||
|
||
private async construirColumnValues(
|
||
boardId: string,
|
||
valores: Record<string, string | number | boolean | undefined>,
|
||
): Promise<string> {
|
||
const out: Record<string, unknown> = {}
|
||
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.resolverLabel(boardId, col, crudo))
|
||
}
|
||
return JSON.stringify(out)
|
||
}
|
||
|
||
private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise<string> {
|
||
const data = await this.graphql<{ create_item: { id: string } }>(
|
||
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!, $groupId: String) {
|
||
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, group_id: $groupId) { id }
|
||
}`,
|
||
{ boardId, itemName: nombre, columnValues, groupId: groupId ?? null },
|
||
)
|
||
return data.create_item.id
|
||
}
|
||
|
||
// ---------- Escrituras de negocio ----------
|
||
|
||
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
|
||
const boardId = config.monday.boards.oportunidades
|
||
const columnValues = await this.construirColumnValues(boardId, {
|
||
Unidad: datos.unidadCodigo,
|
||
'Cliente email': datos.clienteEmail,
|
||
'Cliente teléfono': datos.clienteTelefono,
|
||
'Tipo propuesta': datos.tipoPropuestaLabel,
|
||
'Precio de lista': datos.precioLista,
|
||
'Descuento %': datos.descuentoPct,
|
||
'Precio final': datos.precioFinal,
|
||
'Fecha cotización': datos.fechaCotizacion,
|
||
Estado: datos.estadoLabel,
|
||
})
|
||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
* estadoLabel permite crearla en otra etapa (p. ej. 'Agendo Cita' al
|
||
* convertir un lead del tablero Leads).
|
||
*/
|
||
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: datos.estadoLabel ?? 'Afluencia',
|
||
Fecha: fechaHoy,
|
||
'Cliente teléfono': datos.telefono,
|
||
'Cliente email': datos.email,
|
||
Desarrollo: datos.desarrolloLabel,
|
||
'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)
|
||
}
|
||
|
||
/**
|
||
* Actualiza una oportunidad existente con el perfil completo de la afluencia
|
||
* (mismo builder que crearOportunidadAfluencia) y la pone en etapa
|
||
* 'Afluencia'. La usa el botón "Afluencia" de Mis oportunidades en Mi día:
|
||
* el formulario se manda prellenado y el envío enriquece el item en vez de
|
||
* duplicarlo. El nombre del item se actualiza por el column id 'name'.
|
||
*/
|
||
async actualizarOportunidadAfluencia(oportunidadId: string, datos: DatosOportunidadAfluencia): Promise<void> {
|
||
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 valores = JSON.parse(
|
||
await this.construirColumnValues(boardId, {
|
||
Estado: 'Afluencia',
|
||
Fecha: fechaHoy,
|
||
'Cliente teléfono': datos.telefono,
|
||
'Cliente email': datos.email,
|
||
Desarrollo: datos.desarrolloLabel,
|
||
'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
|
||
'Lead Califica': datos.calificaLabel,
|
||
'Proposito de Compra': datos.loQuierePara,
|
||
COMENTARIOS: datos.notas,
|
||
}),
|
||
) as Record<string, unknown>
|
||
if (datos.nombre !== '') valores['name'] = datos.nombre
|
||
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: JSON.stringify(valores) },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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, {
|
||
Teléfono: datos.telefono,
|
||
Email: datos.email,
|
||
CURP: datos.curp,
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* Actualiza un Contacto existente con los campos presentes (mismo builder de
|
||
* valores que crearContacto) — usado por el guardado progresivo del alta.
|
||
* El nombre del item se actualiza por el column id especial 'name'.
|
||
*/
|
||
async actualizarContacto(itemId: string, datos: Partial<DatosContacto>): Promise<void> {
|
||
const boardId = config.monday.boards.contactos
|
||
const valores = JSON.parse(
|
||
await this.construirColumnValues(boardId, {
|
||
Teléfono: datos.telefono,
|
||
Email: datos.email,
|
||
CURP: datos.curp,
|
||
RFC: datos.rfc,
|
||
Dirección: datos.direccion,
|
||
Estado: datos.estadoLabel,
|
||
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,
|
||
}),
|
||
) as Record<string, unknown>
|
||
if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre
|
||
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, columnValues: JSON.stringify(valores) },
|
||
)
|
||
}
|
||
|
||
async crearApartado(datos: DatosApartado): Promise<string> {
|
||
const boardId = config.monday.boards.apartado
|
||
// Mapeo a las columnas reales del tablero Apartado (verificadas en Monday):
|
||
// contacto (Teléfono/Correo/curp), operación (Precio Final/Enganche/
|
||
// Fecha Compra/Forma de Pago), asignación (Vendedor) y Observaciones.
|
||
// Todo lo opcional se omite si viene undefined (mismo patrón de siempre).
|
||
const columnValues = await this.construirColumnValues(boardId, {
|
||
Unidad: datos.unidadCodigo,
|
||
'Monto apartado': datos.montoApartado,
|
||
'Fecha apartado': datos.fechaApartado,
|
||
'Fecha límite': datos.fechaLimite,
|
||
Estado: datos.estadoLabel,
|
||
Teléfono: datos.telefono,
|
||
'Correo electrónico': datos.email,
|
||
curp: datos.curp, // el título de la columna es minúscula en el board
|
||
'Precio Final': datos.precioFinal,
|
||
Enganche: datos.enganche,
|
||
'Fecha Compra': datos.fechaCompra,
|
||
Vendedor: datos.vendedorId,
|
||
'Forma de Pago': datos.formaPagoLabel, // dropdown; el resolver ajusta case
|
||
Observaciones: datos.observaciones,
|
||
})
|
||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||
}
|
||
|
||
/**
|
||
* Actualiza un apartado existente con los campos presentes (mismo builder de
|
||
* valores que crearApartado) — usado cuando el alta de cliente arranca desde
|
||
* un apartado ya creado (botón "Firma Cliente (Venta)" de Mis apartados):
|
||
* se enriquece el item en vez de crear uno duplicado.
|
||
*/
|
||
async actualizarApartado(itemId: string, datos: Partial<DatosApartado>): Promise<void> {
|
||
const boardId = config.monday.boards.apartado
|
||
const valores = JSON.parse(
|
||
await this.construirColumnValues(boardId, {
|
||
Unidad: datos.unidadCodigo,
|
||
'Monto apartado': datos.montoApartado,
|
||
'Fecha apartado': datos.fechaApartado,
|
||
'Fecha límite': datos.fechaLimite,
|
||
Estado: datos.estadoLabel,
|
||
Teléfono: datos.telefono,
|
||
'Correo electrónico': datos.email,
|
||
curp: datos.curp,
|
||
'Precio Final': datos.precioFinal,
|
||
Enganche: datos.enganche,
|
||
'Fecha Compra': datos.fechaCompra,
|
||
Vendedor: datos.vendedorId,
|
||
'Forma de Pago': datos.formaPagoLabel,
|
||
Observaciones: datos.observaciones,
|
||
}),
|
||
) as Record<string, unknown>
|
||
if (datos.clienteNombre !== undefined && datos.clienteNombre !== '') valores['name'] = datos.clienteNombre
|
||
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, columnValues: JSON.stringify(valores) },
|
||
)
|
||
}
|
||
|
||
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
|
||
// Modo local: Monday caído (circuit breaker) → espejo local + réplica encolada
|
||
if (!saludMonday.estaDisponible()) {
|
||
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
|
||
return
|
||
}
|
||
try {
|
||
const boardId = config.monday.boards.unidades
|
||
// Asegura cache fresco para resolver el itemId
|
||
await this.getUnidades()
|
||
const itemId = this.unidadesCache?.itemIds.get(codigo)
|
||
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
|
||
const col = await this.columnaId(boardId, 'Disponibilidad')
|
||
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({ label: status }) },
|
||
)
|
||
this.invalidarCacheUnidades()
|
||
} catch (err) {
|
||
// Monday se cayó a media operación: mismo fallback local (el 404 de
|
||
// "no existe en Monday" NO es fallo de Monday y se propaga intacto)
|
||
if (err instanceof AppError && err.status === 502) {
|
||
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
|
||
return
|
||
}
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fallback local de actualizarDisponibilidadUnidad: actualiza el status en el
|
||
* espejo (tabla Unidad) y encola la réplica DISPONIBILIDAD_UNIDAD para que el
|
||
* sync worker la aplique en Monday cuando vuelva. 404 si la unidad no está en
|
||
* el espejo. El encolado es best-effort (nunca rompe la escritura local).
|
||
*/
|
||
private async actualizarDisponibilidadUnidadLocal(
|
||
codigo: string,
|
||
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA',
|
||
): Promise<void> {
|
||
const resultado = await prisma.unidad.updateMany({ where: { codigo }, data: { status } })
|
||
if (resultado.count === 0) throw new AppError(404, `Unidad ${codigo} no encontrada`)
|
||
try {
|
||
await prisma.syncPendiente.create({
|
||
data: {
|
||
tipo: 'DISPONIBILIDAD_UNIDAD',
|
||
payloadJson: JSON.stringify({ codigo, status }),
|
||
referenciaLocal: codigo,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
// eslint-disable-next-line no-console
|
||
console.error(`[sync] no se pudo encolar DISPONIBILIDAD_UNIDAD de ${codigo}:`, err)
|
||
}
|
||
}
|
||
|
||
// ---------- 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)
|
||
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 }
|
||
}`,
|
||
{ boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, resuelto)) },
|
||
)
|
||
}
|
||
|
||
/** ID de una columna por título exacto; null si no existe (lectura tolerante) */
|
||
async columnaIdPorTitulo(boardId: string, titulo: string): Promise<string | null> {
|
||
const cols = await this.columnasDe(boardId)
|
||
return cols.get(titulo)?.id ?? null
|
||
}
|
||
|
||
/** Item de cualquier tablero con su grupo y column_values; null si no existe */
|
||
async getItemConGrupo(itemId: string): Promise<ItemConGrupo | null> {
|
||
const data = await this.graphql<{ items: (ItemMonday & { group: { id: string } } | null)[] }>(
|
||
`query ($ids: [ID!]) {
|
||
items(ids: $ids) { id name group { id } column_values { id text value } }
|
||
}`,
|
||
{ ids: [itemId] },
|
||
)
|
||
const item = data.items[0]
|
||
if (!item) return null
|
||
return { id: item.id, name: item.name, groupId: item.group.id, column_values: item.column_values }
|
||
}
|
||
|
||
/** Mueve un item a otro grupo de su mismo tablero */
|
||
async moverItemAGrupo(itemId: string, groupId: string): Promise<void> {
|
||
await this.graphql<{ move_item_to_group: { id: string } }>(
|
||
`mutation ($itemId: ID!, $groupId: String!) {
|
||
move_item_to_group(item_id: $itemId, group_id: $groupId) { id }
|
||
}`,
|
||
{ itemId, groupId },
|
||
)
|
||
}
|
||
|
||
/** 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>"),
|
||
* creado directo en el grupo de su categoría (si está mapeada) */
|
||
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,
|
||
Subcategoria: datos.subcategoria,
|
||
Descripcion: datos.descripcion,
|
||
})
|
||
const buscada = datos.categoria.trim().toUpperCase()
|
||
const groupId = Object.entries(GRUPOS_MOTIVOS_NO_COMPRA).find(
|
||
([categoria]) => categoria.toUpperCase() === buscada,
|
||
)?.[1]
|
||
return this.crearItem(boardId, datos.nombre, columnValues, groupId)
|
||
}
|
||
|
||
/** 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 */
|
||
async getLeads(): Promise<LeadMonday[]> {
|
||
const boardId = config.monday.boards.leads
|
||
const cols = await this.columnasDe(boardId)
|
||
|
||
const items: ItemConFechas[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 200) {
|
||
cursor
|
||
items { id name created_at updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 200, cursor: $cursor) {
|
||
cursor
|
||
items { id name created_at updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
return items.map((item) => 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')
|
||
// Cuando el status nunca se ha escrito, Monday devuelve value=null pero
|
||
// `text` muestra el label default del tablero (hoy "Descartado"): el estado
|
||
// real es vacío, no el default. Se lee `text` SOLO si hay value.
|
||
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||
// Perfilamiento: lectura tolerante por título ('' / null si falta la columna)
|
||
const textoDe = (titulo: string): string => {
|
||
const col = cols.get(titulo)
|
||
return col ? (porId.get(col.id)?.text ?? '') : ''
|
||
}
|
||
const edadRaw = textoDe('Edad')
|
||
const edadNum = Number(edadRaw)
|
||
return {
|
||
id: item.id,
|
||
nombre: item.name,
|
||
createdAt: item.created_at,
|
||
updatedAt: item.updated_at,
|
||
estado,
|
||
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
|
||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||
vendedorIds,
|
||
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 Economía'),
|
||
califica: textoDe('Califica'),
|
||
rangoSalarial: textoDe('Rango Salarial'),
|
||
loQuierePara: textoDe('Lo Quiere Para'),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
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
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 200) {
|
||
cursor
|
||
items { id name created_at updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 200, cursor: $cursor) {
|
||
cursor
|
||
items { id name created_at updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
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) ----------
|
||
|
||
/**
|
||
* Último evento del activity log por item: UNA query por tablero con todos
|
||
* los itemIds (limit = items × 5, por si hay varios eventos por item). El
|
||
* item se identifica por el pulse_id del payload `data` (JSON string). El
|
||
* created_at viene como string numérico; por magnitud se normaliza a ms:
|
||
* > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs,
|
||
* si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday.
|
||
*/
|
||
async getUltimosCambios(boardId: string, itemIds: string[]): Promise<Map<string, UltimoCambioMonday>> {
|
||
const resultado = new Map<string, UltimoCambioMonday>()
|
||
if (itemIds.length === 0) return resultado
|
||
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
|
||
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
|
||
boards(ids: $ids) {
|
||
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
|
||
}
|
||
}`,
|
||
{ ids: [boardId], itemIds, limit: itemIds.length * 5 },
|
||
)
|
||
for (const log of data.boards[0]?.activity_logs ?? []) {
|
||
let itemId: string | undefined
|
||
try {
|
||
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
|
||
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
|
||
} catch {
|
||
continue
|
||
}
|
||
if (itemId === undefined) continue
|
||
const crudo = Number(log.created_at)
|
||
if (!Number.isFinite(crudo)) continue
|
||
// Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo
|
||
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
|
||
const fechaISO = new Date(ms).toISOString()
|
||
// Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente
|
||
const actual = resultado.get(itemId)
|
||
if (actual && actual.fechaISO >= fechaISO) continue
|
||
const userId = Number(log.user_id)
|
||
resultado.set(itemId, {
|
||
fechaISO,
|
||
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
|
||
evento: log.event,
|
||
})
|
||
}
|
||
return resultado
|
||
}
|
||
|
||
/**
|
||
* TODOS los eventos del activity log de un conjunto de items (historial del
|
||
* módulo Contactos), más reciente primero. Misma normalización de created_at
|
||
* que getUltimosCambios.
|
||
*/
|
||
async getHistorialItems(boardId: string, itemIds: string[]): Promise<EventoHistorialMonday[]> {
|
||
if (itemIds.length === 0) return []
|
||
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
|
||
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
|
||
boards(ids: $ids) {
|
||
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
|
||
}
|
||
}`,
|
||
{ ids: [boardId], itemIds, limit: Math.min(itemIds.length * 10, 200) },
|
||
)
|
||
const eventos: EventoHistorialMonday[] = []
|
||
for (const log of data.boards[0]?.activity_logs ?? []) {
|
||
let itemId: string | undefined
|
||
try {
|
||
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
|
||
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
|
||
} catch {
|
||
continue
|
||
}
|
||
if (itemId === undefined) continue
|
||
const crudo = Number(log.created_at)
|
||
if (!Number.isFinite(crudo)) continue
|
||
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
|
||
const userId = Number(log.user_id)
|
||
eventos.push({
|
||
itemId,
|
||
fechaISO: new Date(ms).toISOString(),
|
||
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
|
||
evento: log.event,
|
||
})
|
||
}
|
||
return eventos.sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||
}
|
||
|
||
/** Item del tablero Contactos con TODAS sus columnas por título ('' si vacía) */
|
||
async getContactoDetalle(itemId: string): Promise<Record<string, string> | null> {
|
||
const boardId = config.monday.boards.contactos
|
||
const [cols, item] = await Promise.all([this.columnasDe(boardId), this.getItemConGrupo(itemId)])
|
||
if (!item) return null
|
||
const porTitulo: Record<string, string> = { nombre: item.name }
|
||
for (const [titulo, col] of cols) {
|
||
const cv = item.column_values.find((c) => c.id === col.id)
|
||
// status nunca escrito: value null pero text muestra el default del tablero
|
||
porTitulo[titulo] = cv?.value ? (cv.text ?? '') : ''
|
||
}
|
||
return porTitulo
|
||
}
|
||
|
||
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
|
||
private peopleIds(raw: string | null | undefined): number[] {
|
||
if (!raw) return []
|
||
try {
|
||
const parsed = JSON.parse(raw) as { personsAndTeams?: { id: number; kind: string }[] }
|
||
return (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/** Todas las actividades del tablero Actividades, con tipo, fecha, estado y responsables */
|
||
async getActividades(): Promise<ActividadMonday[]> {
|
||
const boardId = config.monday.boards.actividades
|
||
if (boardId === '') return []
|
||
const cols = await this.columnasDe(boardId)
|
||
const colTipo = cols.get('Tipo')
|
||
const colFecha = cols.get('Fecha')
|
||
const colEstado = cols.get('Estado')
|
||
const colResponsable = cols.get('Responsable')
|
||
|
||
const items: ItemMonday[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500) {
|
||
cursor
|
||
items { id name column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500, cursor: $cursor) {
|
||
cursor
|
||
items { id name column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
return items.map((item) => {
|
||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||
return {
|
||
id: item.id,
|
||
nombre: item.name,
|
||
tipo: colTipo ? (porId.get(colTipo.id)?.text ?? '') : '',
|
||
fecha: colFecha ? (porId.get(colFecha.id)?.text ?? '') : '',
|
||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||
responsableIds: colResponsable ? this.peopleIds(porId.get(colResponsable.id)?.value) : [],
|
||
}
|
||
})
|
||
}
|
||
|
||
/** Todos los apartados del tablero Apartado, con unidad, fecha límite, estado y vendedor */
|
||
async getApartados(): Promise<ApartadoMonday[]> {
|
||
const boardId = config.monday.boards.apartado
|
||
const cols = await this.columnasDe(boardId)
|
||
const colUnidad = cols.get('Unidad')
|
||
const colFechaLimite = cols.get('Fecha límite')
|
||
const colEstado = cols.get('Estado')
|
||
const colVendedor = cols.get('Vendedor')
|
||
const colTelefono = cols.get('Teléfono')
|
||
const colEmail = cols.get('Correo electrónico')
|
||
const colPrecioFinal = cols.get('Precio Final')
|
||
const colFechaCompra = cols.get('Fecha Compra')
|
||
const colCurp = cols.get('curp')
|
||
const colFormaPago = cols.get('Forma de Pago')
|
||
const colEnganche = cols.get('Enganche')
|
||
const colMontoApartado = cols.get('Monto apartado')
|
||
|
||
const items: ItemMonday[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500) {
|
||
cursor
|
||
items { id name column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500, cursor: $cursor) {
|
||
cursor
|
||
items { id name column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
return items.map((item) => {
|
||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||
// Misma corrección que mapearLead: status nunca escrito → value null y
|
||
// `text` muestra el default del tablero; el estado real es vacío
|
||
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||
return {
|
||
id: item.id,
|
||
nombre: item.name,
|
||
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
||
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
||
estado,
|
||
vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
|
||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
|
||
fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '',
|
||
curp: colCurp ? (porId.get(colCurp.id)?.text ?? '') : '',
|
||
formaPago: colFormaPago ? (porId.get(colFormaPago.id)?.text ?? '') : '',
|
||
enganche: colEnganche ? Number(porId.get(colEnganche.id)?.text ?? 0) || 0 : 0,
|
||
montoApartado: colMontoApartado ? Number(porId.get(colMontoApartado.id)?.text ?? 0) || 0 : 0,
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Contactos del tablero Contactos (módulo Contactos de la landing): nombre,
|
||
* teléfono, email y estado, ordenados por updated_at desc, máx 100.
|
||
*/
|
||
async getContactos(): Promise<ContactoMonday[]> {
|
||
const boardId = config.monday.boards.contactos
|
||
const cols = await this.columnasDe(boardId)
|
||
const colTelefono = cols.get('Teléfono')
|
||
const colEmail = cols.get('Email')
|
||
const colEstado = cols.get('Estado')
|
||
|
||
const items: ItemConFechas[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 100) {
|
||
cursor
|
||
items { id name updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 100, cursor: $cursor) {
|
||
cursor
|
||
items { id name updated_at column_values { id text value } }
|
||
}
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
items.push(...page.items)
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
|
||
return items
|
||
.map((item) => {
|
||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||
// Misma corrección que mapearLead: status nunca escrito → value null y
|
||
// `text` muestra el default del tablero; el estado real es vacío
|
||
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||
return {
|
||
id: item.id,
|
||
nombre: item.name,
|
||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||
estado,
|
||
updatedAt: item.updated_at,
|
||
}
|
||
})
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||
.slice(0, 100)
|
||
}
|
||
|
||
/**
|
||
* Labels de una columna status/dropdown (settings_str del board).
|
||
* Monday los devuelve como objeto {index: label} o como arreglo [{id, name}].
|
||
*/
|
||
async labelsDeColumna(boardId: string, titulo: string): Promise<string[]> {
|
||
const data = await this.graphql<{ boards: { columns: { id: string; title: string; settings_str: string }[] }[] }>(
|
||
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title settings_str } } }`,
|
||
{ ids: [boardId] },
|
||
)
|
||
const col = data.boards[0]?.columns.find((c) => c.title === titulo)
|
||
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
|
||
try {
|
||
const settings = JSON.parse(col.settings_str || '{}') as { labels?: Record<string, string> | { name: string }[] }
|
||
const labels = settings.labels
|
||
if (Array.isArray(labels)) return labels.map((l) => l.name)
|
||
if (labels && typeof labels === 'object') return Object.values(labels)
|
||
return []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor desde
|
||
* /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura
|
||
* (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias).
|
||
*/
|
||
async crearLead(datos: DatosLead): Promise<string> {
|
||
const boardId = config.monday.boards.leads
|
||
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: 'NUEVO',
|
||
Fecha: fechaHoy,
|
||
Teléfono: datos.telefono,
|
||
Email: datos.email,
|
||
Origen: datos.origenLabel,
|
||
Desarrollo: datos.desarrolloLabel,
|
||
Notas: datos.notas,
|
||
})
|
||
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.
|
||
*/
|
||
async buscarLeadPorTelefono(telefono: string): Promise<LeadMonday | null> {
|
||
const digitos = telefono.replace(/\D/g, '')
|
||
if (digitos.length < 10) return null
|
||
const clave = digitos.slice(-10)
|
||
const leads = await this.getLeads()
|
||
return (
|
||
leads.find((l) => {
|
||
const d = l.telefono.replace(/\D/g, '')
|
||
return d.length >= 10 && d.slice(-10) === clave
|
||
}) ?? null
|
||
)
|
||
}
|
||
|
||
/** Borra un item de cualquier tablero (limpieza de datos de prueba) */
|
||
async eliminarItem(itemId: string): Promise<void> {
|
||
await this.graphql<{ delete_item: { id: string } }>(
|
||
`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`,
|
||
{ itemId },
|
||
)
|
||
}
|
||
|
||
/** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */
|
||
async crearVenta(datos: DatosVenta): Promise<string> {
|
||
const boardId = config.monday.boards.ventas
|
||
const columnValues = await this.construirColumnValues(boardId, {
|
||
Unidad: datos.unidadCodigo,
|
||
'Valor venta': datos.valorVenta,
|
||
})
|
||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||
}
|
||
|
||
/**
|
||
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
|
||
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
|
||
*/
|
||
async getActividadesAbiertas(): Promise<number> {
|
||
const boardId = config.monday.boards.actividades
|
||
if (boardId === '') return 0
|
||
const cols = await this.columnasDe(boardId)
|
||
const colEstado = cols.get('Estado')
|
||
if (!colEstado) return 0
|
||
|
||
let abiertas = 0
|
||
let cursor: string | null = null
|
||
do {
|
||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||
cursor === null
|
||
? await this.graphql(
|
||
`query ($ids: [ID!]) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500) { cursor items { id column_values { id text } } }
|
||
}
|
||
}`,
|
||
{ ids: [boardId] },
|
||
)
|
||
: await this.graphql(
|
||
`query ($ids: [ID!], $cursor: String!) {
|
||
boards(ids: $ids) {
|
||
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
|
||
}
|
||
}`,
|
||
{ ids: [boardId], cursor },
|
||
)
|
||
const page = data.boards[0]?.items_page
|
||
if (!page) break
|
||
for (const item of page.items) {
|
||
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
|
||
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
|
||
}
|
||
cursor = page.cursor
|
||
} while (cursor !== null)
|
||
return abiertas
|
||
}
|
||
|
||
/** Asigna una persona (user ID de Monday) a una columna people de un item */
|
||
private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): 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({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
|
||
},
|
||
)
|
||
}
|
||
|
||
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
|
||
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
|
||
return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId)
|
||
}
|
||
|
||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||
return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId)
|
||
}
|
||
|
||
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
|
||
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
|
||
const data = await this.graphql<{ create_update: { id: string } }>(
|
||
`mutation ($itemId: ID!, $body: String!) {
|
||
create_update(item_id: $itemId, body: $body) { id }
|
||
}`,
|
||
{ itemId, body },
|
||
)
|
||
return data.create_update.id
|
||
}
|
||
|
||
/**
|
||
* Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday,
|
||
* cuyo email termina en @agent.monday.com).
|
||
*/
|
||
async getUsuariosWorkspace(): Promise<UsuarioMonday[]> {
|
||
const data = await this.graphql<{
|
||
users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[]
|
||
}>(`query { users(limit: 50) { id name email is_guest enabled } }`)
|
||
return data.users
|
||
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
|
||
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
|
||
}
|
||
|
||
/**
|
||
* Miembros de un equipo (team) de Monday por nombre, case-insensitive
|
||
* (p. ej. "vendedores" para el round-robin). Mismo filtro que el workspace.
|
||
*/
|
||
async getUsuariosEquipo(nombre: string): Promise<UsuarioMonday[]> {
|
||
const data = await this.graphql<{
|
||
teams: { name: string; users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[] }[] | null
|
||
}>(`query { teams { name users { id name email is_guest enabled } } }`)
|
||
const equipo = (data.teams ?? []).find(
|
||
(t) => t.name.trim().toUpperCase() === nombre.trim().toUpperCase(),
|
||
)
|
||
if (!equipo) throw new AppError(500, `El equipo "${nombre}" no existe en Monday`)
|
||
return equipo.users
|
||
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
|
||
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
|
||
}
|
||
}
|
||
|
||
// ---------- DTOs de escritura ----------
|
||
|
||
export interface DatosOportunidad {
|
||
clienteNombre: string
|
||
unidadCodigo: string
|
||
tipoPropuestaLabel: string
|
||
precioLista: number
|
||
descuentoPct: number // porcentaje (2 = 2%)
|
||
precioFinal: number
|
||
fechaCotizacion: string // 'YYYY-MM-DD'
|
||
estadoLabel: string // p. ej. 'COTIZADA'
|
||
clienteEmail?: string
|
||
clienteTelefono?: string
|
||
}
|
||
|
||
export interface DatosOportunidadAfluencia {
|
||
nombre: string // name del item = nombre del interesado
|
||
/** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */
|
||
estadoLabel?: string | undefined
|
||
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 {
|
||
nombre: string
|
||
telefono?: string
|
||
email?: string
|
||
curp?: string
|
||
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 {
|
||
clienteNombre: string
|
||
unidadCodigo: string
|
||
montoApartado?: number
|
||
fechaApartado: string // 'YYYY-MM-DD'
|
||
fechaLimite: string // 'YYYY-MM-DD'
|
||
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
||
// Contacto y operación (opcionales; columnas reales del tablero Apartado)
|
||
telefono?: string | undefined // phone 'Teléfono'
|
||
email?: string | undefined // email 'Correo electrónico'
|
||
curp?: string | undefined // text 'curp' (título minúscula en el board)
|
||
precioFinal?: number | undefined // numbers 'Precio Final'
|
||
enganche?: number | undefined // numbers 'Enganche'
|
||
fechaCompra?: string | undefined // date 'Fecha Compra' ('YYYY-MM-DD')
|
||
vendedorId?: number | undefined // people 'Vendedor' (user ID de Monday)
|
||
formaPagoLabel?: string | undefined // dropdown 'Forma de Pago' (labels ya sembrados)
|
||
observaciones?: string | undefined // long_text 'Observaciones'
|
||
}
|
||
|
||
export interface DatosVenta {
|
||
clienteNombre: string
|
||
unidadCodigo?: string
|
||
valorVenta?: number
|
||
}
|
||
|
||
export interface DatosMotivoNoCompra {
|
||
nombre: string // '<motivo> — <nombre del item de origen>'
|
||
categoria: string // una de MOTIVOS_NO_COMPRA
|
||
/** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */
|
||
subcategoria?: string
|
||
descripcion?: string
|
||
}
|
||
|
||
export interface DatosActividad {
|
||
nombre: string
|
||
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
|
||
responsableId?: number // user ID de Monday (se omite si no hay responsable claro)
|
||
fecha: string // 'YYYY-MM-DD'
|
||
estadoLabel: string // 'Abierto' | 'Listo'
|
||
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
|
||
}
|
||
|
||
/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */
|
||
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
|
||
}
|
||
|
||
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||
export const mondayService = new MondayService()
|
||
|