Files
FRASA-CAS/api-gateway/scripts/sync-desarrollos.ts
Consult AS Technology f0f923b2ef 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
2026-08-22 07:18:48 +00:00

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