- 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
197 lines
8.0 KiB
TypeScript
197 lines
8.0 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|