From f0f923b2effd4167e405f0d7f143e580ab7146d3 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 22 Aug 2026 07:18:48 +0000 Subject: [PATCH] feat(monday): alinear Frasa-CAS con CRM real del cliente (7 mejoras) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 5 + api-gateway/README.md | 6 +- .../knowledge/proceso-venta-apartado.md | 29 +++ api-gateway/package.json | 2 + .../migration.sql | 1 + api-gateway/prisma/schema.prisma | 3 + api-gateway/scripts/setup-crm.ts | 240 ++++++++++++++++++ api-gateway/scripts/sync-desarrollos.ts | 196 ++++++++++++++ api-gateway/src/config/index.ts | 6 + .../controllers/clientes.controller.test.ts | 75 ++++++ .../src/controllers/clientes.controller.ts | 130 +++++++++- .../controllers/cotizaciones.controller.ts | 2 +- .../oportunidades.controller.test.ts | 52 ++++ .../controllers/oportunidades.controller.ts | 55 ++++ .../src/controllers/planes.controller.test.ts | 42 +++ .../src/controllers/planes.controller.ts | 48 +++- api-gateway/src/routes/index.ts | 5 + .../services/automatizaciones.service.test.ts | 103 +++++++- .../src/services/automatizaciones.service.ts | 70 ++++- api-gateway/src/services/cobranza.service.ts | 2 + api-gateway/src/services/monday.service.ts | 115 ++++++++- api-gateway/vitest.config.ts | 4 + 22 files changed, 1178 insertions(+), 13 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260822070000_plan_monday_item/migration.sql create mode 100644 api-gateway/scripts/setup-crm.ts create mode 100644 api-gateway/scripts/sync-desarrollos.ts create mode 100644 api-gateway/src/controllers/clientes.controller.test.ts create mode 100644 api-gateway/src/controllers/oportunidades.controller.test.ts create mode 100644 api-gateway/src/controllers/oportunidades.controller.ts create mode 100644 api-gateway/src/controllers/planes.controller.test.ts diff --git a/.env.example b/.env.example index bbdd165..43c37f5 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ MONDAY_BOARD_LEADS="18427678493" MONDAY_BOARD_OPORTUNIDADES="18427678497" MONDAY_BOARD_APARTADO="18427678515" 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) # IDs de usuario Monday separados por coma para el round-robin; diff --git a/api-gateway/README.md b/api-gateway/README.md index eedead6..c591abf 100644 --- a/api-gateway/README.md +++ b/api-gateway/README.md @@ -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/notas` | Agrega nota a la bitácora | | 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 | | GET | `/api/automatizaciones/estado` | Índice RR actual, vendedores, último escaneo | | 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) - **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::` 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::` 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`. ## Notas de arquitectura diff --git a/api-gateway/knowledge/proceso-venta-apartado.md b/api-gateway/knowledge/proceso-venta-apartado.md index 5104d22..73f338b 100644 --- a/api-gateway/knowledge/proceso-venta-apartado.md +++ b/api-gateway/knowledge/proceso-venta-apartado.md @@ -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. +## 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 - Si el esquema incluye mensualidades durante construcción, se genera el plan de pagos y se da seguimiento en la app de cobranza. diff --git a/api-gateway/package.json b/api-gateway/package.json index ca3b4cf..4a63ebc 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -13,6 +13,8 @@ "start": "node dist/index.js", "test": "vitest run", "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" }, "dependencies": { diff --git a/api-gateway/prisma/migrations/20260822070000_plan_monday_item/migration.sql b/api-gateway/prisma/migrations/20260822070000_plan_monday_item/migration.sql new file mode 100644 index 0000000..e91a851 --- /dev/null +++ b/api-gateway/prisma/migrations/20260822070000_plan_monday_item/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "PlanPago" ADD COLUMN "mondayItemId" TEXT; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 749fd4e..b8a93a9 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -80,6 +80,9 @@ model PlanPago { mensualidad Float tasaMoraMensual Float @default(0) 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()) notas NotaPlan[] pagos Pago[] diff --git a/api-gateway/scripts/setup-crm.ts b/api-gateway/scripts/setup-crm.ts new file mode 100644 index 0000000..5137f04 --- /dev/null +++ b/api-gateway/scripts/setup-crm.ts @@ -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 { + 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) + } + } catch { + // settings no parseable: sin labels conocidos + } + return [] +} + +async function asegurarColumna(boardId: string, titulo: string, tipo: string, defaults?: unknown): Promise { + 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 { + 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 { + 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 { + 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) +}) diff --git a/api-gateway/scripts/sync-desarrollos.ts b/api-gateway/scripts/sync-desarrollos.ts new file mode 100644 index 0000000..f1556dc --- /dev/null +++ b/api-gateway/scripts/sync-desarrollos.ts @@ -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 = { + 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 +} + +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> { + const mapa = new Map() + // 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 { + 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 { + 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 = {} + 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) +}) diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 0f92396..a38b358 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -19,6 +19,7 @@ export const config = { apiKey: env('MONDAY_API_KEY'), apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'), apiVersion: '2024-10', + workspace: env('MONDAY_WORKSPACE', ''), boards: { contactos: env('MONDAY_BOARD_CONTACTOS'), desarrollos: env('MONDAY_BOARD_DESARROLLOS'), @@ -27,11 +28,16 @@ export const config = { oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'), apartado: env('MONDAY_BOARD_APARTADO'), 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: { // 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos 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 rrUserIds: env('MONDAY_RR_USER_IDS', ''), inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')), diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts new file mode 100644 index 0000000..cba8cd1 --- /dev/null +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -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() + }) +}) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 8b1ffbd..7df7e0b 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -21,8 +21,14 @@ const crearClienteSchema = z email: z.string().optional(), celular: 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(), + empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(), viviendaCredito: z .object({ proyecto: z.string().min(1), @@ -35,6 +41,106 @@ const crearClienteSchema = z }) .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 = { + SOLTERO: 'Soltero', + CASADO: 'Casado', + UNION_LIBRE: 'Union Libre', + DIVORCIADO: 'Divorciado', + VIUDO: 'Viudo', +} + +const VIVIENDA_ACTUAL_LABEL: Record = { + PRESTADA: 'Prestada', + RENTADA: 'Rentada', + FAMILIAR: 'Comparte con familia', + PROPIA: 'Propia', +} + +const PROPOSITO_COMPRA_LABEL: Record = { + 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 = { + 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 => { if (v === undefined) return 0 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 { curp: dg.curp, rfc: dg.rfc, ...(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) @@ -92,7 +203,7 @@ export async function crearCliente(req: Request, res: Response): Promise { ...(enganche > 0 ? { montoApartado: enganche } : {}), fechaApartado: hoyMas(0), 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 { + 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([]) + }) +}) diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts new file mode 100644 index 0000000..ad6ecb4 --- /dev/null +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -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 { + 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 { + 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 }) +} diff --git a/api-gateway/src/controllers/planes.controller.test.ts b/api-gateway/src/controllers/planes.controller.test.ts new file mode 100644 index 0000000..0fdc3b8 --- /dev/null +++ b/api-gateway/src/controllers/planes.controller.test.ts @@ -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() + }) +}) diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index 0e1ce1a..5283874 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -19,6 +19,8 @@ import { type PlanPago as PlanPagoDominio, } from '../services/cobranza.service.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' // ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- @@ -48,6 +50,7 @@ function aDominio(plan: PlanCompleto): PlanPagoDominio { mensualidad: plan.mensualidad, tasaMoraMensual: plan.tasaMoraMensual, status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO', + mondayItemId: plan.mondayItemId, createdAt: plan.createdAt.toISOString(), pagos: plan.pagos .slice() @@ -105,6 +108,8 @@ const crearPlanSchema = z.object({ engancheTotal: z.number().positive(), numPagos: z.number().int().min(1), 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 */ @@ -129,6 +134,7 @@ export async function crearPlan(req: Request, res: Response): Promise { numPagos: body.numPagos, mensualidad: calendario.mensualidad, tasaMoraMensual: body.tasaMoraMensual ?? 0, + mondayItemId: body.mondayItemId ?? null, pagos: { create: calendario.pagos.map((p) => ({ numPago: p.numPago, @@ -176,6 +182,29 @@ const abonoSchema = z.object({ referencia: z.string().min(1), }) +type MondayApartado = Pick + +/** + * 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 { + 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) */ export async function registrarAbonoEndpoint(req: Request, res: Response): Promise { 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) - 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 { + 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() }) diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 156ed23..d000339 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -18,10 +18,12 @@ import { cancelarPlan, cobranzaHoy, crearPlan, + ligarMondayItem, listarPlanes, obtenerPlan, registrarAbonoEndpoint, } from '../controllers/planes.controller.js' +import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js' import { webhookMonday } from '../controllers/webhooks.controller.js' import { correrRoundRobin, @@ -60,6 +62,9 @@ apiRouter.get('/planes/:id', obtenerPlan) apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint) apiRouter.post('/planes/:id/notas', agregarNota) apiRouter.post('/planes/:id/cancelar', cancelarPlan) +apiRouter.patch('/planes/:id/monday', ligarMondayItem) + +apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint) apiRouter.post('/webhooks/monday', webhookMonday) diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts index c57ff3d..e4d8d6f 100644 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -43,11 +43,19 @@ function stateEnMemoria(): StateStore & { mapa: Map } { } } -/** Monday mockeado: leads fijos, registra asignaciones y updates */ +/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */ function mondayMock(leads: LeadMonday[]) { return { asignaciones: [] as { itemId: string; userId: number }[], updates: [] as { itemId: string; body: string }[], + actividades: [] as { + nombre: string + tipoLabel: string + responsableId: number + fecha: string + estadoLabel: string + leadItemId: string + }[], async getLeads() { return leads }, @@ -58,6 +66,17 @@ function mondayMock(leads: LeadMonday[]) { this.updates.push({ itemId, body }) 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() { return VENDEDORES }, @@ -238,3 +257,85 @@ describe('AutomatizacionesService.escaneoInactividad', () => { 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) + }) +}) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index 8bac6aa..c86d37f 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -13,9 +13,10 @@ import { prisma } from './prisma.js' * recordatorios el mismo día. */ -// Labels reales del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO. -// Solo DESCARTADO es terminal (los demás siguen en seguimiento). -export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO'] as const +// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO +// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado, +// 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_VENDEDORES = 'rr:vendedores' @@ -85,9 +86,14 @@ export function diasSinActividad(lead: LeadMonday, ahora: Date): number { type MondayLeads = Pick< 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 { itemId: string lead: string @@ -117,10 +123,48 @@ export interface ResultadoInactividad { } export class AutomatizacionesService { + private readonly actividadesEnabled: boolean + constructor( private readonly monday: MondayLeads, 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 { + 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; @@ -167,6 +211,13 @@ export class AutomatizacionesService { const vendedor = vendedorEnTurno(vendedores, indice) if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') 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 }) indice++ } @@ -208,6 +259,15 @@ export class AutomatizacionesService { await this.monday.crearUpdateEnItem(lead.id, body) await this.state.set(clave, ahora.toISOString()) 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()) diff --git a/api-gateway/src/services/cobranza.service.ts b/api-gateway/src/services/cobranza.service.ts index 8cd6c8f..cada832 100644 --- a/api-gateway/src/services/cobranza.service.ts +++ b/api-gateway/src/services/cobranza.service.ts @@ -44,6 +44,8 @@ export interface PlanPago { mensualidad: number tasaMoraMensual?: number status: StatusPlan + /** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */ + mondayItemId?: string | null pagos: Pago[] notas: NotaPlan[] createdAt: string // ISO diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index adb3f64..ac217ff 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -279,10 +279,12 @@ export class MondayService { * column_values SIEMPRE se envía con JSON.stringify en variables (nunca * 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) { case 'status': return { label: String(crudo) } + case 'dropdown': + return { labels: [String(crudo)] } case 'date': return { date: String(crudo) } case 'numbers': @@ -291,6 +293,14 @@ export class MondayService { return { email: String(crudo), text: String(crudo) } case 'phone': 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: return String(crudo) } @@ -298,7 +308,7 @@ export class MondayService { private async construirColumnValues( boardId: string, - valores: Record, + valores: Record, ): Promise { const out: Record = {} for (const [titulo, crudo] of Object.entries(valores)) { @@ -346,6 +356,14 @@ export class MondayService { 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) } @@ -378,6 +396,74 @@ export class MondayService { 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 { + 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 { + 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 = " — ") */ + async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise { + 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 { + 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 */ @@ -510,6 +596,14 @@ export interface DatosContacto { 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 { @@ -518,7 +612,22 @@ export interface DatosApartado { montoApartado?: number fechaApartado: 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 // ' — ' + 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) */ diff --git a/api-gateway/vitest.config.ts b/api-gateway/vitest.config.ts index b22e14d..0057803 100644 --- a/api-gateway/vitest.config.ts +++ b/api-gateway/vitest.config.ts @@ -12,6 +12,10 @@ export default defineConfig({ MONDAY_BOARD_OPORTUNIDADES: '555', MONDAY_BOARD_APARTADO: '666', 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', }, }, })