diff --git a/api-gateway/README.md b/api-gateway/README.md index c591abf..3575260 100644 --- a/api-gateway/README.md +++ b/api-gateway/README.md @@ -54,6 +54,7 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s - **Recordatorio de inactividad**: leads con estado no terminal (`DESCARTADO` y `ABANDONO` son terminales) y más de `INACTIVIDAD_DIAS` (default 10) sin actualizarse reciben un update en el item. Dedup por día: clave `recordatorio::` en `AutomatizacionState`. - **Actividades automáticas** (tablero Actividades): al asignar vendedor se crea "Contactar a {lead}" (tipo Llamada, hoy+1, Abierto) y el recordatorio de inactividad crea "Retomar contacto con {lead}" al vendedor asignado. Se apagan con `ACTIVIDADES_ENABLED=false`; un fallo al crear la actividad no rompe la asignación. - **Setup CRM** (`npm run setup:crm`): crea/alinea tableros, columnas y labels del vocabulario CRM FRASA (idempotente). **Sync de desarrollos** (`npm run sync:desarrollos`): recalcula Precio Desde/Hasta, M2, entrega y modelos del tablero Desarrollos desde los inventarios reales. +- **Sync de etapas** (`npm run sync:etapas -- [--archivo ] [--dry-run] [--semilla]`): 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 `[{codigo, etapa}]` (default: el maestro `reference/maestro/maestro-unidades.json`). Idempotente; al llegar a **Vendido**/**Escriturado** crea la actividad de traspaso de departamento (a Escrituración / a SAC) con dedup `traspaso::` en `AutomatizacionState`. En la PRIMERA corrida (carga histórica) usar `--semilla`: registra el dedup sin crear actividades retroactivas. - **Disparadores**: webhook `POST /api/webhooks/monday` (evento `create_item` en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: `POLL_CRON` (default `*/5 * * * *`) para round-robin y `0 9 * * *` para inactividad. Los crons se apagan con `MONDAY_AUTOMATIONS_ENABLED=false`. ## Notas de arquitectura diff --git a/api-gateway/knowledge/proceso-venta-apartado.md b/api-gateway/knowledge/proceso-venta-apartado.md index 9ae135f..65ebc26 100644 --- a/api-gateway/knowledge/proceso-venta-apartado.md +++ b/api-gateway/knowledge/proceso-venta-apartado.md @@ -1,5 +1,31 @@ # Proceso de venta y apartado +## Pipeline de 10 etapas por unidad + +Cada unidad avanza por un pipeline de 10 etapas (vocabulario oficial, en orden). En Monday vive en la columna status **Etapa** del tablero Unidades; en el maestro ERP (Enkontrol) las etapas aparecen en mayúsculas y sin acentos (APARTADO, RESERVADO, VENDIDO, INTEGRACION, EN ANALISIS, AUTORIZADO, NOTARIA, ESCRITURADO, ENTREGA, POSTVENTA): + +1. **Apartado** — el cliente apartó la unidad (pago de apartado). +2. **Reserva** — unidad reservada. +3. **Vendido** — venta formalizada (apartado + enganche + contrato firmado). +4. **Integración** — se integra el expediente del cliente. +5. **En Análisis** — expediente en análisis (crédito / antilavado). +6. **Autorizado** — crédito o pago autorizado. +7. **Notaría** — trámite en notaría. +8. **Escriturado** — escritura firmada. +9. **Entrega** — unidad entregada al cliente. +10. **PostVenta** — seguimiento posterior a la entrega. + +### Traspasos de departamento + +- Al pasar a **Vendido**, la administración de la venta pasa de Ventas a **Escrituración**. El script de sincronización (`npm run sync:etapas`) genera la actividad "Venta de {unidad} lista para Escrituración". +- Al pasar a **Escriturado**, la administración pasa de Escrituración a **SAC** (Servicio de Atención al Cliente). El script genera la actividad "{unidad} escriturado: pasa a SAC". + +Las actividades de traspaso tienen dedup persistente (`traspaso::`): solo se crean una vez por unidad y etapa, en transiciones nuevas (nunca de forma retroactiva en la carga histórica). + +### Mapeo al estatus grueso (columna Disponibilidad) + +El estatus grueso de inventario se deriva de la etapa: sin etapa → **DISPONIBLE**; Apartado/Reserva → **APARTADO**; Vendido/Integración/En Análisis/Autorizado/Notaría → **VENDIDO**; Escriturado/Entrega/PostVenta → **ESCRITURADO**. + ## Apartado 1. El cliente elige unidad y se genera la cotización con las 4 propuestas (Bancario, Infonavit-Cofinavit, Contado y Semi-contado). diff --git a/api-gateway/package.json b/api-gateway/package.json index a6db95d..df8bb97 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -16,6 +16,7 @@ "seed:usuarios": "tsx scripts/seed-usuarios.ts", "setup:crm": "tsx scripts/setup-crm.ts", "sync:desarrollos": "tsx scripts/sync-desarrollos.ts", + "sync:etapas": "tsx scripts/sync-etapas.ts", "prisma:migrate": "prisma migrate dev" }, "dependencies": { diff --git a/api-gateway/scripts/sync-etapas.ts b/api-gateway/scripts/sync-etapas.ts new file mode 100644 index 0000000..0574300 --- /dev/null +++ b/api-gateway/scripts/sync-etapas.ts @@ -0,0 +1,368 @@ +/** + * 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::`): + * - 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 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 ] [--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 => 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 { + 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 { + 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 { + try { + const settings = JSON.parse(col.settings_str ?? '{}') as { labels?: Record } + 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 { + 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 { + 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 { + 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() + 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) +}) diff --git a/api-gateway/src/services/cotizacion.service.ts b/api-gateway/src/services/cotizacion.service.ts index 52eee02..a2d24b6 100644 --- a/api-gateway/src/services/cotizacion.service.ts +++ b/api-gateway/src/services/cotizacion.service.ts @@ -19,6 +19,8 @@ export interface UnidadCotizable { desarrollo: string /** Plazo de mensualidades cuando lo fija el inventario (SOLANA: meses para firma) */ mesesParaFirma?: number | null + /** Etapa del pipeline de venta (columna status "Etapa" del tablero Unidades); null si no aplica */ + etapa?: string | null } export type TipoPropuesta = diff --git a/api-gateway/src/services/etapas.test.ts b/api-gateway/src/services/etapas.test.ts new file mode 100644 index 0000000..1ddef36 --- /dev/null +++ b/api-gateway/src/services/etapas.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { ETAPAS_PIPELINE, normalizarEtapa, rangoEtapa, TRASPASOS } from './etapas.js' + +describe('normalizarEtapa', () => { + it('mapea las etiquetas del maestro ERP a los labels oficiales', () => { + expect(normalizarEtapa('APARTADO')).toBe('Apartado') + expect(normalizarEtapa('RESERVADO')).toBe('Reserva') + expect(normalizarEtapa('VENDIDO')).toBe('Vendido') + expect(normalizarEtapa('INTEGRACION')).toBe('Integración') + expect(normalizarEtapa('EN ANALISIS')).toBe('En Análisis') + expect(normalizarEtapa('AUTORIZADO')).toBe('Autorizado') + expect(normalizarEtapa('NOTARIA')).toBe('Notaría') + expect(normalizarEtapa('ESCRITURADO')).toBe('Escriturado') + expect(normalizarEtapa('ENTREGA')).toBe('Entrega') + expect(normalizarEtapa('POSTVENTA')).toBe('PostVenta') + }) + + it('acepta los labels oficiales tal cual (idempotente)', () => { + for (const etapa of ETAPAS_PIPELINE) { + expect(normalizarEtapa(etapa)).toBe(etapa) + } + }) + + it('devuelve null para vacío, DISPONIBLE o desconocidos', () => { + expect(normalizarEtapa('')).toBeNull() + expect(normalizarEtapa(null)).toBeNull() + expect(normalizarEtapa(undefined)).toBeNull() + expect(normalizarEtapa('DISPONIBLE')).toBeNull() + expect(normalizarEtapa('OTRA COSA')).toBeNull() + }) +}) + +describe('rangoEtapa / TRASPASOS', () => { + it('el rango sigue el orden oficial 1..10', () => { + expect(rangoEtapa('Apartado')).toBe(1) + expect(rangoEtapa('Vendido')).toBe(3) + expect(rangoEtapa('Escriturado')).toBe(8) + expect(rangoEtapa('PostVenta')).toBe(10) + }) + + it('solo Vendido y Escriturado disparan traspaso de departamento', () => { + expect(TRASPASOS['Vendido']).toBe('Escrituración') + expect(TRASPASOS['Escriturado']).toBe('SAC') + expect(Object.keys(TRASPASOS)).toHaveLength(2) + }) +}) diff --git a/api-gateway/src/services/etapas.ts b/api-gateway/src/services/etapas.ts new file mode 100644 index 0000000..1d7b841 --- /dev/null +++ b/api-gateway/src/services/etapas.ts @@ -0,0 +1,69 @@ +/** + * Pipeline de 10 etapas por unidad (vocabulario oficial del cliente, en orden). + * La columna status "Etapa" del tablero Unidades guarda estos labels. + * + * Traspasos de departamento: + * - al pasar a Vendido, la administración de la venta pasa a Escrituración; + * - al pasar a Escriturado, pasa de Escrituración a SAC. + */ + +export const ETAPAS_PIPELINE = [ + 'Apartado', + 'Reserva', + 'Vendido', + 'Integración', + 'En Análisis', + 'Autorizado', + 'Notaría', + 'Escriturado', + 'Entrega', + 'PostVenta', +] as const + +export type EtapaPipeline = (typeof ETAPAS_PIPELINE)[number] + +/** Etapas que disparan traspaso de departamento (etapa → depto que recibe) */ +export const TRASPASOS: Partial> = { + Vendido: 'Escrituración', + Escriturado: 'SAC', +} + +/** Quita acentos y normaliza espacios/mayúsculas para comparar etiquetas del maestro ERP */ +function clave(texto: string): string { + return texto + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .replace(/\s+/g, ' ') + .toUpperCase() +} + +// Alias del maestro ERP (Enkontrol) → label oficial +const ALIAS: Record = { + APARTADO: 'Apartado', + RESERVA: 'Reserva', + RESERVADO: 'Reserva', + VENDIDO: 'Vendido', + INTEGRACION: 'Integración', + 'EN ANALISIS': 'En Análisis', + AUTORIZADO: 'Autorizado', + NOTARIA: 'Notaría', + ESCRITURADO: 'Escriturado', + ENTREGA: 'Entrega', + POSTVENTA: 'PostVenta', + 'POST VENTA': 'PostVenta', +} + +/** + * Normaliza una etapa cruda (maestro ERP o label de Monday) al label oficial. + * Devuelve null si está vacía o no es una etapa del pipeline (p. ej. DISPONIBLE). + */ +export function normalizarEtapa(cruda: string | null | undefined): EtapaPipeline | null { + if (cruda === null || cruda === undefined) return null + return ALIAS[clave(cruda)] ?? null +} + +/** Rango 1..10 de una etapa en el pipeline (0 si no es del pipeline) */ +export function rangoEtapa(etapa: EtapaPipeline): number { + return ETAPAS_PIPELINE.indexOf(etapa) + 1 +} diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 327a276..819177b 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -80,9 +80,23 @@ describe('MondayService', () => { status: 'DISPONIBLE', desarrollo: 'SENDA', mesesParaFirma: null, + etapa: null, }) }) + it('mapea la columna opcional "Etapa" (pipeline de venta) cuando existe', async () => { + const columnasConEtapa = [...COLUMNAS_UNIDADES, { id: 'color_mm6ejjj', title: 'Etapa', type: 'status' }] + const item = itemUnidad('1', 'U-101', 'VENDIDO') + item.column_values.push({ id: 'color_mm6ejjj', text: 'Notaría', value: null }) + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasConEtapa }] }) + return respuestaOk({ boards: [{ items_page: { cursor: null, items: [item] } }] }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades[0]?.etapa).toBe('Notaría') + }) + it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => { const placeholder = { id: '99', diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 6453e34..0995e63 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -254,6 +254,12 @@ export class MondayService { return unidades.find((u) => u.codigo === codigo) ?? null } + /** Item ID de una unidad por código (para escrituras); null si no existe */ + async itemIdDeUnidad(codigo: string): Promise { + await this.getUnidades() // asegura cache fresco + return this.unidadesCache?.itemIds.get(codigo) ?? null + } + private mapearUnidad(item: ItemMonday, cols: Map): Unidad { const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) const textoDe = (titulo: string): string => { @@ -270,6 +276,9 @@ export class MondayService { const colMeses = cols.get('Meses para firma') const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : '' const mesesNum = Number(mesesTexto) + // La columna status "Etapa" (pipeline de 10 etapas) es opcional + const colEtapa = cols.get('Etapa') + const etapaTexto = colEtapa ? (porId.get(colEtapa.id)?.text ?? '') : '' return { codigo: item.name, modelo: textoDe('Modelo'), @@ -283,6 +292,7 @@ export class MondayService { status: status || 'DISPONIBLE', desarrollo: textoDe('Desarrollo'), mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null, + etapa: etapaTexto !== '' ? etapaTexto : null, } } @@ -757,10 +767,10 @@ export interface DatosMotivoNoCompra { export interface DatosActividad { nombre: string tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea' - responsableId: number // user ID de Monday + responsableId?: number // user ID de Monday (se omite si no hay responsable claro) fecha: string // 'YYYY-MM-DD' estadoLabel: string // 'Abierto' | 'Listo' - leadItemId: string // item del tablero Leads relacionado + leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) } /** Singleton usado por los controllers (los tests instancian el suyo) */ diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index c22838a..b514ff6 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -22,7 +22,7 @@ function op(id: string, etapa: string): OportunidadMonday { return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 } } -function unidad(codigo: string, desarrollo: string, status: string): Unidad { +function unidad(codigo: string, desarrollo: string, status: string, etapa: string | null = null): Unidad { return { codigo, modelo: 'M1', @@ -35,6 +35,7 @@ function unidad(codigo: string, desarrollo: string, status: string): Unidad { status, desarrollo, mesesParaFirma: null, + etapa, } } @@ -149,6 +150,44 @@ describe('construirResumen', () => { ]) }) + it('cuenta unidades por etapa del pipeline, en el orden oficial de las 10 etapas', () => { + const r = construirResumen( + { + ...DATOS_BASE, + unidades: [ + unidad('U-101', 'ATELIER', 'VENDIDO', 'Notaría'), + unidad('U-102', 'ATELIER', 'VENDIDO', 'Notaría'), + unidad('U-103', 'ATELIER', 'ESCRITURADO', 'Escriturado'), + unidad('U-104', 'ATELIER', 'APARTADO', 'Apartado'), + unidad('U-105', 'ATELIER', 'DISPONIBLE'), // sin etapa: no cuenta + ], + }, + AHORA, + ) + expect(r.unidades.porEtapa).toEqual([ + { etapa: 'Apartado', total: 1 }, + { etapa: 'Reserva', total: 0 }, + { etapa: 'Vendido', total: 0 }, + { etapa: 'Integración', total: 0 }, + { etapa: 'En Análisis', total: 0 }, + { etapa: 'Autorizado', total: 0 }, + { etapa: 'Notaría', total: 2 }, + { etapa: 'Escriturado', total: 1 }, + { etapa: 'Entrega', total: 0 }, + { etapa: 'PostVenta', total: 0 }, + ]) + }) + + it('manda al final de porEtapa los labels que no son del pipeline oficial', () => { + const r = construirResumen( + { ...DATOS_BASE, unidades: [unidad('U-101', 'ATELIER', 'VENDIDO', 'Etapa rara')] }, + AHORA, + ) + const porEtapa = r.unidades.porEtapa + expect(porEtapa).toHaveLength(11) + expect(porEtapa[10]).toEqual({ etapa: 'Etapa rara', total: 1 }) + }) + it('cobra: planes por status, pagos vencidos y monto en mora de planes activos', () => { const r = construirResumen( { diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 91d1397..49a8dda 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,5 +1,6 @@ import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js' +import { ETAPAS_PIPELINE } from './etapas.js' /** * Agregador de reportes para la app de tableros (/dash/). @@ -44,6 +45,8 @@ export interface ResumenReportes { total: number disponibles: number porDesarrollo: { desarrollo: string; total: number; porStatus: Record }[] + /** Conteo por etapa del pipeline de venta, en el orden oficial de las 10 etapas */ + porEtapa: { etapa: string; total: number }[] } cobranza: { planes: { activos: number; liquidados: number; cancelados: number } @@ -105,6 +108,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor ).length const desarrollos = new Map }>() + const conteoEtapas = new Map() for (const u of datos.unidades) { const clave = u.desarrollo.trim() === '' ? '(sin desarrollo)' : u.desarrollo.trim() const entrada = desarrollos.get(clave) ?? { total: 0, porStatus: {} } @@ -112,7 +116,16 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor const status = u.status.trim() === '' ? '(sin status)' : u.status.trim().toUpperCase() entrada.porStatus[status] = (entrada.porStatus[status] ?? 0) + 1 desarrollos.set(clave, entrada) + const etapa = u.etapa?.trim() + if (etapa) conteoEtapas.set(etapa, (conteoEtapas.get(etapa) ?? 0) + 1) } + // Etapas oficiales primero (en orden del pipeline); cualquier label desconocido va al final + const porEtapaUnidades = [ + ...ETAPAS_PIPELINE.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), + ...[...conteoEtapas.keys()] + .filter((e) => !(ETAPAS_PIPELINE as readonly string[]).includes(e)) + .map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), + ] const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 } let planesAlCorriente = 0 @@ -154,6 +167,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor porDesarrollo: [...desarrollos.entries()] .map(([desarrollo, v]) => ({ desarrollo, total: v.total, porStatus: v.porStatus })) .sort((a, b) => a.desarrollo.localeCompare(b.desarrollo, 'es')), + porEtapa: porEtapaUnidades, }, cobranza: { planes: planesPorStatus, diff --git a/apps/cotizador/src/components/UnitInfoCard.tsx b/apps/cotizador/src/components/UnitInfoCard.tsx index 79c68ec..0cf84f0 100644 --- a/apps/cotizador/src/components/UnitInfoCard.tsx +++ b/apps/cotizador/src/components/UnitInfoCard.tsx @@ -18,14 +18,30 @@ function Fila({ etiqueta, valor }: { etiqueta: string; valor: string }) { const formatoM2 = (v: number | null): string => v === null ? '—' : `${v.toLocaleString('es-MX')} m²` +// Status grueso → etiqueta legible en femenino ("la unidad está …") +const ETIQUETA_STATUS: Record = { + APARTADO: 'Apartada', + APARTADA: 'Apartada', + VENDIDO: 'Vendida', + VENDIDA: 'Vendida', + ESCRITURADO: 'Escriturada', + BLOQUEADO: 'Bloqueada', +} + /** Tarjeta con la ficha técnica de la unidad seleccionada */ export function UnitInfoCard({ unidad, numMeses }: Props) { const extras = unidad.extras ?? {} + const etiquetaStatus = ETIQUETA_STATUS[unidad.status] return (

Unidad {unidad.codigo}

+ {etiquetaStatus !== undefined && unidad.etapa != null && unidad.etapa !== '' && ( +

+ {etiquetaStatus} — en {unidad.etapa.toLowerCase()} +

+ )}
{extras['prototipo'] != null && } diff --git a/apps/cotizador/src/types/index.ts b/apps/cotizador/src/types/index.ts index b063032..cf575b9 100644 --- a/apps/cotizador/src/types/index.ts +++ b/apps/cotizador/src/types/index.ts @@ -22,6 +22,8 @@ export interface Unidad { precioLista?: number | null /** Plazo de mensualidades cuando lo fija el inventario (SOLANA: meses para firma) */ mesesParaFirma?: number | null + /** Etapa del pipeline de venta (Apartado, Reserva, Vendido, … PostVenta); null si no aplica */ + etapa?: string | null /** Datos extra del inventario: prototipo, manzana, lote, etapa, cajones, etc. */ extras?: Record } diff --git a/apps/dashboards/src/lib/reportes.test.ts b/apps/dashboards/src/lib/reportes.test.ts index bdc57bf..d4c478c 100644 --- a/apps/dashboards/src/lib/reportes.test.ts +++ b/apps/dashboards/src/lib/reportes.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { datosCobranza, datosFunnel, + datosPipeline, formatoMoneda, formatoPorcentaje, unidadesApiladas, + unidadesEnEtapa, type ResumenReportes, } from './reportes' @@ -26,6 +28,18 @@ function resumen(overrides: Partial = {}): ResumenReportes { { desarrollo: 'ATELIER', total: 3, porStatus: { DISPONIBLE: 1, VENDIDO: 2 } }, { desarrollo: 'SOLANA', total: 2, porStatus: { DISPONIBLE: 1, ESCRITURADO: 1 } }, ], + porEtapa: [ + { etapa: 'Apartado', total: 1 }, + { etapa: 'Reserva', total: 0 }, + { etapa: 'Vendido', total: 0 }, + { etapa: 'Integración', total: 0 }, + { etapa: 'En Análisis', total: 0 }, + { etapa: 'Autorizado', total: 0 }, + { etapa: 'Notaría', total: 2 }, + { etapa: 'Escriturado', total: 1 }, + { etapa: 'Entrega', total: 0 }, + { etapa: 'PostVenta', total: 0 }, + ], }, cobranza: { planes: { activos: 6, liquidados: 2, cancelados: 1 }, @@ -48,7 +62,35 @@ describe('unidadesApiladas', () => { }) it('con inventario vacío devuelve arreglo vacío', () => { - expect(unidadesApiladas(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [] } }))).toEqual([]) + expect(unidadesApiladas(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [], porEtapa: [] } }))).toEqual([]) + }) +}) + +describe('datosPipeline', () => { + it('devuelve las 10 etapas en el orden oficial con sus totales', () => { + const pipeline = datosPipeline(resumen()) + expect(pipeline.map((e) => e.etapa)).toEqual([ + 'Apartado', + 'Reserva', + 'Vendido', + 'Integración', + 'En Análisis', + 'Autorizado', + 'Notaría', + 'Escriturado', + 'Entrega', + 'PostVenta', + ]) + expect(pipeline.find((e) => e.etapa === 'Notaría')?.valor).toBe(2) + expect(pipeline.find((e) => e.etapa === 'Escriturado')?.valor).toBe(1) + }) +}) + +describe('unidadesEnEtapa', () => { + it('devuelve el total de la etapa pedida y 0 si no aparece', () => { + expect(unidadesEnEtapa(resumen(), 'Notaría')).toBe(2) + expect(unidadesEnEtapa(resumen(), 'Reserva')).toBe(0) + expect(unidadesEnEtapa(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [], porEtapa: [] } }), 'Notaría')).toBe(0) }) }) diff --git a/apps/dashboards/src/lib/reportes.ts b/apps/dashboards/src/lib/reportes.ts index a5fff6f..232d9a5 100644 --- a/apps/dashboards/src/lib/reportes.ts +++ b/apps/dashboards/src/lib/reportes.ts @@ -24,6 +24,8 @@ export interface ResumenReportes { total: number disponibles: number porDesarrollo: { desarrollo: string; total: number; porStatus: Record }[] + /** Conteo por etapa del pipeline de venta, en el orden oficial de las 10 etapas */ + porEtapa: { etapa: string; total: number }[] } cobranza: { planes: { activos: number; liquidados: number; cancelados: number } @@ -74,6 +76,19 @@ export interface EtapaFunnel { valor: number } +/** + * Datos de la gráfica "Pipeline de ventas por etapa": las 10 etapas del + * pipeline de unidad en su orden oficial (el API ya las manda ordenadas). + */ +export function datosPipeline(resumen: ResumenReportes): EtapaFunnel[] { + return resumen.unidades.porEtapa.map((e) => ({ etapa: e.etapa, valor: e.total })) +} + +/** Unidades actualmente en una etapa dada del pipeline (p. ej. 'Notaría'); 0 si no hay */ +export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number { + return resumen.unidades.porEtapa.find((e) => e.etapa === etapa)?.total ?? 0 +} + /** * Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado. * "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba. diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index f23ecbe..1ba3a37 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -19,9 +19,11 @@ import KpiCard from '../components/KpiCard' import { datosCobranza, datosFunnel, + datosPipeline, formatoMoneda, formatoPorcentaje, unidadesApiladas, + unidadesEnEtapa, type ResumenReportes, } from '../lib/reportes' import { fetchResumen } from '../services/reportes' @@ -73,6 +75,8 @@ export default function DashboardPage() { const unidades = resumen ? unidadesApiladas(resumen) : [] const funnel = resumen ? datosFunnel(resumen) : [] const cobranza = resumen ? datosCobranza(resumen) : [] + const pipeline = resumen ? datosPipeline(resumen) : [] + const sinPipeline = pipeline.every((e) => e.valor === 0) const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0 return ( @@ -139,6 +143,11 @@ export default function DashboardPage() { detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados`} /> + +
+ Pipeline de ventas por etapa + {sinPipeline ? ( + + ) : ( +
+ + + + + + + + + +
+ )} +
+
Embudo: leads → oportunidades → ganado