feat(cotizador): multi-desarrollo — ATELIER, ESTANZA, SENDA, SOLANA + DIAGONAL

- Motor de cálculo parametrizado por desarrollo (enganches, descuentos, splits,
  fuente de N, quirk Infonavit-lista); DIAGONAL byte-idéntico
- 285 unidades (5 desarrollos) en seeds, API (?desarrollo=) y board Monday
- Selector de desarrollo en UI, propuestas por config, PDF multi-desarrollo
- Knowledge IA: catálogo por desarrollo + matriz de diferencias (10 docs)
- Tests: cotizador 53, api 99 — fixtures de los Excel validados al centavo
This commit is contained in:
2026-08-22 04:31:21 +00:00
parent 05c44478ec
commit afb454b54c
33 changed files with 2074 additions and 338 deletions

View File

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