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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "PlanPago" ADD COLUMN "mondayItemId" TEXT;
|
||||||
@@ -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[]
|
||||||
|
|||||||
240
api-gateway/scripts/setup-crm.ts
Normal file
240
api-gateway/scripts/setup-crm.ts
Normal 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)
|
||||||
|
})
|
||||||
196
api-gateway/scripts/sync-desarrollos.ts
Normal file
196
api-gateway/scripts/sync-desarrollos.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -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')),
|
||||||
|
|||||||
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal file
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
})
|
})
|
||||||
|
|||||||
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal file
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal file
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal 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 })
|
||||||
|
}
|
||||||
42
api-gateway/src/controllers/planes.controller.test.ts
Normal file
42
api-gateway/src/controllers/planes.controller.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user