import type { Request, Response } from 'express' import { z } from 'zod' import { mondayService, type DatosOportunidad } from '../services/monday.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 { 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 = { 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 { const body = crearCotizacionSchema.parse(req.body) const unidad = await mondayService.getUnidad(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 let mondayItemId: string | null = null try { 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 } : {}), } // 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) } 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 { 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 { 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 { 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 { 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 { 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) }