feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -1,189 +0,0 @@
/**
* Importa los inventarios de ATELIER, ESTANZA, SENDA y SOLANA al board
* "Unidades" de Monday, desde reference/cotizadores/{DEV}.json.
*
* - Idempotente: salta items cuyo nombre (código prefijado) ya existe.
* - Crea la columna "Meses para firma" (numbers) si falta (la usa SOLANA).
* - create_labels_if_missing: la columna status "Disponibilidad" gana las
* etiquetas nuevas (VENDIDO, ESCRITURADO, APARTADO, BLOQUEADO).
* - Delay entre mutaciones para cuidar el rate limit.
*
* Uso: npx tsx scripts/importar-unidades.ts (desde api-gateway/)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const REF_DIR = path.resolve(process.cwd(), '..', 'reference', 'cotizadores')
const DELAY_MS = 400
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
interface UnidadJson {
codigo: string
modelo: string | null
fechaEntrega: string | null
supEstacionamiento: number | null
supConstruccion: number | null
supExcedente: number | null
status: string
extras: Record<string, string | number | null>
}
interface DefinicionDesarrollo {
id: string
archivo: string
codigoMonday: (u: UnidadJson) => string
precio: (u: UnidadJson) => number | null
mesesParaFirma?: (u: UnidadJson) => number | null
}
const DESARROLLOS: DefinicionDesarrollo[] = [
{
id: 'ATELIER',
archivo: 'ATELIER.json',
codigoMonday: (u) => `ATELIER-${u.codigo}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'ESTANZA',
archivo: 'ESTANZA.json',
codigoMonday: (u) => `ESTANZA-${u.codigo.replace(/^CASA-/, '')}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'SENDA',
archivo: 'SENDA.json',
codigoMonday: (u) => `SENDA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
},
{
id: 'SOLANA',
archivo: 'SOLANA.json',
codigoMonday: (u) => `SOLANA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
mesesParaFirma: (u) =>
typeof u.extras['mesesParaFirma'] === 'number' ? u.extras['mesesParaFirma'] : null,
},
]
const svc = new MondayService()
const boardId = config.monday.boards.unidades
/** Columnas del board mapeadas por título */
async function columnasBoard(): Promise<Map<string, { id: string; title: string; type: string }>> {
const data = await svc.graphql<{ boards: { columns: { id: string; title: string; type: string }[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
return new Map((data.boards[0]?.columns ?? []).map((c) => [c.title, c]))
}
async function asegurarColumnaMeses(): Promise<void> {
const cols = await columnasBoard()
if (cols.has('Meses para firma')) return
const data = await svc.graphql<{ create_column: { id: string } }>(
`mutation ($boardId: ID!, $title: String!) {
create_column(board_id: $boardId, title: $title, column_type: numbers) { id }
}`,
{ boardId, title: 'Meses para firma' },
)
console.log(`Columna "Meses para firma" creada (${data.create_column.id})`)
}
/** Nombres de TODOS los items del board (paginado por cursor) */
async function itemsExistentes(): Promise<Set<string>> {
const nombres = new Set<string>()
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: { name: string }[] } }[] } =
cursor === null
? await svc.graphql(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { name } } } }`,
{ ids: [boardId] },
)
: await svc.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { name } } }
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) nombres.add(item.name)
cursor = page.cursor
} while (cursor !== null)
return nombres
}
function columnValuesDe(
def: DefinicionDesarrollo,
u: UnidadJson,
cols: Map<string, { id: string; title: string; type: string }>,
): string {
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown) => {
const col = cols.get(titulo)
if (!col) throw new Error(`Columna "${titulo}" no existe en el board`)
valores[col.id] = valor
}
const precio = def.precio(u)
poner('Desarrollo', def.id)
poner('Modelo', u.modelo ?? '')
if (u.fechaEntrega) poner('Fecha entrega', { date: u.fechaEntrega })
if (u.supConstruccion !== null) poner('Sup. construcción', u.supConstruccion)
if (u.supExcedente !== null) poner('Sup. excedente', u.supExcedente)
if (u.supEstacionamiento !== null) poner('Sup. estacionamiento', u.supEstacionamiento)
if (precio !== null) {
// Desarrollos de precio único: ambos precios replican el precio de lista
poner('Precio sin acabados', precio)
poner('Precio con acabados', precio)
}
poner('Disponibilidad', { label: u.status })
const meses = def.mesesParaFirma?.(u)
if (meses !== null && meses !== undefined) poner('Meses para firma', meses)
return JSON.stringify(valores)
}
async function main(): Promise<void> {
await asegurarColumnaMeses()
const cols = await columnasBoard()
const existentes = await itemsExistentes()
console.log(`Board ${boardId}: ${existentes.size} items existentes`)
for (const def of DESARROLLOS) {
const raw = JSON.parse(await readFile(path.join(REF_DIR, def.archivo), 'utf8')) as {
unidades: UnidadJson[]
}
let creados = 0
let saltados = 0
for (const u of raw.unidades) {
const nombre = def.codigoMonday(u)
if (existentes.has(nombre)) {
saltados++
continue
}
const columnValues = columnValuesDe(def, u, cols)
await svc.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, create_labels_if_missing: true) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
existentes.add(nombre)
creados++
if (creados % 25 === 0) console.log(` ${def.id}: ${creados} creados…`)
await sleep(DELAY_MS)
}
console.log(`${def.id}: ${creados} creados, ${saltados} ya existían (total ${raw.unidades.length})`)
}
svc.invalidarCacheUnidades()
console.log('Importación terminada.')
process.exit(0)
}
main().catch((err) => {
console.error('Falló la importación:', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -1,240 +0,0 @@
/**
* 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)
})

View File

@@ -1,196 +0,0 @@
/**
* 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)
})

View File

@@ -1,368 +0,0 @@
/**
* Sincroniza la columna status "Etapa" del tablero Unidades (pipeline de 10
* etapas: Apartado → Reserva → Vendido → Integración → En Análisis → Autorizado
* → Notaría → Escriturado → Entrega → PostVenta) desde un JSON de etapas.
*
* Formatos de entrada aceptados:
* - Simple: [{ "codigo": "ATELIER-1", "etapa": "NOTARIA" }, ...]
* - Maestro: reference/maestro/maestro-unidades.json (codigo_nuestro +
* etapa_maestro + mapeo.confianza; solo se usan confianza
* alta/media). Es el default.
*
* Idempotente: solo escribe en Monday si la etapa cambió.
*
* Traspasos de departamento (dedup en AutomatizacionState, clave
* `traspaso:<codigo>:<etapa>`):
* - al llegar a Vendido → actividad "Venta de {codigo} lista para Escrituración"
* - al llegar a Escriturado → actividad "{codigo} escriturado: pasa a SAC"
* Una etapa alta implica las anteriores (p. ej. Escriturado cubre Vendido).
*
* Flags:
* --archivo <path> JSON de etapas (default: reference/maestro/maestro-unidades.json)
* --dry-run no escribe en Monday ni en la base; solo reporta
* --semilla registra el dedup de traspasos SIN crear actividades.
* Usarlo en la PRIMERA corrida (carga histórica): las ~50
* unidades ya vendidas/escrituradas no deben generar
* actividades retroactivas. Las corridas siguientes (sin
* --semilla) solo crean actividad en transiciones nuevas.
*
* Uso: npm run sync:etapas -- [--archivo <path>] [--dry-run] [--semilla]
*/
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'
import { ETAPAS_PIPELINE, normalizarEtapa, rangoEtapa, TRASPASOS, type EtapaPipeline } from '../src/services/etapas.js'
import { prisma } from '../src/services/prisma.js'
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const ARCHIVO_DEFAULT = path.join(RAIZ, 'reference/maestro/maestro-unidades.json')
const PAUSA_MS = 400 // entre mutaciones, para no pegarle al rate limit de Monday
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
// ---------- Argumentos ----------
interface Opciones {
archivo: string
dryRun: boolean
semilla: boolean
}
function parseArgs(argv: string[]): Opciones {
const ops: Opciones = { archivo: ARCHIVO_DEFAULT, dryRun: false, semilla: false }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--dry-run') ops.dryRun = true
else if (arg === '--semilla') ops.semilla = true
else if (arg === '--archivo') {
const valor = argv[++i]
if (!valor) throw new Error('--archivo requiere una ruta')
ops.archivo = path.resolve(process.cwd(), valor)
} else {
throw new Error(`Argumento desconocido: ${arg}`)
}
}
return ops
}
// ---------- Entrada ----------
interface EtapaUnidad {
codigo: string
etapa: EtapaPipeline // ya normalizada al label oficial
}
interface EntradaMaestro {
codigo_nuestro?: string | null
etapa_maestro?: string | null
mapeo?: { confianza?: string }
}
interface EntradaSimple {
codigo?: string
etapa?: string | null
}
/** Lee el JSON de etapas y lo deja en pares {codigo, etapa oficial}; avisa lo que descarta */
async function cargarEtapas(archivo: string): Promise<EtapaUnidad[]> {
const json = JSON.parse(await readFile(archivo, 'utf8')) as unknown[]
if (!Array.isArray(json)) throw new Error(`${archivo} no es un array JSON`)
const esMaestro = json.some((e) => typeof e === 'object' && e !== null && 'codigo_nuestro' in e)
const out: EtapaUnidad[] = []
let descartadas = 0
for (const cruda of json) {
if (typeof cruda !== 'object' || cruda === null) {
descartadas++
continue
}
let codigo: string | undefined
let etapaCruda: string | null | undefined
if (esMaestro) {
const e = cruda as EntradaMaestro
const confianza = e.mapeo?.confianza ?? 'ninguna'
if (confianza !== 'alta' && confianza !== 'media') {
descartadas++
continue
}
codigo = e.codigo_nuestro ?? undefined
etapaCruda = e.etapa_maestro
} else {
const e = cruda as EntradaSimple
codigo = e.codigo
etapaCruda = e.etapa
}
if (!codigo) {
descartadas++
continue
}
const etapa = normalizarEtapa(etapaCruda)
if (etapa === null) {
// DISPONIBLE / vacío / desconocido: la unidad no tiene etapa del pipeline;
// no se escribe nada (nunca se borra una etapa existente desde este script)
descartadas++
continue
}
out.push({ codigo, etapa })
}
if (descartadas > 0) console.log(` (${descartadas} entradas descartadas: sin match, sin etapa o confianza baja)`)
return out
}
// ---------- Monday: columna Etapa ----------
const monday = new MondayService()
interface ColumnaBoard {
id: string
title: string
type: string
settings_str?: string
}
async function columnasDe(boardId: string): Promise<ColumnaBoard[]> {
const data = await monday.graphql<{ boards: { columns: ColumnaBoard[] }[] }>(
`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 (de settings_str: {"labels":{"0":"Apartado",...}}) */
function labelsDe(col: ColumnaBoard): Set<string> {
try {
const settings = JSON.parse(col.settings_str ?? '{}') as { labels?: Record<string, string> }
return new Set(Object.values(settings.labels ?? {}))
} catch {
return new Set()
}
}
/**
* Garantiza que exista la columna status "Etapa" con los 10 labels oficiales.
* Monday no crea labels vía defaults de create_column en este workspace, así
* que los labels faltantes se siembran escribiéndolos en un item scratch con
* create_labels_if_missing:true y borrando el item al final.
*/
async function asegurarColumnaEtapa(boardId: string, dryRun: boolean): Promise<void> {
let col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa')
if (col && col.type !== 'status') throw new Error(`La columna "Etapa" existe pero es tipo ${col.type}, no status`)
if (!col) {
if (dryRun) {
console.log(' [dry-run] se crearía la columna "Etapa" (status) con los 10 labels')
return
}
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) {
create_column(board_id: $boardId, title: $title, column_type: $tipo) { id }
}`,
{ boardId, title: 'Etapa', tipo: 'status' },
)
console.log(' + columna "Etapa" creada')
col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa')
if (!col) throw new Error('No se encontró la columna "Etapa" recién creada')
}
const faltantes = ETAPAS_PIPELINE.filter((l) => !labelsDe(col).has(l))
if (faltantes.length === 0) {
console.log(' columna "Etapa" ya tiene los 10 labels')
return
}
if (dryRun) {
console.log(` [dry-run] faltarían labels en "Etapa": ${faltantes.join(', ')}`)
return
}
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: 'tmp-sync-etapas (borrar)' },
)
const scratchId = creado.create_item.id
try {
for (const label of faltantes) {
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: scratchId, values: JSON.stringify({ [col.id]: { label } }) },
)
await sleep(PAUSA_MS)
}
} finally {
await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId: scratchId })
}
console.log(` + labels sembrados en "Etapa": ${faltantes.join(', ')}`)
}
// ---------- Traspasos de departamento ----------
const claveDedup = (codigo: string, etapa: string): string => `traspaso:${codigo}:${etapa}`
const nombreActividad = (codigo: string, etapa: EtapaPipeline): string =>
etapa === 'Vendido' ? `Venta de ${codigo} lista para Escrituración` : `${codigo} escriturado: pasa a SAC`
const fechaHoy = (): string => new Date().toISOString().slice(0, 10)
interface ResumenTraspasos {
actividadesCreadas: number
registradasSemilla: number
yaRegistradas: number
errores: number
}
/**
* Para la etapa vigente de una unidad, cubre los traspasos (Vendido →
* Escrituración, Escriturado → SAC) con rango <= la etapa, con dedup persistente.
*/
async function cubrirTraspasos(
codigo: string,
etapaVigente: EtapaPipeline,
ops: Opciones,
resumen: ResumenTraspasos,
): Promise<void> {
const rango = rangoEtapa(etapaVigente)
for (const [etapaTraspaso, depto] of Object.entries(TRASPASOS) as [EtapaPipeline, string][]) {
if (rangoEtapa(etapaTraspaso) > rango) continue
const clave = claveDedup(codigo, etapaTraspaso)
const ya = await prisma.automatizacionState.findUnique({ where: { clave } })
if (ya) {
resumen.yaRegistradas++
continue
}
if (ops.dryRun) {
console.log(` [dry-run] traspaso pendiente: ${codigo} → ${depto} (${ops.semilla ? 'se registraría sin actividad' : 'crearía actividad'})`)
continue
}
if (ops.semilla) {
await prisma.automatizacionState.upsert({
where: { clave },
update: { valor: 'semilla' },
create: { clave, valor: 'semilla' },
})
resumen.registradasSemilla++
continue
}
try {
await monday.crearActividad({
nombre: nombreActividad(codigo, etapaTraspaso),
tipoLabel: 'Tarea',
fecha: fechaHoy(),
estadoLabel: 'Abierto',
responsableId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : undefined,
})
await prisma.automatizacionState.upsert({
where: { clave },
update: { valor: 'actividad-creada' },
create: { clave, valor: 'actividad-creada' },
})
resumen.actividadesCreadas++
console.log(` + actividad: ${nombreActividad(codigo, etapaTraspaso)}`)
await sleep(PAUSA_MS)
} catch (err) {
// Sin dedup: la próxima corrida reintenta la actividad
resumen.errores++
console.error(` ! no se pudo crear la actividad de traspaso de ${codigo}:`, err instanceof Error ? err.message : err)
}
}
}
// ---------- Main ----------
async function main(): Promise<void> {
const ops = parseArgs(process.argv.slice(2))
const boardId = config.monday.boards.unidades
console.log(`sync-etapas: ${ops.archivo}${ops.dryRun ? ' [dry-run]' : ''}${ops.semilla ? ' [semilla]' : ''}`)
await asegurarColumnaEtapa(boardId, ops.dryRun)
const entradas = await cargarEtapas(ops.archivo)
console.log(` ${entradas.length} unidades con etapa en el archivo`)
const unidades = await monday.getUnidades()
const porCodigo = new Map(unidades.map((u) => [u.codigo, u]))
let escritas = 0
let sinCambio = 0
const noEncontradas: string[] = []
const resumenTraspasos: ResumenTraspasos = { actividadesCreadas: 0, registradasSemilla: 0, yaRegistradas: 0, errores: 0 }
for (const { codigo, etapa } of entradas) {
const unidad = porCodigo.get(codigo)
if (!unidad) {
noEncontradas.push(codigo)
continue
}
const etapaActual = normalizarEtapa(unidad.etapa)
if (etapaActual !== etapa) {
if (ops.dryRun) {
console.log(` [dry-run] ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`)
} else {
const itemId = await monday.itemIdDeUnidad(codigo)
if (!itemId) {
noEncontradas.push(codigo)
continue
}
await monday.cambiarValorColumna(boardId, itemId, 'Etapa', etapa)
escritas++
console.log(` = ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`)
await sleep(PAUSA_MS)
}
} else {
sinCambio++
}
await cubrirTraspasos(codigo, etapa, ops, resumenTraspasos)
}
console.log('\nResumen:')
console.log(` etapas escritas: ${escritas}${ops.dryRun ? ' (dry-run: 0 reales)' : ''}`)
console.log(` sin cambio: ${sinCambio}`)
console.log(` no encontradas: ${noEncontradas.length}${noEncontradas.length > 0 ? ` (${noEncontradas.join(', ')})` : ''}`)
console.log(` actividades creadas: ${resumenTraspasos.actividadesCreadas}`)
console.log(` traspasos sembrados: ${resumenTraspasos.registradasSemilla}`)
console.log(` traspasos ya dedup: ${resumenTraspasos.yaRegistradas}`)
if (resumenTraspasos.errores > 0) console.log(` ERRORES en actividades: ${resumenTraspasos.errores}`)
// Conteo final por etapa (lectura fresca del board)
if (!ops.dryRun) {
monday.invalidarCacheUnidades()
const frescas = await monday.getUnidades()
const conteo = new Map<string, number>()
for (const u of frescas) {
const e = normalizarEtapa(u.etapa)
if (e) conteo.set(e, (conteo.get(e) ?? 0) + 1)
}
console.log('\nUnidades por etapa (board):')
for (const etapa of ETAPAS_PIPELINE) {
const n = conteo.get(etapa) ?? 0
if (n > 0) console.log(` ${etapa}: ${n}`)
}
const sinEtapa = frescas.length - [...conteo.values()].reduce((a, b) => a + b, 0)
console.log(` (sin etapa): ${sinEtapa}`)
}
await prisma.$disconnect()
}
main().catch((err) => {
console.error('sync-etapas falló:', err)
process.exit(1)
})