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:
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)
|
||||
})
|
||||
Reference in New Issue
Block a user