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:
147
api-gateway/src/services/alertas-inventario.service.test.ts
Normal file
147
api-gateway/src/services/alertas-inventario.service.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
104
api-gateway/src/services/alertas-inventario.service.ts
Normal file
104
api-gateway/src/services/alertas-inventario.service.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user