feat(cotizador): historial, compartir WhatsApp, folio+QR, comparador de unidades, alertas de inventario

- Mis cotizaciones (guardar con cliente, retomar, nueva) asociadas al vendedor del token
- Compartir: resumen copiable + deep link wa.me; PDF con folio, vigencia y QR
- GET /api/cotizaciones/:id/publico (público, datos mínimos sin PII del cliente)
- Comparador lado a lado de 2 unidades (mismo o distinto desarrollo)
- POST /api/cotizaciones/alertas-inventario: avisa al vendedor si una unidad
  cotizada cambia de estatus (dedup; wire con syncs documentado)
- Fix: labels de propuesta mapeados a los reales del board Oportunidades
- Tests: api 201, cotizador 82
This commit is contained in:
2026-08-28 06:58:10 +00:00
parent bc0f9da8fe
commit 4f31d1c171
24 changed files with 1938 additions and 20 deletions

View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import {
procesarAlertasInventario,
VENTANA_ALERTA_DIAS,
type AlertasInventarioDeps,
type CotizacionParaAlerta,
} from './alertas-inventario.service.js'
interface Opciones {
cotizaciones?: CotizacionParaAlerta[]
mondayUserId?: number | null
yaAlertada?: boolean
fallaMonday?: boolean
}
/** Dependencias mockeadas: registra actividades y claves dedup marcadas */
function depsMock(opts: Opciones = {}) {
const dedup = new Set<string>()
if (opts.yaAlertada) dedup.add('*') // comodín: todo ya alertado
const deps: AlertasInventarioDeps & {
actividades: { nombre: string; responsableId: number }[]
consultas: { unidadCodigo: string; desde: Date }[]
} = {
actividades: [],
consultas: [],
async buscarCotizacionesRecientes(unidadCodigo, desde) {
this.consultas.push({ unidadCodigo, desde })
return opts.cotizaciones ?? []
},
async mondayUserIdDeVendedor() {
return opts.mondayUserId === undefined ? 12345 : opts.mondayUserId
},
async crearActividad(datos) {
if (opts.fallaMonday) throw new Error('Monday caído')
this.actividades.push({ nombre: datos.nombre, responsableId: datos.responsableId })
return 'act-1'
},
async yaAlertada(clave) {
return dedup.has('*') || dedup.has(clave)
},
async marcarAlertada(clave) {
dedup.add(clave)
},
}
return deps
}
const AHORA = new Date('2026-08-28T12:00:00')
describe('procesarAlertasInventario', () => {
it('crea la actividad al vendedor con mondayUserId y texto de estatus', async () => {
const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] })
const r = await procesarAlertasInventario(
deps,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
AHORA,
)
expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
expect(deps.actividades).toEqual([
{ nombre: 'La unidad U-101 que cotizaste pasó a APARTADO', responsableId: 12345 },
])
})
it('busca cotizaciones en ventana de 30 días hacia atrás', async () => {
const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] })
await procesarAlertasInventario(deps, [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }], AHORA)
const esperado = new Date(AHORA.getTime() - VENTANA_ALERTA_DIAS * 86400000)
expect(deps.consultas[0]?.desde.getTime()).toBe(esperado.getTime())
})
it('dedup por unidad+cotización+estatus: no repite la alerta', async () => {
const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] })
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }
await procesarAlertasInventario(deps, [cambio], AHORA)
const r = await procesarAlertasInventario(deps, [cambio], AHORA)
expect(r.alertasCreadas).toBe(0)
expect(r.omitidas).toBe(1)
expect(deps.actividades).toHaveLength(1)
})
it('omite cotizaciones sin vendedor o sin mapeo Monday', async () => {
const sinVendedor = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: null }] })
const r1 = await procesarAlertasInventario(
sinVendedor,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
AHORA,
)
expect(r1.omitidas).toBe(1)
expect(sinVendedor.actividades).toHaveLength(0)
const sinMapeo = depsMock({
cotizaciones: [{ id: 'cot-2', vendedorId: 'ven-2' }],
mondayUserId: null,
})
const r2 = await procesarAlertasInventario(
sinMapeo,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
AHORA,
)
expect(r2.omitidas).toBe(1)
expect(sinMapeo.actividades).toHaveLength(0)
})
it('si Monday falla no marca dedup (reintentable) y cuenta fallidas', async () => {
const deps = depsMock({
cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }],
fallaMonday: true,
})
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }
const r1 = await procesarAlertasInventario(deps, [cambio], AHORA)
expect(r1.fallidas).toBe(1)
// Segundo intento vuelve a intentarlo (no quedó deduplicada)
const r2 = await procesarAlertasInventario(deps, [cambio], AHORA)
expect(r2.fallidas).toBe(1)
})
it('procesa varias cotizaciones y varios cambios', async () => {
const deps = depsMock({
cotizaciones: [
{ id: 'cot-1', vendedorId: 'ven-1' },
{ id: 'cot-2', vendedorId: 'ven-2' },
],
})
const r = await procesarAlertasInventario(
deps,
[
{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' },
{ unidadCodigo: 'U-102', statusNuevo: 'VENDIDA' },
],
AHORA,
)
expect(r.cambiosProcesados).toBe(2)
expect(r.alertasCreadas).toBe(4) // 2 cambios × 2 cotizaciones
})
it('sin cotizaciones recientes no hace nada', async () => {
const deps = depsMock({ cotizaciones: [] })
const r = await procesarAlertasInventario(
deps,
[{ unidadCodigo: 'U-999', statusNuevo: 'APARTADO' }],
AHORA,
)
expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 0, fallidas: 0 })
expect(deps.actividades).toHaveLength(0)
expect(vi.isMockFunction(deps.crearActividad)).toBe(false) // función real del mock, sin llamadas
})
})

