feat(monday): alinear Frasa-CAS con CRM real del cliente (7 mejoras)

- Vocabulario funnel: labels Intento de contacto/Abandono en Leads,
  etapas completas en Oportunidades (Cotizador, Ganado, Perdido...)
- Apartados: pipeline Enkontrol (8 estados) + checkboxes CURP/RFC/Comprobante
  que la app de alta prende al subir documentos
- Motivos No Compra: board catálogo + dropdown + endpoint /oportunidades/:id/perder
- Perfil demográfico en Contactos mapeado desde el alta (estado civil,
  vivienda, propósito, fecha nac., rango de edad calculado)
- Desarrollos: catálogo maestro sincronizado (precios, m2, tipo, entrega)
- Actividades: board nuevo + creación automática en round-robin e inactividad
- Cobranza: plan liquidado mueve el apartado a 'X Escriturar' en Monday
- Scripts: setup:crm y sync:desarrollos (idempotentes)
- Tests 119/119 verdes
This commit is contained in:
2026-08-22 07:18:48 +00:00
parent afb454b54c
commit f0f923b2ef
22 changed files with 1178 additions and 13 deletions

View File

@@ -0,0 +1,55 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(),
})
type MondayOportunidades = Pick<
MondayService,
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
>
/**
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
* registra el motivo en el tablero catálogo Motivos No Compra.
* Devuelve el ID del item creado en Motivos No Compra.
*/
export async function perderOportunidad(
monday: MondayOportunidades,
mondayItemId: string,
motivo: string,
comentario?: string,
): Promise<string> {
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
}