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:
@@ -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 =
|
||||
|
||||
46
api-gateway/src/services/etapas.test.ts
Normal file
46
api-gateway/src/services/etapas.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
69
api-gateway/src/services/etapas.ts
Normal file
69
api-gateway/src/services/etapas.ts
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user