feat(unidades): pipeline de 10 etapas por unidad + sync desde Reporte_EstatusVenta

- Columna Etapa en Monday Unidades (10 labels oficiales), 155 unidades pobladas
- sync-etapas.ts idempotente (--archivo/--dry-run/--semilla) con traspasos
  Vendido→Escrituración y Escriturado→SAC como actividades (sin retroactivas)
- API: etapa en unidades + porEtapa en /api/reportes/resumen
- Dashboard: gráfica pipeline por etapa + KPI en notaría; cotizador muestra etapa
- 28 cambios aplicados desde Reporte_EstatusVenta (8 avances, 20 nuevas);
  contradicciones retenidas para revisión humana (documentadas)
- Tests: api 168, dashboards 9, cotizador 71
This commit is contained in:
2026-08-28 04:43:02 +00:00
parent 04de7d703f
commit 9e04b76616
16 changed files with 697 additions and 4 deletions

View File

@@ -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 =

View File

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

View File

@@ -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<Record<EtapaPipeline, string>> = {
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<string, EtapaPipeline> = {
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
}

View File

@@ -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',

View File

@@ -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<string | null> {
await this.getUnidades() // asegura cache fresco
return this.unidadesCache?.itemIds.get(codigo) ?? null
}
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): 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) */

View File

@@ -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(
{

View File

@@ -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<string, number> }[]
/** 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<string, { total: number; porStatus: Record<string, number> }>()
const conteoEtapas = new Map<string, number>()
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,