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:
@@ -29,8 +29,11 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
|
||||
| GET | `/api/health` | `{status, monday: 'up'|'down'}` |
|
||||
| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra; `?desarrollo=` filtra por DIAGONAL/ATELIER/ESTANZA/SENDA/SOLANA) |
|
||||
| GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) |
|
||||
| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday |
|
||||
| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday. Asocia `vendedorId` = `sub` del JWT |
|
||||
| GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta |
|
||||
| GET | `/api/cotizaciones/mias` | Últimas 50 del vendedor autenticado (más recientes primero) |
|
||||
| GET | `/api/cotizaciones/:id/publico` | **Público (sin auth)**, destino del QR del PDF: folio, desarrollo, unidad, propuesta, montos, vigencia y nombre del vendedor. NUNCA datos de contacto del cliente |
|
||||
| POST | `/api/cotizaciones/alertas-inventario` | Alerta de cambio de estatus (ver abajo) |
|
||||
| POST | `/api/clientes` | Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday |
|
||||
| GET | `/api/clientes/:id` | Cliente con documentos |
|
||||
| POST | `/api/clientes/:id/documentos` | Multipart (campo `archivos`, máx 10, pdf/jpg/png, 10 MB) |
|
||||
@@ -57,6 +60,21 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
|
||||
- **Sync de etapas** (`npm run sync:etapas -- [--archivo <path>] [--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:<codigo>:<etapa>` 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`.
|
||||
|
||||
## Alerta de inventario (cambio de estatus de unidad)
|
||||
|
||||
`POST /api/cotizaciones/alertas-inventario` con body `{"cambios": [{"unidadCodigo": "U-101", "statusNuevo": "APARTADO"}]}` (máx 100). Por cada unidad busca cotizaciones de los últimos 30 días y crea una Actividad en Monday al vendedor de cada una ("La unidad X que cotizaste pasó a APARTADO", tipo Tarea, Abierto), solo si el vendedor tiene `mondayUserId` mapeado. Dedup persistente por unidad+cotización+estatus (clave `alerta-inv:<unidad>:<cotizacionId>:<status>` en `AutomatizacionState`); si Monday falla, esa alerta NO queda deduplicada y el siguiente sync la reintenta. Responde `{cambiosProcesados, alertasCreadas, omitidas, fallidas}`.
|
||||
|
||||
**Cómo invocarlo desde el sync**: al final de `sync-etapas.ts` / `sync-estatus-maestro.py`, recolectar los cambios de estatus aplicados y hacer un POST autenticado (Bearer JWT):
|
||||
|
||||
```bash
|
||||
curl -X POST "$API_URL/api/cotizaciones/alertas-inventario" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"cambios":[{"unidadCodigo":"U-101","statusNuevo":"APARTADO"}]}'
|
||||
```
|
||||
|
||||
(Wire directo pendiente: los syncs actuales no reportan su lista de cambios; por eso queda como endpoint invocable.)
|
||||
|
||||
|
||||
## Notas de arquitectura
|
||||
|
||||
- **Monday service** (`src/services/monday.service.ts`): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, `column_values` siempre via `JSON.stringify` en variables, cache de unidades 5 min con invalidación tras escrituras.
|
||||
|
||||
@@ -13,6 +13,11 @@ datasource db {
|
||||
model Cotizacion {
|
||||
id String @id @default(uuid())
|
||||
unidadCodigo String
|
||||
// Desarrollo de la unidad al momento de cotizar (DIAGONAL | ATELIER | ...)
|
||||
desarrollo String @default("DIAGONAL")
|
||||
// Usuario del ecosistema que generó la cotización (sub del JWT); null en
|
||||
// cotizaciones históricas o creadas sin auth (AUTH_ENABLED=false)
|
||||
vendedorId String?
|
||||
clienteNombre String
|
||||
clienteEmail String?
|
||||
clienteTelefono String?
|
||||
@@ -30,6 +35,7 @@ model Cotizacion {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([unidadCodigo])
|
||||
@@index([vendedorId])
|
||||
}
|
||||
|
||||
model Cliente {
|
||||
|
||||
@@ -2,8 +2,13 @@ import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import {
|
||||
procesarAlertasInventario,
|
||||
type ResultadoAlertas,
|
||||
} from '../services/alertas-inventario.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
||||
|
||||
const crearCotizacionSchema = z.object({
|
||||
unidadCodigo: z.string().min(1),
|
||||
@@ -23,6 +28,15 @@ const hoyISO = (): string => {
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
// Labels reales de la columna status "Tipo de Propuesta" del tablero Oportunidades
|
||||
const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
||||
BANCARIO: 'CREDITO BANCARIO',
|
||||
INFONAVIT: 'INFONAVIT-COFINAVIT',
|
||||
COFINAVIT: 'INFONAVIT-COFINAVIT',
|
||||
CONTADO: 'CONTADO',
|
||||
SEMI_CONTADO: 'SEMI CONTADO',
|
||||
}
|
||||
|
||||
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
|
||||
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const body = crearCotizacionSchema.parse(req.body)
|
||||
@@ -37,6 +51,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
const cotizacion = await prisma.cotizacion.create({
|
||||
data: {
|
||||
unidadCodigo: unidad.codigo,
|
||||
desarrollo: unidad.desarrollo ?? 'DIAGONAL',
|
||||
vendedorId: req.usuarioToken?.sub ?? null,
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteEmail: body.clienteEmail ?? null,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
@@ -59,7 +75,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
mondayItemId = await mondayService.crearOportunidad({
|
||||
clienteNombre: body.clienteNombre,
|
||||
unidadCodigo: unidad.codigo,
|
||||
tipoPropuestaLabel: body.tipoPropuesta,
|
||||
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
|
||||
precioLista,
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
@@ -74,7 +90,22 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
console.error('No se pudo crear la oportunidad en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones/mias — últimas 50 del vendedor autenticado (sub del JWT) */
|
||||
export async function listarMisCotizaciones(req: Request, res: Response): Promise<void> {
|
||||
const vendedorId = req.usuarioToken?.sub
|
||||
if (!vendedorId) {
|
||||
res.json([])
|
||||
return
|
||||
}
|
||||
const cotizaciones = await prisma.cotizacion.findMany({
|
||||
where: { vendedorId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
res.json(cotizaciones.map((c) => ({ ...c, folio: folioCotizacion(c.id) })))
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones */
|
||||
@@ -89,3 +120,91 @@ export async function obtenerCotizacion(req: Request, res: Response): Promise<vo
|
||||
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
||||
res.json(cotizacion)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cotizaciones/:id/publico — PÚBLICO (sin auth; es el destino del QR
|
||||
* del PDF). Devuelve SOLO datos de la propuesta comercial; nunca datos de
|
||||
* contacto del cliente (nombre/teléfono/email).
|
||||
*/
|
||||
export async function obtenerCotizacionPublica(req: Request, res: Response): Promise<void> {
|
||||
const cotizacion = await prisma.cotizacion.findUnique({
|
||||
where: { id: String(req.params['id']) },
|
||||
})
|
||||
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
||||
|
||||
let vendedorNombre: string | null = null
|
||||
if (cotizacion.vendedorId) {
|
||||
const vendedor = await prisma.usuario.findUnique({
|
||||
where: { id: cotizacion.vendedorId },
|
||||
select: { nombre: true },
|
||||
})
|
||||
vendedorNombre = vendedor?.nombre ?? null
|
||||
}
|
||||
|
||||
res.json({
|
||||
folio: folioCotizacion(cotizacion.id),
|
||||
desarrollo: cotizacion.desarrollo,
|
||||
unidadCodigo: cotizacion.unidadCodigo,
|
||||
tipoPropuesta: cotizacion.tipoPropuesta,
|
||||
precioFinal: cotizacion.precioFinal,
|
||||
enganche: cotizacion.enganche,
|
||||
mensualidad: cotizacion.mensualidad,
|
||||
pagoFinal: cotizacion.pagoFinal,
|
||||
vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(),
|
||||
vendedorNombre,
|
||||
})
|
||||
}
|
||||
|
||||
const alertasInventarioSchema = z.object({
|
||||
cambios: z
|
||||
.array(
|
||||
z.object({
|
||||
unidadCodigo: z.string().min(1),
|
||||
statusNuevo: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/cotizaciones/alertas-inventario — lo invoca el sync de estatus al
|
||||
* detectar cambios (ver README). Crea una Actividad en Monday al vendedor de
|
||||
* cada cotización reciente (≤30 días) de la unidad afectada, con dedup
|
||||
* persistente por unidad+cotización+estatus.
|
||||
*/
|
||||
export async function alertasInventarioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const { cambios } = alertasInventarioSchema.parse(req.body)
|
||||
|
||||
const resultado: ResultadoAlertas = await procesarAlertasInventario(
|
||||
{
|
||||
buscarCotizacionesRecientes: (unidadCodigo, desde) =>
|
||||
prisma.cotizacion.findMany({
|
||||
where: { unidadCodigo, createdAt: { gte: desde } },
|
||||
select: { id: true, vendedorId: true },
|
||||
}),
|
||||
mondayUserIdDeVendedor: async (vendedorId) => {
|
||||
const usuario = await prisma.usuario.findUnique({
|
||||
where: { id: vendedorId },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
return usuario?.mondayUserId ?? null
|
||||
},
|
||||
crearActividad: (datos) => mondayService.crearActividad(datos),
|
||||
yaAlertada: async (clave) => {
|
||||
const estado = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||
return estado !== null
|
||||
},
|
||||
marcarAlertada: async (clave) => {
|
||||
await prisma.automatizacionState.upsert({
|
||||
where: { clave },
|
||||
create: { clave, valor: new Date().toISOString() },
|
||||
update: { valor: new Date().toISOString() },
|
||||
})
|
||||
},
|
||||
},
|
||||
cambios,
|
||||
)
|
||||
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,30 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deja pasar GET /cotizaciones/:id/publico sin token (destino del QR)', () => {
|
||||
vi.clearAllMocks()
|
||||
authMiddleware(
|
||||
reqFake({ method: 'GET', path: '/cotizaciones/abc-123/publico' }),
|
||||
resFake().res,
|
||||
next,
|
||||
)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('el patrón público no abre otras rutas de cotizaciones', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'GET', path: '/cotizaciones/abc-123' },
|
||||
{ method: 'GET', path: '/cotizaciones/mias' },
|
||||
{ method: 'POST', path: '/cotizaciones/abc-123/publico' },
|
||||
{ method: 'GET', path: '/cotizaciones/abc-123/publico/extra' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake(c), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
|
||||
|
||||
@@ -12,6 +12,13 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
|
||||
{ metodo: 'POST', ruta: '/webhooks/monday' },
|
||||
]
|
||||
|
||||
// Rutas públicas con parámetros (se comparan contra req.path relativo a /api).
|
||||
// El endpoint público de cotización es el destino del QR del PDF: el cliente
|
||||
// final lo abre sin sesión; devuelve solo datos comerciales, nunca del cliente.
|
||||
const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
|
||||
{ metodo: 'GET', patron: /^\/cotizaciones\/[^/]+\/publico$/ },
|
||||
]
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
usuarioToken?: TokenPayload
|
||||
@@ -28,7 +35,9 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return
|
||||
}
|
||||
|
||||
const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta)
|
||||
const publica =
|
||||
RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta) ||
|
||||
PATRONES_PUBLICOS.some((r) => r.metodo === req.method && r.patron.test(req.path))
|
||||
if (publica) {
|
||||
next()
|
||||
return
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Router } from 'express'
|
||||
import { health } from '../controllers/health.controller.js'
|
||||
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
|
||||
import {
|
||||
alertasInventarioEndpoint,
|
||||
crearCotizacion,
|
||||
listarCotizaciones,
|
||||
listarMisCotizaciones,
|
||||
obtenerCotizacion,
|
||||
obtenerCotizacionPublica,
|
||||
} from '../controllers/cotizaciones.controller.js'
|
||||
import {
|
||||
crearCliente,
|
||||
@@ -59,6 +62,10 @@ apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||
|
||||
apiRouter.post('/cotizaciones', crearCotizacion)
|
||||
apiRouter.get('/cotizaciones', listarCotizaciones)
|
||||
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
|
||||
apiRouter.post('/cotizaciones/alertas-inventario', alertasInventarioEndpoint)
|
||||
// Público (sin auth): destino del QR del PDF; ver RUTAS_PUBLICAS/PATRONES_PUBLICOS
|
||||
apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||
|
||||
apiRouter.post('/clientes', crearCliente)
|
||||
|
||||
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
|
||||
}
|
||||
25
api-gateway/src/utils/folio.test.ts
Normal file
25
api-gateway/src/utils/folio.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { folioCotizacion, vigenciaCotizacion, VIGENCIA_DIAS } from './folio.js'
|
||||
|
||||
describe('folioCotizacion', () => {
|
||||
it('deriva COT-XXXXXX (6 hex mayúsculas) del uuid', () => {
|
||||
expect(folioCotizacion('3f8a1b2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3')).toBe('COT-3F8A1B')
|
||||
})
|
||||
|
||||
it('es determinista: mismo id, mismo folio', () => {
|
||||
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
expect(folioCotizacion(id)).toBe(folioCotizacion(id))
|
||||
})
|
||||
|
||||
it('tolera ids sin hex suficiente usando el id en mayúsculas', () => {
|
||||
expect(folioCotizacion('xyz')).toBe('COT-XYZ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vigenciaCotizacion', () => {
|
||||
it('suma VIGENCIA_DIAS días naturales a la emisión', () => {
|
||||
const creada = new Date('2026-08-28T12:00:00')
|
||||
const limite = vigenciaCotizacion(creada)
|
||||
expect(limite.getDate() - creada.getDate()).toBe(VIGENCIA_DIAS)
|
||||
})
|
||||
})
|
||||
18
api-gateway/src/utils/folio.ts
Normal file
18
api-gateway/src/utils/folio.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Folio corto de cotización, derivado de forma determinista del id (uuid).
|
||||
* Espejo de apps/cotizador/src/lib/folio.ts (mantener sincronizados).
|
||||
*/
|
||||
export function folioCotizacion(id: string): string {
|
||||
const hex = id.replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
||||
return `COT-${(hex || id.toUpperCase()).slice(0, 6)}`
|
||||
}
|
||||
|
||||
/** Días naturales de vigencia de la cotización (igual que en el PDF del cotizador) */
|
||||
export const VIGENCIA_DIAS = 3
|
||||
|
||||
/** Fecha límite de vigencia: emisión + VIGENCIA_DIAS días naturales */
|
||||
export function vigenciaCotizacion(creada: Date): Date {
|
||||
const limite = new Date(creada)
|
||||
limite.setDate(limite.getDate() + VIGENCIA_DIAS)
|
||||
return limite
|
||||
}
|
||||
Reference in New Issue
Block a user