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

@@ -13,6 +13,11 @@ MONDAY_BOARD_LEADS="18427678493"
MONDAY_BOARD_OPORTUNIDADES="18427678497" MONDAY_BOARD_OPORTUNIDADES="18427678497"
MONDAY_BOARD_APARTADO="18427678515" MONDAY_BOARD_APARTADO="18427678515"
MONDAY_BOARD_VENTAS="18427678520" MONDAY_BOARD_VENTAS="18427678520"
# Tableros creados por api-gateway/scripts/setup-crm.ts (npm run setup:crm)
MONDAY_BOARD_MOTIVOS="18427702505"
MONDAY_BOARD_ACTIVIDADES="18427702517"
# 'false' desactiva la creación de actividades en Monday (default true)
ACTIVIDADES_ENABLED="true"
# Automatizaciones del tablero Leads (api-gateway) # Automatizaciones del tablero Leads (api-gateway)
# IDs de usuario Monday separados por coma para el round-robin; # IDs de usuario Monday separados por coma para el round-robin;

View File

@@ -41,6 +41,8 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
| POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) | | POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) |
| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora | | POST | `/api/planes/:id/notas` | Agrega nota a la bitácora |
| POST | `/api/planes/:id/cancelar` | Cancela el plan | | POST | `/api/planes/:id/cancelar` | Cancela el plan |
| PATCH | `/api/planes/:id/monday` | Liga/desliga el item del tablero Apartado (`{mondayItemId}`; al liquidarse el plan, ese item pasa a `X Escriturar`) |
| POST | `/api/oportunidades/:mondayItemId/perder` | Etapa `Perdido` + dropdown motivo; registra en el tablero Motivos No Compra (`{motivo, comentario?}`) |
| POST | `/api/webhooks/monday` | Handshake `challenge`; `create_item` en Leads dispara round-robin | | POST | `/api/webhooks/monday` | Handshake `challenge`; `create_item` en Leads dispara round-robin |
| GET | `/api/automatizaciones/estado` | Índice RR actual, vendedores, último escaneo | | GET | `/api/automatizaciones/estado` | Índice RR actual, vendedores, último escaneo |
| POST | `/api/automatizaciones/round-robin/run` | Pasada manual de asignación round-robin | | POST | `/api/automatizaciones/round-robin/run` | Pasada manual de asignación round-robin |
@@ -49,7 +51,9 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
## Automatizaciones de Monday (tablero Leads) ## Automatizaciones de Monday (tablero Leads)
- **Round-robin de vendedores**: leads sin persona en la columna people `Vendedor` reciben el siguiente vendedor de la lista rotativa. La lista viene de `MONDAY_RR_USER_IDS` (coma-separados) o, si está vacía, de los usuarios no-guest del workspace (cacheados en BD). El índice se persiste en la tabla `AutomatizacionState` (sobrevive reinicios). - **Round-robin de vendedores**: leads sin persona en la columna people `Vendedor` reciben el siguiente vendedor de la lista rotativa. La lista viene de `MONDAY_RR_USER_IDS` (coma-separados) o, si está vacía, de los usuarios no-guest del workspace (cacheados en BD). El índice se persiste en la tabla `AutomatizacionState` (sobrevive reinicios).
- **Recordatorio de inactividad**: leads con estado no terminal (`DESCARTADO` es el único terminal) y más de `INACTIVIDAD_DIAS` (default 10) sin actualizarse reciben un update en el item. Dedup por día: clave `recordatorio:<itemId>:<fecha>` en `AutomatizacionState`. - **Recordatorio de inactividad**: leads con estado no terminal (`DESCARTADO` y `ABANDONO` son terminales) y más de `INACTIVIDAD_DIAS` (default 10) sin actualizarse reciben un update en el item. Dedup por día: clave `recordatorio:<itemId>:<fecha>` en `AutomatizacionState`.
- **Actividades automáticas** (tablero Actividades): al asignar vendedor se crea "Contactar a {lead}" (tipo Llamada, hoy+1, Abierto) y el recordatorio de inactividad crea "Retomar contacto con {lead}" al vendedor asignado. Se apagan con `ACTIVIDADES_ENABLED=false`; un fallo al crear la actividad no rompe la asignación.
- **Setup CRM** (`npm run setup:crm`): crea/alinea tableros, columnas y labels del vocabulario CRM FRASA (idempotente). **Sync de desarrollos** (`npm run sync:desarrollos`): recalcula Precio Desde/Hasta, M2, entrega y modelos del tablero Desarrollos desde los inventarios reales.
- **Disparadores**: webhook `POST /api/webhooks/monday` (evento `create_item` en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: `POLL_CRON` (default `*/5 * * * *`) para round-robin y `0 9 * * *` para inactividad. Los crons se apagan con `MONDAY_AUTOMATIONS_ENABLED=false`. - **Disparadores**: webhook `POST /api/webhooks/monday` (evento `create_item` en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: `POLL_CRON` (default `*/5 * * * *`) para round-robin y `0 9 * * *` para inactividad. Los crons se apagan con `MONDAY_AUTOMATIONS_ENABLED=false`.
## Notas de arquitectura ## Notas de arquitectura

View File

@@ -26,6 +26,35 @@ La venta se considera realizada cuando se completa: pago de apartado + enganche
El expediente básico completo es requisito para hacer válido el **pago de la comisión** del asesor. El seguimiento a la integración del expediente hasta la escrituración es responsabilidad del asesor de ventas y de gerencia. El expediente básico completo es requisito para hacer válido el **pago de la comisión** del asesor. El seguimiento a la integración del expediente hasta la escrituración es responsabilidad del asesor de ventas y de gerencia.
## Pipeline del apartado en Monday (tablero Apartado, estilo Enkontrol)
El item de Apartado nace en **Pago Apartado** cuando se da de alta al cliente y avanza por estas etapas:
1. **Entrego Documentacion** — el cliente entrega su expediente. En Monday hay checkboxes **CURP**, **RFC** y **Comprobante** que se prenden automáticamente al subir cada documento en la app de altas (el INE no tiene checkbox).
2. **Pago Apartado** — apartado pagado (etapa inicial).
3. **Alta Enkontrol** — cliente dado de alta en el sistema Enkontrol.
4. **Cita Clientes** — cita programada para firma.
5. **Contrato Enkontrol** — contrato generado en Enkontrol.
6. **Firma Cliente (Venta)** — contrato firmado: la venta se concreta.
7. **Cancelacion** — apartado cancelado.
8. **En Definicion** — caso en revisión.
Postventa: cuando el plan de pagos queda **LIQUIDADO** en la app de cobranza, el item pasa automáticamente a **X Escriturar**; al escriturarse se marca **Escriturado**.
## Motivo de no compra (oportunidad perdida)
Cuando una oportunidad se pierde, su etapa pasa a **Perdido** y se registra el motivo (vía `POST /api/oportunidades/:id/perder`) en el tablero **Motivos No Compra**, con una de estas categorías:
- **Precio** — se le hizo caro o no le alcanzó.
- **Producto** — el inmueble no cubre sus necesidades.
- **Ubicacion** — la zona no le convence.
- **Financiamiento** — no le autorizaron el crédito o no le gustaron las condiciones.
- **Competencia** — compró con otro desarrollador.
- **Cliente** — motivos personales del cliente.
- **Timing** — no es el momento de compra.
Cada motivo registrado admite una descripción (comentario del asesor) y puede llevar acción recomendada y responsable, para alimentar decisiones comerciales.
## Después de la venta ## Después de la venta
- Si el esquema incluye mensualidades durante construcción, se genera el plan de pagos y se da seguimiento en la app de cobranza. - Si el esquema incluye mensualidades durante construcción, se genera el plan de pagos y se da seguimiento en la app de cobranza.

View File

@@ -13,6 +13,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest run", "test": "vitest run",
"seed:ai": "tsx scripts/seed-ai.ts", "seed:ai": "tsx scripts/seed-ai.ts",
"setup:crm": "tsx scripts/setup-crm.ts",
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
"prisma:migrate": "prisma migrate dev" "prisma:migrate": "prisma migrate dev"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1 @@
ALTER TABLE "PlanPago" ADD COLUMN "mondayItemId" TEXT;

View File

@@ -80,6 +80,9 @@ model PlanPago {
mensualidad Float mensualidad Float
tasaMoraMensual Float @default(0) tasaMoraMensual Float @default(0)
status String @default("ACTIVO") // ACTIVO | CANCELADO (LIQUIDADO se deriva) status String @default("ACTIVO") // ACTIVO | CANCELADO (LIQUIDADO se deriva)
// Item del tablero Apartado (Monday) ligado a este plan; al liquidarse el
// plan, ese item pasa a 'X Escriturar' (puente cobranza → postventa).
mondayItemId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
notas NotaPlan[] notas NotaPlan[]
pagos Pago[] pagos Pago[]

View File

@@ -0,0 +1,240 @@
/**
* Setup idempotente del workspace Frasa-CAS para alinearlo con el CRM FRASA real:
*
* - Leads: labels 'Intento de contacto' y 'Abandono' en el status Estado.
* - Oportunidades: labels de Etapa del funnel CRM + dropdown 'Motivo No Compra'.
* - Apartado: labels del pipeline Enkontrol + checkboxes CURP / RFC / Comprobante.
* - Contactos: dropdowns del perfil demográfico + date Fecha Nacimiento.
* - Tableros nuevos 'Motivos No Compra' y 'Actividades' (si MONDAY_BOARD_MOTIVOS /
* MONDAY_BOARD_ACTIVIDADES están vacíos en .env). Imprime los IDs creados.
*
* Uso: npm run setup:crm (desde api-gateway)
* Es seguro re-ejecutarlo: solo crea lo que falta.
*/
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const monday = new MondayService()
// ---------- Vocabulario CRM FRASA (normalizado, sin los typos del original) ----------
const ETIQUETAS_LEADS_ESTADO = ['Intento de contacto', 'Abandono']
const ETIQUETAS_ETAPA_OPORTUNIDAD = [
'Afluencia',
'Cotizador',
'Agendo Cita',
'Aparto',
'Ganado',
'Perdido',
'Abandono',
'No Asistio',
]
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
]
const ETIQUETAS_APARTADO_ESTADO = [
'Entrego Documentacion',
'Pago Apartado',
'Alta Enkontrol',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente (Venta)',
'Cancelacion',
'En Definicion',
// Postventa (cobranza): al liquidarse el plan de pagos
'X Escriturar',
'Escriturado',
]
const PERFIL_CONTACTOS: { titulo: string; tipo: string; labels?: string[] }[] = [
{ titulo: 'Sexo', tipo: 'dropdown', labels: ['Hombre', 'Mujer', 'No define'] },
{ titulo: 'Estado Civil', tipo: 'dropdown', labels: ['Soltero', 'Casado', 'Union Libre', 'Divorciado', 'Viudo'] },
{ titulo: 'Rango de Edad', tipo: 'dropdown', labels: ['Menor de 24', 'Entre 24 y 40', 'Mayor de 40'] },
{
titulo: 'Actividad Economica',
tipo: 'dropdown',
labels: ['Empleado', 'Empresario', 'Pensionado', 'Comerciante'],
},
{
titulo: 'Vivienda Actual',
tipo: 'dropdown',
labels: ['Propia', 'Rentada', 'Prestada', 'Comparte con familia'],
},
{ titulo: 'Proposito de Compra', tipo: 'dropdown', labels: ['Habitarla', 'Inversion'] },
{ titulo: 'Fecha Nacimiento', tipo: 'date' },
]
// ---------- Helpers ----------
interface Columna {
id: string
title: string
type: string
settings_str: string
}
async function columnas(boardId: string): Promise<Columna[]> {
const data = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`,
{ ids: [boardId] },
)
return data.boards[0]?.columns ?? []
}
/** Labels actuales de una columna status/dropdown (parsea settings_str) */
function labelsDe(col: Columna): string[] {
try {
const s = JSON.parse(col.settings_str) as { labels?: unknown }
if (Array.isArray(s.labels)) {
// dropdown: [{id, name}]
return s.labels.map((l) => (typeof l === 'object' && l !== null ? String((l as { name?: string }).name) : String(l)))
}
if (s.labels !== null && typeof s.labels === 'object') {
// status: {"0": "Label", ...}
return Object.values(s.labels as Record<string, string>)
}
} catch {
// settings no parseable: sin labels conocidos
}
return []
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, defaults?: unknown): Promise<void> {
const cols = await columnas(boardId)
if (cols.some((c) => c.title === titulo)) {
console.log(` = columna "${titulo}" ya existe (${boardId})`)
return
}
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!, $defaults: JSON) {
create_column(board_id: $boardId, title: $title, column_type: $tipo, defaults: $defaults) { id }
}`,
{ boardId, title: titulo, tipo, ...(defaults !== undefined ? { defaults: JSON.stringify(defaults) } : {}) },
)
console.log(` + columna "${titulo}" (${tipo}) creada en ${boardId}`)
}
/**
* Agrega labels faltantes a una columna status/dropdown usando un item scratch
* con create_labels_if_missing (change_column_metadata no siempre está disponible).
*/
async function asegurarEtiquetas(boardId: string, tituloColumna: string, etiquetas: string[]): Promise<void> {
const cols = await columnas(boardId)
const col = cols.find((c) => c.title === tituloColumna)
if (!col) throw new Error(`Columna "${tituloColumna}" no existe en ${boardId}`)
const actuales = new Set(labelsDe(col).map((l) => l.toLowerCase()))
const faltantes = etiquetas.filter((e) => !actuales.has(e.toLowerCase()))
if (faltantes.length === 0) {
console.log(` = "${tituloColumna}" ya tiene todas las etiquetas (${boardId})`)
return
}
// Item scratch: se crea, se le escriben los labels nuevos y se borra
const creado = await monday.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`,
{ boardId, name: '__scratch_labels__' },
)
const itemId = creado.create_item.id
try {
for (const etiqueta of faltantes) {
const valor = col.type === 'dropdown' ? { labels: [etiqueta] } : { label: etiqueta }
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: JSON.stringify({ [col.id]: valor }) },
)
console.log(` + etiqueta "${etiqueta}" en "${tituloColumna}" (${boardId})`)
}
} finally {
await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId })
}
}
async function crearTablero(nombre: string): Promise<string> {
const workspaces = await monday.graphql<{ workspaces: { id: string; name: string }[] }>(
`query { workspaces { id name } }`,
)
const ws = workspaces.workspaces.find((w) => w.name === config.monday.workspace)
const data = await monday.graphql<{ create_board: { id: string } }>(
`mutation ($name: String!, $kind: BoardKind!, $ws: ID) {
create_board(board_name: $name, board_kind: $kind, workspace_id: $ws) { id }
}`,
{ name: nombre, kind: 'public', ...(ws ? { ws: ws.id } : {}) },
)
return data.create_board.id
}
// ---------- Main ----------
async function main(): Promise<void> {
const b = config.monday.boards
console.log('== Leads: etiquetas de Estado ==')
await asegurarEtiquetas(b.leads, 'Estado', ETIQUETAS_LEADS_ESTADO)
console.log('== Oportunidades: Etapa (columna status "Estado") + Motivo No Compra ==')
await asegurarEtiquetas(b.oportunidades, 'Estado', ETIQUETAS_ETAPA_OPORTUNIDAD)
await asegurarColumna(b.oportunidades, 'Motivo No Compra', 'dropdown')
await asegurarEtiquetas(b.oportunidades, 'Motivo No Compra', MOTIVOS_NO_COMPRA)
console.log('== Apartado: pipeline Enkontrol + checkboxes de documentos ==')
await asegurarEtiquetas(b.apartado, 'Estado', ETIQUETAS_APARTADO_ESTADO)
for (const titulo of ['CURP', 'RFC', 'Comprobante']) {
await asegurarColumna(b.apartado, titulo, 'checkbox')
}
console.log('== Contactos: perfil demográfico ==')
for (const col of PERFIL_CONTACTOS) {
await asegurarColumna(b.contactos, col.titulo, col.tipo)
if (col.labels) await asegurarEtiquetas(b.contactos, col.titulo, col.labels)
}
console.log('== Tablero Motivos No Compra ==')
let motivosId = b.motivos
if (motivosId === '') {
motivosId = await crearTablero('Motivos No Compra')
console.log(` + tablero creado: ${motivosId} → MONDAY_BOARD_MOTIVOS="${motivosId}" en .env`)
} else {
console.log(` = ya configurado: ${motivosId}`)
}
await asegurarColumna(motivosId, 'Categoria', 'status')
await asegurarEtiquetas(motivosId, 'Categoria', MOTIVOS_NO_COMPRA)
await asegurarColumna(motivosId, 'Descripcion', 'long_text')
await asegurarColumna(motivosId, 'Accion Recomendada', 'long_text')
await asegurarColumna(motivosId, 'Responsable', 'people')
console.log('== Tablero Actividades ==')
let actividadesId = b.actividades
if (actividadesId === '') {
actividadesId = await crearTablero('Actividades')
console.log(` + tablero creado: ${actividadesId} → MONDAY_BOARD_ACTIVIDADES="${actividadesId}" en .env`)
} else {
console.log(` = ya configurado: ${actividadesId}`)
}
await asegurarColumna(actividadesId, 'Tipo', 'status')
await asegurarEtiquetas(actividadesId, 'Tipo', ['Llamada', 'Reunion', 'Tarea'])
await asegurarColumna(actividadesId, 'Responsable', 'people')
await asegurarColumna(actividadesId, 'Fecha', 'date')
await asegurarColumna(actividadesId, 'Estado', 'status')
await asegurarEtiquetas(actividadesId, 'Estado', ['Abierto', 'Listo'])
// board_relation NO se puede crear vía API ("column type is not supported yet"):
// la columna "Lead" (relación al tablero Leads) debe crearse a mano en la UI.
// El servicio la usa si existe y la omite si no.
console.log(' ! crea a mano la columna "Lead" (Conectar tableros → Leads) en el tablero Actividades')
console.log('\nListo. Si se crearon tableros nuevos, copia los IDs a .env y .env.example.')
}
main().catch((err) => {
console.error('setup-crm falló:', err)
process.exit(1)
})

View File

@@ -0,0 +1,196 @@
/**
* Sincroniza el tablero Desarrollos (catálogo maestro, estilo CRM FRASA) con
* los inventarios reales:
* - DIAGONAL: apps/cotizador/src/data/unidades.ts
* - ATELIER / ESTANZA / SENDA / SOLANA: reference/cotizadores/*.json
*
* Calcula (solo unidades DISPONIBLE): Precio Desde/Hasta, M2 Construccion
* (rango), Fecha Entrega (la más próxima) y Modelos; y fija el Tipo de
* Inmueble (DIAGONAL/ATELIER = Departamentos; el resto = Casas).
*
* Idempotente: crea las columnas y los items que falten; actualiza valores.
* Uso: npm run sync:desarrollos (desde api-gateway)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const monday = new MondayService()
const TIPO_POR_DESARROLLO: Record<string, string> = {
DIAGONAL: 'Departamentos',
ATELIER: 'Departamentos',
ESTANZA: 'Casas',
SENDA: 'Casas',
SOLANA: 'Casas',
}
interface UnidadInventario {
codigo: string
modelo: string
fechaEntrega: string | null
supConstruccion: number | null
precioSinAcabados: number | null
precioConAcabados: number | null
status: string
extras?: { precioLista?: number | null; precioPreventa?: number | null } & Record<string, unknown>
}
interface StatsDesarrollo {
nombre: string
tipoInmueble: string
precioDesde: number | null
precioHasta: number | null
m2Construccion: string // 'min - max' o ''
fechaEntrega: string | null // la más próxima entre DISPONIBLEs
modelos: string // únicos, separados por coma
disponibles: number
}
/** Precio de referencia: con acabados, si no sin acabados, si no lista/preventa (extras) */
function precioDe(u: UnidadInventario): number | null {
return u.precioConAcabados ?? u.precioSinAcabados ?? u.extras?.precioLista ?? u.extras?.precioPreventa ?? null
}
export function calcularStats(nombre: string, unidades: UnidadInventario[]): StatsDesarrollo {
const disponibles = unidades.filter((u) => u.status.toUpperCase() === 'DISPONIBLE')
const precios = disponibles.map(precioDe).filter((p): p is number => p !== null && p > 0)
const m2 = disponibles.map((u) => u.supConstruccion).filter((s): s is number => s !== null && s > 0)
const fechas = disponibles
.map((u) => u.fechaEntrega)
.filter((f): f is string => f !== null && f !== '')
.sort()
const modelos = [...new Set(disponibles.map((u) => u.modelo).filter((m) => m !== ''))].sort()
const redondeo = (n: number): number => Math.round(n * 100) / 100
return {
nombre,
tipoInmueble: TIPO_POR_DESARROLLO[nombre] ?? 'Casas',
precioDesde: precios.length > 0 ? Math.min(...precios) : null,
precioHasta: precios.length > 0 ? Math.max(...precios) : null,
m2Construccion: m2.length > 0 ? `${redondeo(Math.min(...m2))} - ${redondeo(Math.max(...m2))}` : '',
fechaEntrega: fechas[0] ?? null,
modelos: modelos.join(', '),
disponibles: disponibles.length,
}
}
async function cargarInventarios(): Promise<Map<string, UnidadInventario[]>> {
const mapa = new Map<string, UnidadInventario[]>()
// DIAGONAL vive en el seed del cotizador (TS)
const mod = (await import(
pathToFileURL2('../../apps/cotizador/src/data/unidades.ts')
)) as { UNIDADES_SEED: UnidadInventario[] }
mapa.set('DIAGONAL', mod.UNIDADES_SEED)
for (const nombre of ['ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']) {
const crudo = await readFile(path.join(RAIZ, `reference/cotizadores/${nombre}.json`), 'utf8')
const json = JSON.parse(crudo) as { unidades: UnidadInventario[] }
mapa.set(nombre, json.unidades)
}
return mapa
}
/** Ruta file:// absoluta para import dinámico de TS fuera del paquete */
function pathToFileURL2(relDesdeScript: string): string {
const abs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), relDesdeScript)
return `file://${abs}`
}
// ---------- Monday ----------
interface Columna {
id: string
title: string
type: string
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, existentes: Columna[]): Promise<void> {
if (existentes.some((c) => c.title === titulo)) return
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) {
create_column(board_id: $boardId, title: $title, column_type: $tipo) { id }
}`,
{ boardId, title: titulo, tipo },
)
console.log(` + columna "${titulo}" (${tipo})`)
}
async function main(): Promise<void> {
const boardId = config.monday.boards.desarrollos
const colsData = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = colsData.boards[0]?.columns ?? []
await asegurarColumna(boardId, 'Precio Desde', 'numbers', columnas)
await asegurarColumna(boardId, 'Precio Hasta', 'numbers', columnas)
await asegurarColumna(boardId, 'M2 Construccion', 'text', columnas)
await asegurarColumna(boardId, 'Tipo de Inmueble', 'dropdown', columnas)
await asegurarColumna(boardId, 'Fecha Entrega', 'date', columnas)
await asegurarColumna(boardId, 'Modelos', 'text', columnas)
// Recargar columnas tras posibles creaciones y mapear por título
const colsFrescas = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const porTitulo = new Map((colsFrescas.boards[0]?.columns ?? []).map((c) => [c.title, c]))
const itemsData = await monday.graphql<{ boards: { items_page: { items: { id: string; name: string }[] } }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 100) { items { id name } } } }`,
{ ids: [boardId] },
)
const itemsPorNombre = new Map(
(itemsData.boards[0]?.items_page.items ?? []).map((i) => [i.name.trim().toUpperCase(), i.id]),
)
const inventarios = await cargarInventarios()
for (const [nombre, unidades] of inventarios) {
const stats = calcularStats(nombre, unidades)
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown): void => {
const col = porTitulo.get(titulo)
if (col) valores[col.id] = valor
}
if (stats.precioDesde !== null) poner('Precio Desde', stats.precioDesde)
if (stats.precioHasta !== null) poner('Precio Hasta', stats.precioHasta)
if (stats.m2Construccion !== '') poner('M2 Construccion', stats.m2Construccion)
poner('Tipo de Inmueble', { labels: [stats.tipoInmueble] })
if (stats.fechaEntrega !== null) poner('Fecha Entrega', { date: stats.fechaEntrega })
if (stats.modelos !== '') poner('Modelos', stats.modelos)
const columnValues = JSON.stringify(valores)
const itemId = itemsPorNombre.get(nombre)
if (itemId === undefined) {
await monday.graphql(
`mutation ($boardId: ID!, $name: String!, $values: JSON!) {
create_item(board_id: $boardId, item_name: $name, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, name: nombre, values: columnValues },
)
console.log(`+ ${nombre}: item creado (${stats.disponibles} disponibles)`)
} else {
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: columnValues },
)
console.log(`= ${nombre}: actualizado (${stats.disponibles} disponibles)`)
}
console.log(
` ${stats.tipoInmueble} | $${stats.precioDesde ?? '-'} - $${stats.precioHasta ?? '-'} | ` +
`m2 ${stats.m2Construccion || '-'} | entrega ${stats.fechaEntrega ?? '-'} | modelos: ${stats.modelos || '-'}`,
)
}
console.log('\nSincronización de Desarrollos completa.')
}
main().catch((err) => {
console.error('sync-desarrollos falló:', err)
process.exit(1)
})

View File

@@ -19,6 +19,7 @@ export const config = {
apiKey: env('MONDAY_API_KEY'), apiKey: env('MONDAY_API_KEY'),
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'), apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
apiVersion: '2024-10', apiVersion: '2024-10',
workspace: env('MONDAY_WORKSPACE', ''),
boards: { boards: {
contactos: env('MONDAY_BOARD_CONTACTOS'), contactos: env('MONDAY_BOARD_CONTACTOS'),
desarrollos: env('MONDAY_BOARD_DESARROLLOS'), desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
@@ -27,11 +28,16 @@ export const config = {
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'), oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
apartado: env('MONDAY_BOARD_APARTADO'), apartado: env('MONDAY_BOARD_APARTADO'),
ventas: env('MONDAY_BOARD_VENTAS'), ventas: env('MONDAY_BOARD_VENTAS'),
// Tableros creados por scripts/setup-crm.ts; '' = aún no configurado
motivos: env('MONDAY_BOARD_MOTIVOS', ''),
actividades: env('MONDAY_BOARD_ACTIVIDADES', ''),
}, },
}, },
automatizaciones: { automatizaciones: {
// 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos // 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos
enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false', enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false',
// 'false' desactiva la creación de actividades en Monday (default true; false en tests)
actividadesEnabled: env('ACTIVIDADES_ENABLED', 'true') !== 'false',
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace // IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
rrUserIds: env('MONDAY_RR_USER_IDS', ''), rrUserIds: env('MONDAY_RR_USER_IDS', ''),
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')), inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
actividadEconomicaLabel,
checkboxParaTipoDocumento,
perfilDemografico,
rangoEdadLabel,
} from './clientes.controller.js'
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
it('mapea los tipos con checkbox al título de la columna', () => {
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('COMPROBANTE')).toBe('Comprobante')
expect(checkboxParaTipoDocumento('COMPROBANTE_DOMICILIO')).toBe('Comprobante')
})
it('normaliza mayúsculas y espacios', () => {
expect(checkboxParaTipoDocumento(' curp ')).toBe('CURP')
expect(checkboxParaTipoDocumento('comprobante_domicilio')).toBe('Comprobante')
})
it('INE y otros tipos no tienen checkbox (null)', () => {
expect(checkboxParaTipoDocumento('INE')).toBeNull()
expect(checkboxParaTipoDocumento('ACTA_NACIMIENTO')).toBeNull()
expect(checkboxParaTipoDocumento('OTRO')).toBeNull()
})
})
describe('rangoEdadLabel', () => {
const hoy = new Date('2026-08-22T12:00:00')
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
})
it('devuelve undefined con fechas inválidas o ausentes', () => {
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
})
})
describe('perfilDemografico (alta → Contacto Monday)', () => {
it('mapea los valores del formato a los labels del CRM', () => {
const perfil = perfilDemografico({
estadoCivil: 'UNION_LIBRE',
tipoVivienda: 'FAMILIAR',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-05-10',
})
expect(perfil.estadoCivilLabel).toBe('Union Libre')
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
expect(perfil.propositoCompraLabel).toBe('Habitarla')
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
expect(perfil.fechaNacimiento).toBe('1990-05-10')
})
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
expect(perfilDemografico({})).toEqual({})
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
})
})
describe('actividadEconomicaLabel', () => {
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
})
})

View File

@@ -21,8 +21,14 @@ const crearClienteSchema = z
email: z.string().optional(), email: z.string().optional(),
celular: z.string().optional(), celular: z.string().optional(),
direccion: z.string().optional(), direccion: z.string().optional(),
// Perfil demográfico (opcionales; se replican al Contacto en Monday)
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
tipoCompra: z.string().optional(),
}) })
.passthrough(), .passthrough(),
empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(),
viviendaCredito: z viviendaCredito: z
.object({ .object({
proyecto: z.string().min(1), proyecto: z.string().min(1),
@@ -35,6 +41,106 @@ const crearClienteSchema = z
}) })
.passthrough() // el resto del payload pasa tal cual a payloadJson .passthrough() // el resto del payload pasa tal cual a payloadJson
// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ----------
const ESTADO_CIVIL_LABEL: Record<string, string> = {
SOLTERO: 'Soltero',
CASADO: 'Casado',
UNION_LIBRE: 'Union Libre',
DIVORCIADO: 'Divorciado',
VIUDO: 'Viudo',
}
const VIVIENDA_ACTUAL_LABEL: Record<string, string> = {
PRESTADA: 'Prestada',
RENTADA: 'Rentada',
FAMILIAR: 'Comparte con familia',
PROPIA: 'Propia',
}
const PROPOSITO_COMPRA_LABEL: Record<string, string> = {
INVERSION: 'Inversion',
VIVIR_AHI: 'Habitarla',
}
// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un
// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina.
const ACTIVIDAD_ECONOMICA_LABEL: Record<string, string> = {
ASALARIADO: 'Empleado',
}
/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */
export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined {
if (!fechaNacimiento) return undefined
const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`)
if (Number.isNaN(nacimiento.getTime())) return undefined
let edad = hoy.getFullYear() - nacimiento.getFullYear()
const aunNoCumple =
hoy.getMonth() < nacimiento.getMonth() ||
(hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate())
if (aunNoCumple) edad--
if (edad < 0 || edad > 120) return undefined
if (edad < 24) return 'Menor de 24'
if (edad <= 40) return 'Entre 24 y 40'
return 'Mayor de 40'
}
/** Labels demográficos del Contacto a partir del payload del formato de alta */
export function perfilDemografico(dg: {
estadoCivil?: string | undefined
tipoVivienda?: string | undefined
tipoCompra?: string | undefined
fechaNacimiento?: string | undefined
}): {
estadoCivilLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
rangoEdadLabel?: string | undefined
fechaNacimiento?: string | undefined
} {
const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase()
const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '')
? dg.fechaNacimiento!.slice(0, 10)
: undefined
return {
...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined
? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] }
: {}),
...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined
? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] }
: {}),
...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined
? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] }
: {}),
...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}),
...(fecha !== undefined ? { fechaNacimiento: fecha } : {}),
}
}
/** Label de "Actividad Economica" derivado del tipo de empleo del formato */
export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined {
return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()]
}
/**
* Checkbox del tablero Apartado que corresponde a un tipo de documento.
* INE y el resto no tienen checkbox (devuelve null).
*/
export function checkboxParaTipoDocumento(tipo: string): string | null {
switch (tipo.trim().toUpperCase()) {
case 'CURP':
return 'CURP'
case 'RFC':
case 'CONSTANCIA_RFC':
return 'RFC'
case 'COMPROBANTE':
case 'COMPROBANTE_DOMICILIO':
return 'Comprobante'
default:
return null
}
}
const aNumero = (v: string | number | undefined): number => { const aNumero = (v: string | number | undefined): number => {
if (v === undefined) return 0 if (v === undefined) return 0
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, '')) const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
@@ -81,6 +187,11 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
curp: dg.curp, curp: dg.curp,
rfc: dg.rfc, rfc: dg.rfc,
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}), ...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
// Perfil demográfico: solo lo que el formato de alta captura.
// Sin fuente en el formulario: Sexo (no se pregunta) y Actividad
// Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo).
...perfilDemografico(dg),
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
}) })
const valorVenta = aNumero(vc.valorVenta) const valorVenta = aNumero(vc.valorVenta)
@@ -92,7 +203,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
...(enganche > 0 ? { montoApartado: enganche } : {}), ...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0), fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15), fechaLimite: hoyMas(15),
estadoLabel: 'VIGENTE', estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
}) })
} }
@@ -175,6 +286,23 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
}), }),
), ),
) )
// Prende el checkbox del documento en el item de Apartado del cliente
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
}
}
res.status(201).json(documentos) res.status(201).json(documentos)
} }

