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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
}

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

View 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
}