View File

@@ -0,0 +1,104 @@
/**
* Alerta de inventario: cuando una unidad cambia de estatus, se busca si hay
* cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad en
* Monday al vendedor de cada cotización (solo si tiene mondayUserId mapeado).
*
* Lógica pura con dependencias inyectadas para poder probarla con mocks; el
* controller la cablea con Prisma y MondayService.
*/
export interface CambioEstatusUnidad {
unidadCodigo: string
statusNuevo: string // p. ej. APARTADO | VENDIDA | BLOQUEADO
}
export interface CotizacionParaAlerta {
id: string
vendedorId: string | null
}
export interface AlertasInventarioDeps {
/** Cotizaciones de la unidad creadas desde `desde` (ventana de 30 días) */
buscarCotizacionesRecientes(unidadCodigo: string, desde: Date): Promise<CotizacionParaAlerta[]>
/** mondayUserId del vendedor; null si no existe o no está mapeado */
mondayUserIdDeVendedor(vendedorId: string): Promise<number | null>
/** Crea la Actividad en Monday y devuelve su item id */
crearActividad(datos: {
nombre: string
tipoLabel: string
responsableId: number
fecha: string // 'YYYY-MM-DD'
estadoLabel: string
}): Promise<string>
/** Dedup persistente por clave (unidad+cotización+estatus) */
yaAlertada(clave: string): Promise<boolean>
marcarAlertada(clave: string): Promise<void>
}
export interface ResultadoAlertas {
cambiosProcesados: number
alertasCreadas: number
omitidas: number // sin vendedor, sin mapeo Monday o ya alertadas
fallidas: number // Monday falló; NO quedan deduplicadas (reintentables)
}
export const VENTANA_ALERTA_DIAS = 30
const claveDedup = (cambio: CambioEstatusUnidad, cotizacionId: string): string =>
`alerta-inv:${cambio.unidadCodigo}:${cotizacionId}:${cambio.statusNuevo}`
const hoyISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
/** Procesa los cambios de estatus y crea las actividades correspondientes */
export async function procesarAlertasInventario(
deps: AlertasInventarioDeps,
cambios: CambioEstatusUnidad[],
ahora: Date = new Date(),
): Promise<ResultadoAlertas> {
const desde = new Date(ahora.getTime() - VENTANA_ALERTA_DIAS * 86400000)
const resultado: ResultadoAlertas = {
cambiosProcesados: 0,
alertasCreadas: 0,
omitidas: 0,
fallidas: 0,
}
for (const cambio of cambios) {
resultado.cambiosProcesados += 1
const cotizaciones = await deps.buscarCotizacionesRecientes(cambio.unidadCodigo, desde)
for (const cotizacion of cotizaciones) {
const clave = claveDedup(cambio, cotizacion.id)
if (await deps.yaAlertada(clave)) {
resultado.omitidas += 1
continue
}
if (!cotizacion.vendedorId) {
resultado.omitidas += 1
continue
}
const mondayUserId = await deps.mondayUserIdDeVendedor(cotizacion.vendedorId)
if (mondayUserId === null) {
resultado.omitidas += 1
continue
}
try {
await deps.crearActividad({
nombre: `La unidad ${cambio.unidadCodigo} que cotizaste pasó a ${cambio.statusNuevo}`,
tipoLabel: 'Tarea',
responsableId: mondayUserId,
fecha: hoyISO(ahora),
estadoLabel: 'Abierto',
})
await deps.marcarAlertada(clave)
resultado.alertasCreadas += 1
} catch {
// Monday falló: no se marca dedup para que el siguiente sync reintente
resultado.fallidas += 1
}
}
}
return resultado
}