View File

@@ -64,7 +64,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
descuentoPct: propuesta.descuento * 100, descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal, precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(), fechaCotizacion: hoyISO(),
estadoLabel: 'COTIZADA', estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}), ...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}), ...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}) })

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import { perderOportunidad } from './oportunidades.controller.js'
/** Monday mockeado: registra los cambios de columna y los motivos creados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async obtenerNombreItem(_itemId: string) {
return 'Juan Pérez'
},
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
this.motivos.push(datos)
return 'motivo-item-1'
},
}
}
describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro')
expect(motivoItemId).toBe('motivo-item-1')
// Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test)
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' },
])
// Item en el catálogo: nombre = motivo + item de origen
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
})
it('el comentario es opcional', async () => {
const monday = mondayMock()
await perderOportunidad(monday, 'opp-9', 'Competencia')
expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' })
})
it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído')
// No se registró motivo si falló el cambio de etapa
expect(monday.motivos).toEqual([])
})
})

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 })
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { notificarEscrituracionSiLiquidado } from './planes.controller.js'
/** Monday mockeado: registra cambios de columna */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
}
}
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
const monday = mondayMock()
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(true)
// board 666 = MONDAY_BOARD_APARTADO de test
expect(monday.cambios).toEqual([
{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' },
])
})
it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => {
const monday = mondayMock()
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false)
expect(monday.cambios).toEqual([])
})
it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(false)
expect(spyError).toHaveBeenCalled()
spyError.mockRestore()
})
})

