Files
FRASA-CAS/api-gateway/src/controllers/cotizaciones.controller.ts
Consult AS Technology 60649890eb feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
2026-09-06 08:08:56 +00:00

250 lines
9.8 KiB
TypeScript

import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
import { obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { funnelService } from '../services/funnel.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 { encolarSyncSeguro } from '../services/crm-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
const crearCotizacionSchema = z.object({
unidadCodigo: z.string().min(1),
acabados: z.boolean(),
apartado: z.number().positive().optional(),
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
// esquemas con crédito (bancario/Infonavit)
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
// Item del tablero Oportunidades al que queda vinculada la cotización
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(),
})
const hoyISO = (): string => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
// Labels reales de la columna status "Tipo de Propuesta" del tablero
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
export 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)
// Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno
const unidad = await obtenerUnidadConFallback(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
const precioLista = obtenerPrecioLista(unidad, body.acabados)
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,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados,
apartado,
descuento: propuesta.descuento,
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega)
mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal,
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó
// persistida y la vinculación queda encolada (COTIZACION_OPORTUNIDAD) para
// que el worker la aplique cuando Monday vuelva
let mondayItemId: string | null = null
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}
try {
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
// item directamente y la cotización queda vinculada al cliente.
const opExistente =
body.oportunidadId !== undefined
? body.oportunidadId
: body.clienteTelefono !== undefined
? await funnelService.oportunidadPorTelefono(body.clienteTelefono)
: null
if (opExistente !== null) {
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
mondayItemId = opExistente
} else {
mondayItemId = await mondayService.crearOportunidad(datosOportunidad)
}
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
await encolarSyncSeguro(
'COTIZACION_OPORTUNIDAD',
{
datos: datosOportunidad,
...(body.oportunidadId !== undefined ? { oportunidadId: body.oportunidadId } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
},
cotizacion.id,
)
}
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 */
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
res.json(cotizaciones)
}
/** GET /api/cotizaciones/:id */
export async function obtenerCotizacion(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')
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)
}