View File

@@ -19,6 +19,8 @@ import {
type PlanPago as PlanPagoDominio, type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js' } from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js' import { BadRequestError, NotFoundError } from '../utils/errors.js'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client' import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- // ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
@@ -48,6 +50,7 @@ function aDominio(plan: PlanCompleto): PlanPagoDominio {
mensualidad: plan.mensualidad, mensualidad: plan.mensualidad,
tasaMoraMensual: plan.tasaMoraMensual, tasaMoraMensual: plan.tasaMoraMensual,
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO', status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
mondayItemId: plan.mondayItemId,
createdAt: plan.createdAt.toISOString(), createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos pagos: plan.pagos
.slice() .slice()
@@ -105,6 +108,8 @@ const crearPlanSchema = z.object({
engancheTotal: z.number().positive(), engancheTotal: z.number().positive(),
numPagos: z.number().int().min(1), numPagos: z.number().int().min(1),
tasaMoraMensual: z.number().min(0).optional(), tasaMoraMensual: z.number().min(0).optional(),
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
mondayItemId: z.string().min(1).optional(),
}) })
/** POST /api/planes — genera el calendario de pagos y lo persiste */ /** POST /api/planes — genera el calendario de pagos y lo persiste */
@@ -129,6 +134,7 @@ export async function crearPlan(req: Request, res: Response): Promise<void> {
numPagos: body.numPagos, numPagos: body.numPagos,
mensualidad: calendario.mensualidad, mensualidad: calendario.mensualidad,
tasaMoraMensual: body.tasaMoraMensual ?? 0, tasaMoraMensual: body.tasaMoraMensual ?? 0,
mondayItemId: body.mondayItemId ?? null,
pagos: { pagos: {
create: calendario.pagos.map((p) => ({ create: calendario.pagos.map((p) => ({
numPago: p.numPago, numPago: p.numPago,
@@ -176,6 +182,29 @@ const abonoSchema = z.object({
referencia: z.string().min(1), referencia: z.string().min(1),
}) })
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
/**
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
*/
export async function notificarEscrituracionSiLiquidado(
plan: { mondayItemId?: string | null },
liquidado: boolean,
monday: MondayApartado = mondayService,
): Promise<boolean> {
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
try {
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
return true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
return false
}
}
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */ /** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> { export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
const body = abonoSchema.parse(req.body) const body = abonoSchema.parse(req.body)
@@ -206,7 +235,24 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
]) ])
const actualizado = await cargarPlan(plan.id) const actualizado = await cargarPlan(plan.id)
res.status(201).json(conCalculados(aDominio(actualizado))) const dominioActualizado = aDominio(actualizado)
const calculado = conCalculados(dominioActualizado)
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
res.status(201).json(calculado)
}
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
const body = ligarMondaySchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
} }
const notaSchema = z.object({ texto: z.string() }) const notaSchema = z.object({ texto: z.string() })

View File

@@ -18,10 +18,12 @@ import {
cancelarPlan, cancelarPlan,
cobranzaHoy, cobranzaHoy,
crearPlan, crearPlan,
ligarMondayItem,
listarPlanes, listarPlanes,
obtenerPlan, obtenerPlan,
registrarAbonoEndpoint, registrarAbonoEndpoint,
} from '../controllers/planes.controller.js' } from '../controllers/planes.controller.js'
import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js' import { webhookMonday } from '../controllers/webhooks.controller.js'
import { import {
correrRoundRobin, correrRoundRobin,
@@ -60,6 +62,9 @@ apiRouter.get('/planes/:id', obtenerPlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint) apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota) apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan) apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.post('/webhooks/monday', webhookMonday) apiRouter.post('/webhooks/monday', webhookMonday)

View File

@@ -43,11 +43,19 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
} }
} }
/** Monday mockeado: leads fijos, registra asignaciones y updates */ /** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[]) { function mondayMock(leads: LeadMonday[]) {
return { return {
asignaciones: [] as { itemId: string; userId: number }[], asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[], updates: [] as { itemId: string; body: string }[],
actividades: [] as {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}[],
async getLeads() { async getLeads() {
return leads return leads
}, },
@@ -58,6 +66,17 @@ function mondayMock(leads: LeadMonday[]) {
this.updates.push({ itemId, body }) this.updates.push({ itemId, body })
return 'upd-1' return 'upd-1'
}, },
async crearActividad(datos: {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}) {
this.actividades.push(datos)
return 'act-1'
},
async getUsuariosWorkspace() { async getUsuariosWorkspace() {
return VENDEDORES return VENDEDORES
}, },
@@ -238,3 +257,85 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
expect(monday.updates).toHaveLength(2) expect(monday.updates).toHaveLength(2)
}) })
}) })
// ---------- Actividades automáticas (tablero Actividades del CRM) ----------
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => {
const monday = mondayMock([lead('1'), lead('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead 1',
tipoLabel: 'Llamada',
responsableId: 101,
estadoLabel: 'Abierto',
leadItemId: '1',
})
// Fecha compromiso: hoy + 1 día (fecha local, como la construye el servicio)
const esperada = new Date()
esperada.setDate(esperada.getDate() + 1)
const m = String(esperada.getMonth() + 1).padStart(2, '0')
const d = String(esperada.getDate()).padStart(2, '0')
expect(monday.actividades[0]?.fecha).toBe(`${esperada.getFullYear()}-${m}-${d}`)
expect(monday.actividades[1]?.responsableId).toBe(102)
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([lead('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
const monday = mondayMock([
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
expect(r.recordatoriosEnviados).toBe(2)
expect(monday.actividades).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Lead 1',
tipoLabel: 'Llamada',
responsableId: 103,
estadoLabel: 'Abierto',
leadItemId: '1',
})
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [101] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([])
})
it('un fallo al crear la actividad no rompe la asignación', async () => {
const monday = mondayMock([lead('1')])
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1) // la asignación se hizo igual
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
spyError.mockRestore()
})
})
describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
it('ABANDONO también es terminal', () => {
expect(esEstadoTerminal('ABANDONO')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})

View File

@@ -13,9 +13,10 @@ import { prisma } from './prisma.js'
* recordatorios el mismo día. * recordatorios el mismo día.
*/ */
// Labels reales del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO. // Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO
// Solo DESCARTADO es terminal (los demás siguen en seguimiento). // más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado,
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO'] as const // Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
const CLAVE_RR_INDICE = 'rr:indice' const CLAVE_RR_INDICE = 'rr:indice'
const CLAVE_RR_VENDEDORES = 'rr:vendedores' const CLAVE_RR_VENDEDORES = 'rr:vendedores'
@@ -85,9 +86,14 @@ export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
type MondayLeads = Pick< type MondayLeads = Pick<
MondayService, MondayService,
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' 'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' | 'crearActividad'
> >
export interface OpcionesAutomatizaciones {
/** Crear actividades en el tablero Actividades al asignar/recordar (default: config) */
actividadesEnabled?: boolean
}
export interface AsignacionRR { export interface AsignacionRR {
itemId: string itemId: string
lead: string lead: string
@@ -117,10 +123,48 @@ export interface ResultadoInactividad {
} }
export class AutomatizacionesService { export class AutomatizacionesService {
private readonly actividadesEnabled: boolean
constructor( constructor(
private readonly monday: MondayLeads, private readonly monday: MondayLeads,
private readonly state: StateStore, private readonly state: StateStore,
) {} opciones: OpcionesAutomatizaciones = {},
) {
this.actividadesEnabled = opciones.actividadesEnabled ?? config.automatizaciones.actividadesEnabled
}
/** 'YYYY-MM-DD' de hoy + N días (fecha compromiso de la actividad) */
private static fechaHoyMas(dias: number): string {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/**
* Crea una actividad en Monday sin interrumpir la automatización:
* un fallo aquí solo se loguea (la asignación/recordatorio ya se hizo).
*/
private async crearActividadSegura(datos: {
nombre: string
responsableId: number
leadItemId: string
}): Promise<void> {
try {
await this.monday.crearActividad({
nombre: datos.nombre,
tipoLabel: 'Llamada',
responsableId: datos.responsableId,
fecha: AutomatizacionesService.fechaHoyMas(1),
estadoLabel: 'Abierto',
leadItemId: datos.leadItemId,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la actividad "${datos.nombre}":`, err)
}
}
/** /**
* Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida; * Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida;
@@ -167,6 +211,13 @@ export class AutomatizacionesService {
const vendedor = vendedorEnTurno(vendedores, indice) const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
await this.monday.asignarVendedorALead(lead.id, vendedor.id) await this.monday.asignarVendedorALead(lead.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${lead.nombre}`,
responsableId: vendedor.id,
leadItemId: lead.id,
})
}
detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre }) detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
indice++ indice++
} }
@@ -208,6 +259,15 @@ export class AutomatizacionesService {
await this.monday.crearUpdateEnItem(lead.id, body) await this.monday.crearUpdateEnItem(lead.id, body)
await this.state.set(clave, ahora.toISOString()) await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++ recordatoriosEnviados++
// Además del update, deja una actividad al vendedor asignado (si tiene)
const vendedorId = lead.vendedorIds[0]
if (this.actividadesEnabled && vendedorId !== undefined) {
await this.crearActividadSegura({
nombre: `Retomar contacto con ${lead.nombre}`,
responsableId: vendedorId,
leadItemId: lead.id,
})
}
} }
if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString()) if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString())

View File

@@ -44,6 +44,8 @@ export interface PlanPago {
mensualidad: number mensualidad: number
tasaMoraMensual?: number tasaMoraMensual?: number
status: StatusPlan status: StatusPlan
/** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */
mondayItemId?: string | null
pagos: Pago[] pagos: Pago[]
notas: NotaPlan[] notas: NotaPlan[]
createdAt: string // ISO createdAt: string // ISO

View File

@@ -279,10 +279,12 @@ export class MondayService {
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca * column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query). * template literals dentro del query).
*/ */
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown { private valorPorTipo(col: ColumnaMeta, crudo: string | number | boolean): unknown {
switch (col.type) { switch (col.type) {
case 'status': case 'status':
return { label: String(crudo) } return { label: String(crudo) }
case 'dropdown':
return { labels: [String(crudo)] }
case 'date': case 'date':
return { date: String(crudo) } return { date: String(crudo) }
case 'numbers': case 'numbers':
@@ -291,6 +293,14 @@ export class MondayService {
return { email: String(crudo), text: String(crudo) } return { email: String(crudo), text: String(crudo) }
case 'phone': case 'phone':
return { phone: String(crudo), countryShortName: 'MX' } return { phone: String(crudo), countryShortName: '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: default:
return String(crudo) return String(crudo)
} }
@@ -298,7 +308,7 @@ export class MondayService {
private async construirColumnValues( private async construirColumnValues(
boardId: string, boardId: string,
valores: Record<string, string | number | undefined>, valores: Record<string, string | number | boolean | undefined>,
): Promise<string> { ): Promise<string> {
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) { for (const [titulo, crudo] of Object.entries(valores)) {
@@ -346,6 +356,14 @@ export class MondayService {
RFC: datos.rfc, RFC: datos.rfc,
Dirección: datos.direccion, Dirección: datos.direccion,
Estado: datos.estadoLabel, 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) return this.crearItem(boardId, datos.nombre, columnValues)
} }
@@ -378,6 +396,74 @@ export class MondayService {
this.invalidarCacheUnidades() this.invalidarCacheUnidades()
} }
// ---------- 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)
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, valor)) },
)
}
/** 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>") */
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,
Descripcion: datos.descripcion,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/** 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) ---------- // ---------- Leads (automatizaciones) ----------
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */ /** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
@@ -510,6 +596,14 @@ export interface DatosContacto {
rfc?: string rfc?: string
direccion?: string direccion?: string
estadoLabel: string // p. ej. 'CLIENTE' 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 { export interface DatosApartado {
@@ -518,7 +612,22 @@ export interface DatosApartado {
montoApartado?: number montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD' fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD' fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE' estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA
descripcion?: string
}
export interface DatosActividad {
nombre: string
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
responsableId: number // user ID de Monday
fecha: string // 'YYYY-MM-DD'
estadoLabel: string // 'Abierto' | 'Listo'
leadItemId: string // item del tablero Leads relacionado
} }
/** Singleton usado por los controllers (los tests instancian el suyo) */ /** Singleton usado por los controllers (los tests instancian el suyo) */

View File

@@ -12,6 +12,10 @@ export default defineConfig({
MONDAY_BOARD_OPORTUNIDADES: '555', MONDAY_BOARD_OPORTUNIDADES: '555',
MONDAY_BOARD_APARTADO: '666', MONDAY_BOARD_APARTADO: '666',
MONDAY_BOARD_VENTAS: '777', MONDAY_BOARD_VENTAS: '777',
MONDAY_BOARD_MOTIVOS: '888',
MONDAY_BOARD_ACTIVIDADES: '999',
// Las automatizaciones no deben crear actividades salvo que el test lo pida
ACTIVIDADES_ENABLED: 'false',
}, },
}, },
}) })